mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-27 18:51:01 +00:00
Format all code using swift-format
This commit is contained in:
parent
42f880aaa8
commit
35e8cb6512
246 changed files with 5509 additions and 3908 deletions
|
@ -6,11 +6,10 @@
|
|||
//
|
||||
|
||||
import MobileCoreServices
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
import Models
|
||||
import Network
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// Sample code was sending this from a thread to another, let asume @Sendable for this
|
||||
extension NSExtensionContext: @unchecked @retroactive Sendable {}
|
||||
|
@ -79,11 +78,17 @@ extension ActionRequestHandler {
|
|||
guard itemProvider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
|
||||
continue
|
||||
}
|
||||
guard let dictionary = try await itemProvider.loadItem(forTypeIdentifier: UTType.propertyList.identifier) as? [String: Any] else {
|
||||
guard
|
||||
let dictionary = try await itemProvider.loadItem(
|
||||
forTypeIdentifier: UTType.propertyList.identifier) as? [String: Any]
|
||||
else {
|
||||
throw Error.loadedItemHasWrongType
|
||||
}
|
||||
let input = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as! [String: Any]? ?? [:]
|
||||
guard let absoluteStringUrl = input["url"] as? String, let url = URL(string: absoluteStringUrl) else {
|
||||
let input =
|
||||
dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as! [String: Any]? ?? [:]
|
||||
guard let absoluteStringUrl = input["url"] as? String,
|
||||
let url = URL(string: absoluteStringUrl)
|
||||
else {
|
||||
throw Error.urlNotFound
|
||||
}
|
||||
return url
|
||||
|
@ -96,7 +101,8 @@ extension ActionRequestHandler {
|
|||
private func output(wrapping deeplink: URL) -> [NSExtensionItem] {
|
||||
let results = ["deeplink": deeplink.absoluteString]
|
||||
let dictionary = [NSExtensionJavaScriptFinalizeArgumentKey: results]
|
||||
let provider = NSItemProvider(item: dictionary as NSDictionary, typeIdentifier: UTType.propertyList.identifier)
|
||||
let provider = NSItemProvider(
|
||||
item: dictionary as NSDictionary, typeIdentifier: UTType.propertyList.identifier)
|
||||
let item = NSExtensionItem()
|
||||
item.attachments = [provider]
|
||||
return [item]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import AVFoundation
|
||||
import Account
|
||||
import AppAccount
|
||||
import AVFoundation
|
||||
import DesignSystem
|
||||
import Env
|
||||
import KeychainSwift
|
||||
|
@ -32,7 +32,8 @@ struct AppView: View {
|
|||
#if os(visionOS)
|
||||
tabBarView
|
||||
#else
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac
|
||||
{
|
||||
sidebarView
|
||||
} else {
|
||||
tabBarView
|
||||
|
@ -54,11 +55,15 @@ struct AppView: View {
|
|||
|
||||
@ViewBuilder
|
||||
var tabBarView: some View {
|
||||
TabView(selection: .init(get: {
|
||||
TabView(
|
||||
selection: .init(
|
||||
get: {
|
||||
selectedTab
|
||||
}, set: { newTab in
|
||||
},
|
||||
set: { newTab in
|
||||
updateTab(with: newTab)
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
ForEach(availableTabs) { tab in
|
||||
tab.makeContentView(selectedTab: $selectedTab)
|
||||
.tabItem {
|
||||
|
@ -82,7 +87,9 @@ struct AppView: View {
|
|||
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
|
||||
|
@ -115,12 +122,15 @@ struct AppView: View {
|
|||
|
||||
#if !os(visionOS)
|
||||
var sidebarView: some View {
|
||||
SideBarView(selectedTab: .init(get: {
|
||||
SideBarView(
|
||||
selectedTab: .init(
|
||||
get: {
|
||||
selectedTab
|
||||
}, set: { newTab in
|
||||
},
|
||||
set: { newTab in
|
||||
updateTab(with: newTab)
|
||||
}), tabs: availableTabs)
|
||||
{
|
||||
}), tabs: availableTabs
|
||||
) {
|
||||
HStack(spacing: 0) {
|
||||
if #available(iOS 18.0, *) {
|
||||
baseTabView
|
||||
|
@ -171,8 +181,7 @@ struct AppView: View {
|
|||
}
|
||||
|
||||
var notificationsSecondaryColumn: some View {
|
||||
NotificationsTab(selectedTab: .constant(.notifications)
|
||||
, lockedType: nil)
|
||||
NotificationsTab(selectedTab: .constant(.notifications), lockedType: nil)
|
||||
.environment(\.isSecondaryColumn, true)
|
||||
.frame(maxWidth: .secondaryColumnWidth)
|
||||
.id(appAccountsManager.currentAccount.id)
|
||||
|
|
|
@ -17,9 +17,12 @@ extension IceCubesApp {
|
|||
.keyboardShortcut("n", modifiers: .shift)
|
||||
Button("menu.new-post") {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
|
||||
openWindow(
|
||||
value: WindowDestinationEditor.newStatusEditor(
|
||||
visibility: userPreferences.postVisibility))
|
||||
#else
|
||||
appRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
|
||||
appRouterPath.presentedSheet = .newStatusEditor(
|
||||
visibility: userPreferences.postVisibility)
|
||||
#endif
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
|
|
|
@ -27,15 +27,19 @@ extension IceCubesApp {
|
|||
.environment(\.isSupporter, isSupporter)
|
||||
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
|
||||
if #available(iOS 18.0, *) {
|
||||
MediaUIView(selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments)
|
||||
MediaUIView(
|
||||
selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments
|
||||
)
|
||||
.presentationBackground(.ultraThinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.presentationSizing(.page)
|
||||
.withEnvironments()
|
||||
} else {
|
||||
MediaUIView(selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments)
|
||||
MediaUIView(
|
||||
selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments
|
||||
)
|
||||
.presentationBackground(.ultraThinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.withEnvironments()
|
||||
|
@ -44,9 +48,11 @@ extension IceCubesApp {
|
|||
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
||||
if newValue != nil {
|
||||
pushNotificationsService.handledNotification = nil
|
||||
if appAccountsManager.currentAccount.oauthToken?.accessToken != newValue?.account.token.accessToken,
|
||||
let account = appAccountsManager.availableAccounts.first(where:
|
||||
{ $0.oauthToken?.accessToken == newValue?.account.token.accessToken })
|
||||
if appAccountsManager.currentAccount.oauthToken?.accessToken
|
||||
!= newValue?.account.token.accessToken,
|
||||
let account = appAccountsManager.availableAccounts.first(where: {
|
||||
$0.oauthToken?.accessToken == newValue?.account.token.accessToken
|
||||
})
|
||||
{
|
||||
appAccountsManager.currentAccount = account
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
|
@ -122,7 +128,8 @@ extension IceCubesApp {
|
|||
Group {
|
||||
switch destination.wrappedValue {
|
||||
case let .mediaViewer(attachments, selectedAttachment):
|
||||
MediaUIView(selectedAttachment: selectedAttachment,
|
||||
MediaUIView(
|
||||
selectedAttachment: selectedAttachment,
|
||||
attachments: attachments)
|
||||
case .none:
|
||||
EmptyView()
|
||||
|
@ -141,10 +148,13 @@ 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 ?? "",
|
||||
openWindow(
|
||||
value: WindowDestinationEditor.prefilledStatusEditor(
|
||||
text: postIntent.content ?? "",
|
||||
visibility: userPreferences.postVisibility))
|
||||
#else
|
||||
appRouterPath.presentedSheet = .prefilledStatusEditor(text: postIntent.content ?? "",
|
||||
appRouterPath.presentedSheet = .prefilledStatusEditor(
|
||||
text: postIntent.content ?? "",
|
||||
visibility: userPreferences.postVisibility)
|
||||
#endif
|
||||
} else if let tabIntent = appIntentService.handledIntent?.intent as? TabIntent {
|
||||
|
@ -152,7 +162,8 @@ extension IceCubesApp {
|
|||
} else if let imageIntent = appIntentService.handledIntent?.intent as? PostImageIntent,
|
||||
let urls = imageIntent.images?.compactMap({ $0.fileURL })
|
||||
{
|
||||
appRouterPath.presentedSheet = .imageURL(urls: urls,
|
||||
appRouterPath.presentedSheet = .imageURL(
|
||||
urls: urls,
|
||||
visibility: userPreferences.postVisibility)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import AVFoundation
|
||||
import Account
|
||||
import AppAccount
|
||||
import AVFoundation
|
||||
import DesignSystem
|
||||
import Env
|
||||
import KeychainSwift
|
||||
|
@ -53,7 +53,8 @@ struct IceCubesApp: App {
|
|||
userPreferences.setClient(client: client)
|
||||
Task {
|
||||
await currentInstance.fetchCurrentInstance()
|
||||
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
|
||||
watcher.setClient(
|
||||
client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
|
||||
watcher.watch(streams: [.user, .direct])
|
||||
}
|
||||
}
|
||||
|
@ -65,7 +66,8 @@ struct IceCubesApp: App {
|
|||
case .active:
|
||||
watcher.watch(streams: [.user, .direct])
|
||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||
userPreferences.reloadNotificationsCount(tokens: appAccountsManager.availableAccounts.compactMap(\.oauthToken))
|
||||
userPreferences.reloadNotificationsCount(
|
||||
tokens: appAccountsManager.availableAccounts.compactMap(\.oauthToken))
|
||||
Task {
|
||||
await userPreferences.refreshServerPreferences()
|
||||
}
|
||||
|
@ -90,9 +92,10 @@ struct IceCubesApp: App {
|
|||
}
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_: UIApplication,
|
||||
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||
{
|
||||
func application(
|
||||
_: UIApplication,
|
||||
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
|
||||
|
@ -102,9 +105,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
return true
|
||||
}
|
||||
|
||||
func application(_: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
|
||||
{
|
||||
func application(
|
||||
_: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
PushNotificationsService.shared.pushToken = deviceToken
|
||||
Task {
|
||||
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
|
||||
|
@ -114,12 +118,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
|
||||
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
|
||||
|
||||
func application(_: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
|
||||
UserPreferences.shared.reloadNotificationsCount(tokens: AppAccountsManager.shared.availableAccounts.compactMap(\.oauthToken))
|
||||
func application(_: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any]) async
|
||||
-> UIBackgroundFetchResult
|
||||
{
|
||||
UserPreferences.shared.reloadNotificationsCount(
|
||||
tokens: AppAccountsManager.shared.availableAccounts.compactMap(\.oauthToken))
|
||||
return .noData
|
||||
}
|
||||
|
||||
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
func application(
|
||||
_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options _: UIScene.ConnectionOptions
|
||||
) -> UISceneConfiguration {
|
||||
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
|
||||
if connectingSceneSession.role == .windowApplication {
|
||||
configuration.delegateClass = SceneDelegate.self
|
||||
|
|
|
@ -23,7 +23,8 @@ public struct ReportView: View {
|
|||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("report.comment.placeholder",
|
||||
TextField(
|
||||
"report.comment.placeholder",
|
||||
text: $commentText,
|
||||
axis: .vertical)
|
||||
}
|
||||
|
@ -47,7 +48,9 @@ public struct ReportView: View {
|
|||
Task {
|
||||
do {
|
||||
let _: ReportSent =
|
||||
try await client.post(endpoint: Statuses.report(accountId: status.account.id,
|
||||
try await client.post(
|
||||
endpoint: Statuses.report(
|
||||
accountId: status.account.id,
|
||||
statusId: status.id,
|
||||
comment: commentText))
|
||||
dismiss()
|
||||
|
|
|
@ -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,11 +27,14 @@ private struct SafariRouter: ViewModifier {
|
|||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
.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 }
|
||||
|
@ -41,7 +44,8 @@ private struct SafariRouter: ViewModifier {
|
|||
#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)
|
||||
}
|
||||
|
@ -58,10 +62,11 @@ private struct SafariRouter: ViewModifier {
|
|||
}
|
||||
} else if url.query()?.contains("callback=") == false,
|
||||
url.host() == AppInfo.premiumInstance,
|
||||
let accountName = appAccount.currentAccount.accountName {
|
||||
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)
|
||||
|
|
|
@ -36,7 +36,8 @@ struct SideBarView<Content: View>: View {
|
|||
private func makeIconForTab(tab: AppTab) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
HStack {
|
||||
SideBarIcon(systemIconName: tab.iconName,
|
||||
SideBarIcon(
|
||||
systemIconName: tab.iconName,
|
||||
isSelected: tab == selectedTab)
|
||||
if userPreferences.isSidebarExpanded {
|
||||
Text(tab.title)
|
||||
|
@ -45,9 +46,14 @@ 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(
|
||||
|
@ -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,12 +114,16 @@ struct SideBarView<Content: View>: View {
|
|||
} label: {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if userPreferences.isSidebarExpanded {
|
||||
AppAccountView(viewModel: .init(appAccount: account,
|
||||
AppAccountView(
|
||||
viewModel: .init(
|
||||
appAccount: account,
|
||||
isCompact: false,
|
||||
isInSettings: false),
|
||||
isParentPresented: .constant(false))
|
||||
} else {
|
||||
AppAccountView(viewModel: .init(appAccount: account,
|
||||
AppAccountView(
|
||||
viewModel: .init(
|
||||
appAccount: account,
|
||||
isCompact: true,
|
||||
isInSettings: false),
|
||||
isParentPresented: .constant(false))
|
||||
|
@ -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,7 +189,8 @@ struct SideBarView<Content: View>: View {
|
|||
tabsView
|
||||
} else {
|
||||
ForEach(appAccounts.availableAccounts) { account in
|
||||
makeAccountButton(account: account,
|
||||
makeAccountButton(
|
||||
account: account,
|
||||
showBadge: account.id != appAccounts.currentAccount.id)
|
||||
if account.id == appAccounts.currentAccount.id {
|
||||
tabsView
|
||||
|
@ -186,7 +202,9 @@ struct SideBarView<Content: View>: View {
|
|||
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.thinMaterial)
|
||||
.safeAreaInset(edge: .bottom, content: {
|
||||
.safeAreaInset(
|
||||
edge: .bottom,
|
||||
content: {
|
||||
HStack(spacing: 16) {
|
||||
postButton
|
||||
.padding(.vertical, 24)
|
||||
|
|
|
@ -57,7 +57,8 @@ struct NotificationsTab: View {
|
|||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
switch type {
|
||||
case .follow, .follow_request:
|
||||
routerPath.navigate(to: .accountDetailWithAccount(account: newValue.notification.account))
|
||||
routerPath.navigate(
|
||||
to: .accountDetailWithAccount(account: newValue.notification.account))
|
||||
default:
|
||||
if let status = newValue.notification.status {
|
||||
routerPath.navigate(to: .statusDetailWithStatus(status: status))
|
||||
|
@ -81,7 +82,9 @@ struct NotificationsTab: View {
|
|||
|
||||
private func clearNotifications() {
|
||||
if selectedTab == .notifications || isSecondaryColumn {
|
||||
if let token = appAccount.currentAccount.oauthToken, userPreferences.notificationsCount[token] ?? 0 > 0 {
|
||||
if let token = appAccount.currentAccount.oauthToken,
|
||||
userPreferences.notificationsCount[token] ?? 0 > 0
|
||||
{
|
||||
userPreferences.notificationsCount[token] = 0
|
||||
}
|
||||
if watcher.unreadNotificationsCount > 0 {
|
||||
|
|
|
@ -49,11 +49,15 @@ 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: {
|
||||
|
@ -78,7 +82,8 @@ struct AboutView: View {
|
|||
#endif
|
||||
|
||||
Section {
|
||||
Text("""
|
||||
Text(
|
||||
"""
|
||||
• [EmojiText](https://github.com/divadretlaw/EmojiText)
|
||||
|
||||
• [HTML2Markdown](https://gitlab.com/mflint/HTML2Markdown)
|
||||
|
@ -102,7 +107,8 @@ struct AboutView: View {
|
|||
• [RevenueCat](https://github.com/RevenueCat/purchases-ios)
|
||||
|
||||
• [SFSafeSymbols](https://github.com/SFSafeSymbols/SFSafeSymbols)
|
||||
""")
|
||||
"""
|
||||
)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.scaledSubheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
@ -124,7 +130,9 @@ struct AboutView: View {
|
|||
#endif
|
||||
.navigationTitle(Text("settings.about.title"))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
}
|
||||
|
@ -152,13 +160,15 @@ struct AboutView: View {
|
|||
private func fetchAccounts() async {
|
||||
await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
let viewModel = try await fetchAccountViewModel(client, account: "dimillian@mastodon.social")
|
||||
let viewModel = try await fetchAccountViewModel(
|
||||
client, account: "dimillian@mastodon.social")
|
||||
await MainActor.run {
|
||||
dimillianAccount = viewModel
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
let viewModel = try await fetchAccountViewModel(client, account: "icecubesapp@mastodon.online")
|
||||
let viewModel = try await fetchAccountViewModel(
|
||||
client, account: "icecubesapp@mastodon.online")
|
||||
await MainActor.run {
|
||||
iceCubesAccount = viewModel
|
||||
}
|
||||
|
@ -166,9 +176,12 @@ struct AboutView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func fetchAccountViewModel(_ client: Client, account: String) async throws -> AccountsListRowViewModel {
|
||||
private func fetchAccountViewModel(_ client: Client, account: String) async throws
|
||||
-> AccountsListRowViewModel
|
||||
{
|
||||
let dimillianAccount: Account = try await client.get(endpoint: Accounts.lookup(name: account))
|
||||
let rel: [Relationship] = try await client.get(endpoint: Accounts.relationships(ids: [dimillianAccount.id]))
|
||||
let rel: [Relationship] = try await client.get(
|
||||
endpoint: Accounts.relationships(ids: [dimillianAccount.id]))
|
||||
return .init(account: dimillianAccount, relationShip: rel.first)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,20 +47,25 @@ struct AccountSettingsView: View {
|
|||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if let subscription = pushNotifications.subscriptions.first(where: { $0.account.token == appAccount.oauthToken }) {
|
||||
if let subscription = pushNotifications.subscriptions.first(where: {
|
||||
$0.account.token == appAccount.oauthToken
|
||||
}) {
|
||||
NavigationLink(destination: PushNotificationsView(subscription: subscription)) {
|
||||
Label("settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
|
||||
Label(
|
||||
"settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
|
||||
Section {
|
||||
Label("settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
|
||||
Label(
|
||||
"settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
|
||||
Button("settings.account.action.delete-cache", role: .destructive) {
|
||||
Task {
|
||||
await timelineCache.clearCache(for: appAccountsManager.currentClient.id)
|
||||
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id)
|
||||
cachedPostsCount = await timelineCache.cachedPostsCount(
|
||||
for: appAccountsManager.currentClient.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -81,7 +86,9 @@ struct AccountSettingsView: View {
|
|||
Task {
|
||||
let client = Client(server: appAccount.server, oauthToken: token)
|
||||
await timelineCache.clearCache(for: client.id)
|
||||
if let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) {
|
||||
if let sub = pushNotifications.subscriptions.first(where: {
|
||||
$0.account.token == token
|
||||
}) {
|
||||
await sub.deleteSubscription()
|
||||
}
|
||||
appAccountsManager.delete(account: appAccount)
|
||||
|
@ -106,7 +113,8 @@ struct AccountSettingsView: View {
|
|||
}
|
||||
}
|
||||
.task {
|
||||
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id)
|
||||
cachedPostsCount = await timelineCache.cachedPostsCount(
|
||||
for: appAccountsManager.currentClient.id)
|
||||
}
|
||||
.navigationTitle(account.safeDisplayName)
|
||||
#if !os(visionOS)
|
||||
|
|
|
@ -35,7 +35,8 @@ 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: "")
|
||||
|
||||
|
@ -123,11 +124,11 @@ struct AddAccountView: View {
|
|||
do {
|
||||
// bare bones preflight for domain validity
|
||||
let instanceDetailClient = Client(server: sanitizedName)
|
||||
if
|
||||
instanceDetailClient.server.contains("."),
|
||||
if instanceDetailClient.server.contains("."),
|
||||
instanceDetailClient.server.last != "."
|
||||
{
|
||||
let instance: Instance = try await instanceDetailClient.get(endpoint: Instances.instance)
|
||||
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
|
||||
|
@ -222,7 +223,10 @@ struct AddAccountView: View {
|
|||
.foregroundStyle(theme.tintColor)
|
||||
}
|
||||
.padding(.bottom, 5)
|
||||
Text(instance.info?.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
|
||||
Text(
|
||||
instance.info?.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
?? ""
|
||||
)
|
||||
.foregroundStyle(Color.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
|
@ -273,7 +277,8 @@ 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,
|
||||
let url = try? await webAuthenticationSession.authenticate(
|
||||
using: oauthURL,
|
||||
callbackURLScheme: AppInfo.scheme.replacingOccurrences(of: "://", with: ""))
|
||||
{
|
||||
await continueSignIn(url: url)
|
||||
|
@ -292,7 +297,9 @@ 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,
|
||||
appAccountsManager.add(
|
||||
account: AppAccount(
|
||||
server: client.server,
|
||||
accountName: "\(account.acct)@\(client.server)",
|
||||
oauthToken: oauthToken))
|
||||
Task {
|
||||
|
|
|
@ -34,7 +34,10 @@ struct ContentSettingsView: View {
|
|||
#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)
|
||||
|
@ -89,17 +92,23 @@ struct ContentSettingsView: View {
|
|||
#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)
|
||||
}
|
||||
|
|
|
@ -29,7 +29,8 @@ struct DisplaySettingsView: View {
|
|||
|
||||
@State private var isFontSelectorPresented = false
|
||||
|
||||
private let previewStatusViewModel = StatusRowViewModel(status: Status.placeholder(forSettings: true, language: "la"),
|
||||
private let previewStatusViewModel = StatusRowViewModel(
|
||||
status: Status.placeholder(forSettings: true, language: "la"),
|
||||
client: Client(server: ""),
|
||||
routerPath: RouterPath()) // translate from latin button
|
||||
|
||||
|
@ -96,7 +97,9 @@ struct DisplaySettingsView: View {
|
|||
Rectangle()
|
||||
.fill(theme.secondaryBackgroundColor)
|
||||
.frame(height: 30)
|
||||
.mask(LinearGradient(gradient: Gradient(colors: [theme.secondaryBackgroundColor, .clear]),
|
||||
.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)
|
||||
|
@ -135,7 +141,10 @@ struct DisplaySettingsView: View {
|
|||
|
||||
private var fontSection: some View {
|
||||
Section("settings.display.section.font") {
|
||||
Picker("settings.display.font", selection: .init(get: { () -> FontState in
|
||||
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" {
|
||||
|
@ -144,7 +153,8 @@ struct DisplaySettingsView: View {
|
|||
return FontState.SFRounded
|
||||
}
|
||||
return theme.chosenFontData != nil ? FontState.custom : FontState.system
|
||||
}, set: { newValue in
|
||||
},
|
||||
set: { newValue in
|
||||
switch newValue {
|
||||
case .system:
|
||||
theme.chosenFont = nil
|
||||
|
@ -157,7 +167,8 @@ struct DisplaySettingsView: View {
|
|||
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,8 +185,10 @@ 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))")
|
||||
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
|
||||
|
@ -224,12 +237,17 @@ struct DisplaySettingsView: View {
|
|||
Toggle("settings.display.show-reply-indentation", isOn: $userPreferences.showReplyIndentation)
|
||||
if userPreferences.showReplyIndentation {
|
||||
VStack {
|
||||
Slider(value: .init(get: {
|
||||
Slider(
|
||||
value: .init(
|
||||
get: {
|
||||
Double(userPreferences.maxReplyIndentation)
|
||||
}, set: { newVal in
|
||||
},
|
||||
set: { newVal in
|
||||
userPreferences.maxReplyIndentation = UInt(newVal)
|
||||
}), in: 1 ... 20, step: 1)
|
||||
Text("settings.display.max-reply-indentation-\(String(userPreferences.maxReplyIndentation))")
|
||||
}), in: 1...20, step: 1)
|
||||
Text(
|
||||
"settings.display.max-reply-indentation-\(String(userPreferences.maxReplyIndentation))"
|
||||
)
|
||||
.font(.scaledBody)
|
||||
}
|
||||
.alignmentGuide(.listRowSeparatorLeading) { d in
|
||||
|
|
|
@ -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
|
||||
|
@ -44,25 +45,45 @@ struct IconSelectorView: View {
|
|||
let icons: [Icon]
|
||||
|
||||
static let items = [
|
||||
IconSelector(title: "settings.app.icon.official".localized, icons: [
|
||||
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]),
|
||||
.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))]
|
||||
|
||||
|
|
|
@ -18,16 +18,20 @@ struct PushNotificationsView: View {
|
|||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle(isOn: .init(get: {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isEnabled
|
||||
}, set: { newValue in
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isEnabled = newValue
|
||||
if newValue {
|
||||
updateSubscription()
|
||||
} else {
|
||||
deleteSubscription()
|
||||
}
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
Text("settings.push.main-toggle")
|
||||
}
|
||||
} footer: {
|
||||
|
@ -39,52 +43,76 @@ struct PushNotificationsView: View {
|
|||
|
||||
if subscription.isEnabled {
|
||||
Section {
|
||||
Toggle(isOn: .init(get: {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isMentionNotificationEnabled
|
||||
}, set: { newValue in
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isMentionNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
Label("settings.push.mentions", systemImage: "at")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isFollowNotificationEnabled
|
||||
}, set: { newValue in
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isFollowNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
Label("settings.push.follows", systemImage: "person.badge.plus")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isFavoriteNotificationEnabled
|
||||
}, set: { newValue in
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isFavoriteNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
Label("settings.push.favorites", systemImage: "star")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isReblogNotificationEnabled
|
||||
}, set: { newValue in
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isReblogNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
Label("settings.push.boosts", image: "Rocket")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isPollNotificationEnabled
|
||||
}, set: { newValue in
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isPollNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
Label("settings.push.polls", systemImage: "chart.bar")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isNewPostsNotificationEnabled
|
||||
}, set: { newValue in
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isNewPostsNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
Label("settings.push.new-posts", systemImage: "bubble.right")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,7 +60,9 @@ struct SettingsTabs: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn, !isModal {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn,
|
||||
!isModal
|
||||
{
|
||||
SecondaryColumnToolbarItem()
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -259,7 +263,9 @@ 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)
|
||||
|
@ -276,7 +282,9 @@ 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)
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -337,7 +348,8 @@ 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)
|
||||
|
|
|
@ -72,16 +72,32 @@ 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: {
|
||||
}
|
||||
)
|
||||
.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()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
|
|
@ -14,31 +14,39 @@ struct SwipeActionsSettingsView: View {
|
|||
Label("settings.swipeactions.status.leading", systemImage: "arrow.right")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusLeadingLeft,
|
||||
label: "settings.swipeactions.primary")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
createStatusActionPicker(
|
||||
selection: $userPreferences.swipeActionsStatusTrailingLeft,
|
||||
label: "settings.swipeactions.secondary"
|
||||
)
|
||||
.disabled(userPreferences.swipeActionsStatusTrailingRight == .none)
|
||||
|
||||
} header: {
|
||||
|
@ -51,7 +59,10 @@ struct SwipeActionsSettingsView: View {
|
|||
#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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
|
|
@ -111,7 +111,8 @@ struct TranslationSettingsView: View {
|
|||
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 {
|
||||
|
|
|
@ -3,6 +3,6 @@ import WishKit
|
|||
|
||||
struct WishlistView: View {
|
||||
var body: some View {
|
||||
WishKit.view
|
||||
WishKit.FeedbackListView()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -350,9 +353,12 @@ private struct SymbolSearchResultsView: View {
|
|||
!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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -68,7 +68,9 @@ struct AddRemoteTimelineView: View {
|
|||
.onChange(of: instanceName) { _, newValue in
|
||||
instanceNamePublisher.send(newValue)
|
||||
}
|
||||
.onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { newValue in
|
||||
.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)
|
||||
|
@ -91,7 +93,10 @@ struct AddRemoteTimelineView: View {
|
|||
ProgressView()
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
} else {
|
||||
ForEach(instanceName.isEmpty ? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }) { instance in
|
||||
ForEach(
|
||||
instanceName.isEmpty
|
||||
? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }
|
||||
) { instance in
|
||||
Button {
|
||||
instanceName = instance.name
|
||||
} label: {
|
||||
|
|
|
@ -38,10 +38,12 @@ struct TimelineTab: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationStack(path: $routerPath.path) {
|
||||
TimelineView(timeline: $timeline,
|
||||
TimelineView(
|
||||
timeline: $timeline,
|
||||
pinnedFilters: $pinnedFilters,
|
||||
selectedTagGroup: $selectedTagGroup,
|
||||
canFilterTimeline: canFilterTimeline)
|
||||
canFilterTimeline: canFilterTimeline
|
||||
)
|
||||
.withAppRouter()
|
||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||
.toolbar {
|
||||
|
@ -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,7 +193,8 @@ struct TimelineTab: View {
|
|||
timeline = .resume
|
||||
} label: {
|
||||
VStack {
|
||||
Label(TimelineFilter.resume.localizedTitle(),
|
||||
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,9 +309,11 @@ struct TimelineTab: View {
|
|||
}
|
||||
|
||||
private var contentFilterButton: some View {
|
||||
Button(action: {
|
||||
Button(
|
||||
action: {
|
||||
routerPath.presentedSheet = .timelineContentFilter
|
||||
}, label: {
|
||||
},
|
||||
label: {
|
||||
Label("timeline.content-filter.title", systemSymbol: .line3HorizontalDecrease)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
statusEditorToolbarItem(
|
||||
routerPath: routerPath,
|
||||
visibility: userPreferences.postVisibility)
|
||||
if UIDevice.current.userInterfaceIdiom != .pad ||
|
||||
(UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact)
|
||||
if UIDevice.current.userInterfaceIdiom != .pad
|
||||
|| (UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact)
|
||||
{
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
AppAccountsSelectorView(routerPath: routerPath, avatarConfig: theme.avatarShape == .circle ? .badge : .badgeRounded)
|
||||
AppAccountsSelectorView(
|
||||
routerPath: routerPath,
|
||||
avatarConfig: theme.avatarShape == .circle ? .badge : .badgeRounded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@ import SwiftUI
|
|||
@Observable
|
||||
public class AppIntentService: @unchecked Sendable {
|
||||
struct HandledIntent: Equatable {
|
||||
static func == (lhs: AppIntentService.HandledIntent, rhs: AppIntentService.HandledIntent) -> Bool {
|
||||
static func == (lhs: AppIntentService.HandledIntent, rhs: AppIntentService.HandledIntent)
|
||||
-> Bool
|
||||
{
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ struct AppShortcuts: AppShortcutsProvider {
|
|||
AppShortcut(
|
||||
intent: TabIntent(),
|
||||
phrases: [
|
||||
"Open \(.applicationName)",
|
||||
"Open \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Open Ice Cubes",
|
||||
systemImageName: "cube"
|
||||
|
|
|
@ -9,10 +9,12 @@ enum PostVisibility: String, AppEnum {
|
|||
case direct, priv, unlisted, pub
|
||||
|
||||
public static var caseDisplayRepresentations: [PostVisibility: DisplayRepresentation] {
|
||||
[.direct: "Private",
|
||||
[
|
||||
.direct: "Private",
|
||||
.priv: "Followers Only",
|
||||
.unlisted: "Quiet Public",
|
||||
.pub: "Public"]
|
||||
.pub: "Public",
|
||||
]
|
||||
}
|
||||
|
||||
static var typeDisplayName: LocalizedStringResource { "Visibility" }
|
||||
|
@ -44,12 +46,15 @@ struct InlinePostIntent: AppIntent {
|
|||
@Parameter(title: "Post visibility", requestValueDialog: IntentDialog("Visibility of your post"))
|
||||
var visibility: PostVisibility
|
||||
|
||||
@Parameter(title: "Post content", requestValueDialog: IntentDialog("Content of the post to be sent to Mastodon"))
|
||||
@Parameter(
|
||||
title: "Post content",
|
||||
requestValueDialog: IntentDialog("Content of the post to be sent to Mastodon"))
|
||||
var content: String
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
|
||||
let client = Client(server: account.account.server, version: .v1, oauthToken: account.account.oauthToken)
|
||||
let client = Client(
|
||||
server: account.account.server, version: .v1, oauthToken: account.account.oauthToken)
|
||||
let status = StatusData(status: content, visibility: visibility.toAppVisibility)
|
||||
do {
|
||||
let status: Status = try await client.post(endpoint: Statuses.postStatus(json: status))
|
||||
|
|
|
@ -3,10 +3,12 @@ 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",
|
||||
@Parameter(
|
||||
title: "Image",
|
||||
description: "Image to post on Mastodon",
|
||||
supportedTypeIdentifiers: ["public.image"],
|
||||
inputConnectionBehavior: .connectToPreviousIntentResult)
|
||||
|
|
|
@ -17,7 +17,8 @@ enum TabEnum: String, AppEnum, Sendable {
|
|||
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Tab"
|
||||
|
||||
nonisolated static var caseDisplayRepresentations: [TabEnum: DisplayRepresentation] {
|
||||
[.timeline: .init(title: "Home Timeline"),
|
||||
[
|
||||
.timeline: .init(title: "Home Timeline"),
|
||||
.trending: .init(title: "Trending Timeline"),
|
||||
.federated: .init(title: "Federated Timeline"),
|
||||
.local: .init(title: "Local Timeline"),
|
||||
|
@ -32,7 +33,8 @@ enum TabEnum: String, AppEnum, Sendable {
|
|||
.followedTags: .init(title: "Followed Tags"),
|
||||
.lists: .init(title: "Lists"),
|
||||
.links: .init(title: "Trending Links"),
|
||||
.post: .init(title: "New post")]
|
||||
.post: .init(title: "New post"),
|
||||
]
|
||||
}
|
||||
|
||||
var toAppTab: AppTab {
|
||||
|
|
|
@ -10,15 +10,20 @@ 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)],
|
||||
return .init(
|
||||
entries: [.init(date: Date(), account: account, avatar: images?.first?.value)],
|
||||
policy: .atEnd)
|
||||
}
|
||||
|
||||
|
@ -26,7 +31,8 @@ struct AccountWidgetProvider: AppIntentTimelineProvider {
|
|||
guard let account = configuration.account else {
|
||||
return .placeholder()
|
||||
}
|
||||
let client = Client(server: account.account.server,
|
||||
let client = Client(
|
||||
server: account.account.server,
|
||||
oauthToken: account.account.oauthToken)
|
||||
do {
|
||||
let account: Account = try await client.get(endpoint: Accounts.verifyCredentials)
|
||||
|
@ -47,10 +53,11 @@ struct AccountWidget: Widget {
|
|||
let kind: String = "AccountWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: AccountWidgetConfiguration.self,
|
||||
provider: AccountWidgetProvider())
|
||||
{ entry in
|
||||
provider: AccountWidgetProvider()
|
||||
) { entry in
|
||||
AccountWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
|
|
@ -7,49 +7,70 @@ import WidgetKit
|
|||
|
||||
struct HashtagPostsWidgetProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in _: Context) -> PostsWidgetEntry {
|
||||
.init(date: Date(),
|
||||
.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(),
|
||||
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(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
let timeline: TimelineFilter = .hashtag(tag: hashgtag, accountId: nil)
|
||||
let statuses = await loadStatuses(for: timeline,
|
||||
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(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: timeline.title,
|
||||
statuses: statuses,
|
||||
images: images)], policy: .atEnd)
|
||||
images: images)
|
||||
], policy: .atEnd)
|
||||
} catch {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
@ -59,10 +80,11 @@ struct HashtagPostsWidget: Widget {
|
|||
let kind: String = "HashtagPostsWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: HashtagPostsWidgetConfiguration.self,
|
||||
provider: HashtagPostsWidgetProvider())
|
||||
{ entry in
|
||||
provider: HashtagPostsWidgetProvider()
|
||||
) { entry in
|
||||
PostsWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
@ -75,7 +97,8 @@ struct HashtagPostsWidget: Widget {
|
|||
#Preview(as: .systemMedium) {
|
||||
HashtagPostsWidget()
|
||||
} timeline: {
|
||||
PostsWidgetEntry(date: .now,
|
||||
PostsWidgetEntry(
|
||||
date: .now,
|
||||
title: "#Mastodon",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
|
|
|
@ -7,48 +7,69 @@ import WidgetKit
|
|||
|
||||
struct LatestPostsWidgetProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in _: Context) -> PostsWidgetEntry {
|
||||
.init(date: Date(),
|
||||
.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(),
|
||||
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(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
let statuses = await loadStatuses(for: timeline.timeline,
|
||||
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(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: timeline.timeline.title,
|
||||
statuses: statuses,
|
||||
images: images)], policy: .atEnd)
|
||||
images: images)
|
||||
], policy: .atEnd)
|
||||
} catch {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: configuration.timeline?.timeline.title ?? "",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
@ -77,10 +98,11 @@ struct LatestPostsWidget: Widget {
|
|||
let kind: String = "LatestPostsWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: LatestPostsWidgetConfiguration.self,
|
||||
provider: LatestPostsWidgetProvider())
|
||||
{ entry in
|
||||
provider: LatestPostsWidgetProvider()
|
||||
) { entry in
|
||||
PostsWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
@ -93,7 +115,8 @@ struct LatestPostsWidget: Widget {
|
|||
#Preview(as: .systemMedium) {
|
||||
LatestPostsWidget()
|
||||
} timeline: {
|
||||
PostsWidgetEntry(date: .now,
|
||||
PostsWidgetEntry(
|
||||
date: .now,
|
||||
title: "Mastodon",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
|
|
|
@ -7,49 +7,70 @@ import WidgetKit
|
|||
|
||||
struct ListsWidgetProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in _: Context) -> PostsWidgetEntry {
|
||||
.init(date: Date(),
|
||||
.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(),
|
||||
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(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "List name",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
let filter: TimelineFilter = .list(list: timeline.list)
|
||||
let statuses = await loadStatuses(for: filter,
|
||||
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(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: filter.title,
|
||||
statuses: statuses,
|
||||
images: images)], policy: .atEnd)
|
||||
images: images)
|
||||
], policy: .atEnd)
|
||||
} catch {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "List name",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
@ -59,10 +80,11 @@ struct ListsPostWidget: Widget {
|
|||
let kind: String = "ListsPostWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: ListsWidgetConfiguration.self,
|
||||
provider: ListsWidgetProvider())
|
||||
{ entry in
|
||||
provider: ListsWidgetProvider()
|
||||
) { entry in
|
||||
PostsWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
@ -75,7 +97,8 @@ struct ListsPostWidget: Widget {
|
|||
#Preview(as: .systemMedium) {
|
||||
ListsPostWidget()
|
||||
} timeline: {
|
||||
PostsWidgetEntry(date: .now,
|
||||
PostsWidgetEntry(
|
||||
date: .now,
|
||||
title: "List name",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
|
|
|
@ -7,55 +7,78 @@ import WidgetKit
|
|||
|
||||
struct MentionsWidgetProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in _: Context) -> PostsWidgetEntry {
|
||||
.init(date: Date(),
|
||||
.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(),
|
||||
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(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
let client = Client(server: account.account.server,
|
||||
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,
|
||||
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(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: statuses,
|
||||
images: images)], policy: .atEnd)
|
||||
images: images)
|
||||
], policy: .atEnd)
|
||||
} catch {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
@ -65,10 +88,11 @@ struct MentionsWidget: Widget {
|
|||
let kind: String = "MentionsWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: MentionsWidgetConfiguration.self,
|
||||
provider: MentionsWidgetProvider())
|
||||
{ entry in
|
||||
provider: MentionsWidgetProvider()
|
||||
) { entry in
|
||||
PostsWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
@ -81,7 +105,8 @@ struct MentionsWidget: Widget {
|
|||
#Preview(as: .systemMedium) {
|
||||
MentionsWidget()
|
||||
} timeline: {
|
||||
PostsWidgetEntry(date: .now,
|
||||
PostsWidgetEntry(
|
||||
date: .now,
|
||||
title: "Mentions",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
|
|
|
@ -52,7 +52,9 @@ struct PostsWidgetView: View {
|
|||
@ViewBuilder
|
||||
private func makeStatusView(_ status: Status) -> some View {
|
||||
if let url = URL(string: status.url ?? "") {
|
||||
Link(destination: url, label: {
|
||||
Link(
|
||||
destination: url,
|
||||
label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
makeStatusHeaderView(status)
|
||||
Text(status.content.asSafeMarkdownAttributedString)
|
||||
|
|
|
@ -7,13 +7,16 @@ import Timeline
|
|||
import UIKit
|
||||
import WidgetKit
|
||||
|
||||
func loadStatuses(for timeline: TimelineFilter,
|
||||
func loadStatuses(
|
||||
for timeline: TimelineFilter,
|
||||
account: AppAccountEntity,
|
||||
widgetFamily: WidgetFamily) async -> [Status]
|
||||
{
|
||||
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,
|
||||
var statuses: [Status] = try await client.get(
|
||||
endpoint: timeline.endpoint(
|
||||
sinceId: nil,
|
||||
maxId: nil,
|
||||
minId: nil,
|
||||
offset: nil,
|
||||
|
|
|
@ -9,15 +9,18 @@ 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,
|
||||
let casted = unsafeBitCast(
|
||||
contentHandler,
|
||||
to: (@Sendable (UNNotificationContent) -> Void).self)
|
||||
Task {
|
||||
if let content = await provider.buildContent() {
|
||||
|
@ -61,12 +64,15 @@ actor NotificationServiceContentProvider {
|
|||
return bestAttemptContent
|
||||
}
|
||||
|
||||
guard let plaintextData = NotificationService.decrypt(payload: payload,
|
||||
guard
|
||||
let plaintextData = NotificationService.decrypt(
|
||||
payload: payload,
|
||||
salt: salt,
|
||||
auth: auth,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey),
|
||||
let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData)
|
||||
let notification = try? JSONDecoder().decode(
|
||||
MastodonPushNotification.self, from: plaintextData)
|
||||
else {
|
||||
return bestAttemptContent
|
||||
}
|
||||
|
@ -77,14 +83,18 @@ actor NotificationServiceContentProvider {
|
|||
}
|
||||
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)
|
||||
|
||||
|
@ -100,16 +110,19 @@ actor NotificationServiceContentProvider {
|
|||
if let remoteNotification = await toRemoteNotification(localNotification: notification),
|
||||
let type = remoteNotification.supportedType
|
||||
{
|
||||
let intent = buildMessageIntent(remoteNotification: remoteNotification,
|
||||
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,
|
||||
if let attachment = try? UNNotificationAttachment(
|
||||
identifier: filename,
|
||||
url: fileURL,
|
||||
options: nil) {
|
||||
options: nil)
|
||||
{
|
||||
bestAttemptContent.attachments = [attachment]
|
||||
}
|
||||
}
|
||||
|
@ -134,12 +149,16 @@ 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 {
|
||||
|
@ -148,13 +167,15 @@ actor NotificationServiceContentProvider {
|
|||
return nil
|
||||
}
|
||||
|
||||
private func buildMessageIntent(remoteNotification: Models.Notification,
|
||||
private func buildMessageIntent(
|
||||
remoteNotification: Models.Notification,
|
||||
currentUser: String,
|
||||
avatarURL: URL) -> INSendMessageIntent
|
||||
{
|
||||
avatarURL: URL
|
||||
) -> INSendMessageIntent {
|
||||
let handle = INPersonHandle(value: remoteNotification.account.id, type: .unknown)
|
||||
let avatar = INImage(url: avatarURL)
|
||||
let sender = INPerson(personHandle: handle,
|
||||
let sender = INPerson(
|
||||
personHandle: handle,
|
||||
nameComponents: nil,
|
||||
displayName: remoteNotification.account.safeDisplayName,
|
||||
image: avatar,
|
||||
|
@ -163,7 +184,8 @@ actor NotificationServiceContentProvider {
|
|||
var recipents: [INPerson]?
|
||||
var groupName: INSpeakableString?
|
||||
if keychainAccounts.count > 1 {
|
||||
let me = INPerson(personHandle: .init(value: currentUser, type: .unknown),
|
||||
let me = INPerson(
|
||||
personHandle: .init(value: currentUser, type: .unknown),
|
||||
nameComponents: nil,
|
||||
displayName: currentUser,
|
||||
image: nil,
|
||||
|
@ -172,7 +194,8 @@ actor NotificationServiceContentProvider {
|
|||
recipents = [me, sender]
|
||||
groupName = .init(spokenPhrase: currentUser)
|
||||
}
|
||||
let intent = INSendMessageIntent(recipients: recipents,
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: recipents,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: nil,
|
||||
speakableGroupName: groupName,
|
||||
|
@ -192,7 +215,9 @@ actor NotificationServiceContentProvider {
|
|||
let tokens = AppAccountsManager.shared.pushAccounts.map(\.token)
|
||||
preferences.reloadNotificationsCount(tokens: tokens)
|
||||
|
||||
if let token = keychainAccounts.first(where: { $0.oauthToken?.accessToken == notification.accessToken })?.oauthToken {
|
||||
if let token = keychainAccounts.first(where: {
|
||||
$0.oauthToken?.accessToken == notification.accessToken
|
||||
})?.oauthToken {
|
||||
var currentCount = preferences.notificationsCount[token] ?? 0
|
||||
currentCount += 1
|
||||
preferences.notificationsCount[token] = currentCount
|
||||
|
|
|
@ -2,18 +2,29 @@ import CryptoKit
|
|||
import Foundation
|
||||
|
||||
extension NotificationService {
|
||||
static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? {
|
||||
static func decrypt(
|
||||
payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey,
|
||||
publicKey: P256.KeyAgreement.PublicKey
|
||||
) -> Data? {
|
||||
guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32)
|
||||
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(
|
||||
using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8),
|
||||
outputByteCount: 32)
|
||||
|
||||
let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
|
||||
let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
|
||||
let keyInfo = info(
|
||||
type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation,
|
||||
serverPublicKey: publicKey.x963Representation)
|
||||
let key = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
|
||||
|
||||
let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
|
||||
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||
let nonceInfo = info(
|
||||
type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation,
|
||||
serverPublicKey: publicKey.x963Representation)
|
||||
let nonce = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||
|
||||
let nonceData = nonce.withUnsafeBytes(Array.init)
|
||||
|
||||
|
|
|
@ -59,10 +59,11 @@ class ShareViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: .shareSheetClose,
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .shareSheetClose,
|
||||
object: nil,
|
||||
queue: nil)
|
||||
{ [weak self] _ in
|
||||
queue: nil
|
||||
) { [weak self] _ in
|
||||
self?.close()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "Account",
|
||||
targets: ["Account"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Network", path: "../Network"),
|
||||
|
@ -36,7 +36,7 @@ let package = Package(
|
|||
.product(name: "WrappingHStack", package: "WrappingHStack"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
|
|
|
@ -18,7 +18,8 @@ public struct AccountDetailContextMenu: View {
|
|||
Section(account.acct) {
|
||||
if !viewModel.isCurrentUser {
|
||||
Button {
|
||||
routerPath.presentedSheet = .mentionStatusEditor(account: account,
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
@ -84,7 +90,9 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: account.id,
|
||||
notify: false,
|
||||
reblogs: relationship.showingReblogs))
|
||||
} catch {}
|
||||
|
@ -96,7 +104,9 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: account.id,
|
||||
notify: true,
|
||||
reblogs: relationship.showingReblogs))
|
||||
} catch {}
|
||||
|
@ -109,7 +119,9 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: account.id,
|
||||
notify: relationship.notifying,
|
||||
reblogs: false))
|
||||
} catch {}
|
||||
|
@ -121,7 +133,9 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: account.id,
|
||||
notify: relationship.notifying,
|
||||
reblogs: true))
|
||||
} catch {}
|
||||
|
@ -159,7 +173,9 @@ public struct AccountDetailContextMenu: View {
|
|||
ShareLink(item: url, subject: Text(account.safeDisplayName)) {
|
||||
Label("account.action.share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button { UIApplication.shared.open(url) } label: {
|
||||
Button {
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
Label("status.action.view-in-browser", systemImage: "safari")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import AppAccount
|
||||
import DesignSystem
|
||||
import EmojiText
|
||||
import Env
|
||||
import Models
|
||||
import NukeUI
|
||||
import SwiftUI
|
||||
import AppAccount
|
||||
|
||||
@MainActor
|
||||
struct AccountDetailHeaderView: View {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -114,7 +117,8 @@ struct AccountDetailHeaderView: View {
|
|||
}
|
||||
let attachement = MediaAttachment.imageWith(url: account.header)
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
openWindow(value: WindowDestinationMedia.mediaViewer(
|
||||
openWindow(
|
||||
value: WindowDestinationMedia.mediaViewer(
|
||||
attachments: [attachement],
|
||||
selectedAttachment: attachement
|
||||
))
|
||||
|
@ -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],
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -274,9 +288,12 @@ struct AccountDetailHeaderView: View {
|
|||
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
||||
.padding(.top, 8)
|
||||
.textSelection(.enabled)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
}
|
||||
)
|
||||
.accessibilityRespondsToUserInteraction(false)
|
||||
|
||||
if let translation = viewModel.translation, !viewModel.isLoadingTranslation {
|
||||
|
@ -284,7 +301,10 @@ struct AccountDetailHeaderView: View {
|
|||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(translation.content.asSafeMarkdownAttributedString)
|
||||
.font(.scaledBody)
|
||||
Text(getLocalizedStringLabel(langCode: translation.detectedSourceLanguage, provider: translation.provider))
|
||||
Text(
|
||||
getLocalizedStringLabel(
|
||||
langCode: translation.detectedSourceLanguage, provider: translation.provider)
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -351,7 +373,11 @@ struct AccountDetailHeaderView: View {
|
|||
if let subscription = viewModel.subClubUser?.subscription,
|
||||
let accountName = appAccount.currentAccount.accountName,
|
||||
let premiumUsername = account.premiumUsername,
|
||||
let url = URL(string: "https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)") {
|
||||
let url = URL(
|
||||
string:
|
||||
"https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)"
|
||||
)
|
||||
{
|
||||
openURL(url)
|
||||
} else {
|
||||
isTipSheetPresented = true
|
||||
|
@ -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
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
.accessibilityValue(field.verifiedAt != nil ? "accessibility.tabs.profile.fields.verified.label" : "")
|
||||
}
|
||||
)
|
||||
.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)
|
||||
|
@ -492,7 +525,8 @@ private struct ConditionalUserDefinedFieldAccessibilityActionModifier: ViewModif
|
|||
|
||||
struct AccountDetailHeaderView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AccountDetailHeaderView(viewModel: .init(account: .placeholder()),
|
||||
AccountDetailHeaderView(
|
||||
viewModel: .init(account: .placeholder()),
|
||||
account: .placeholder(),
|
||||
scrollViewProxy: nil)
|
||||
}
|
||||
|
|
|
@ -52,8 +52,7 @@ public struct AccountDetailView: View {
|
|||
.applyAccountDetailsRowStyle(theme: theme)
|
||||
|
||||
Picker("", selection: $viewModel.selectedTab) {
|
||||
ForEach(viewModel.tabs, id: \.self)
|
||||
{ tab in
|
||||
ForEach(viewModel.tabs, id: \.self) { tab in
|
||||
if tab == .boosts {
|
||||
Image("Rocket")
|
||||
.tag(tab)
|
||||
|
@ -81,7 +80,9 @@ public struct AccountDetailView: View {
|
|||
}
|
||||
.onTapGesture {
|
||||
if let account = viewModel.account {
|
||||
routerPath.navigate(to: .accountMediaGridView(account: account,
|
||||
routerPath.navigate(
|
||||
to: .accountMediaGridView(
|
||||
account: account,
|
||||
initialMediaStatuses: viewModel.statusesMedias))
|
||||
}
|
||||
}
|
||||
|
@ -89,7 +90,8 @@ public struct AccountDetailView: View {
|
|||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
StatusesListView(fetcher: viewModel,
|
||||
StatusesListView(
|
||||
fetcher: viewModel,
|
||||
client: client,
|
||||
routerPath: routerPath)
|
||||
}
|
||||
|
@ -146,9 +148,12 @@ public struct AccountDetailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isEditingRelationshipNote, content: {
|
||||
.sheet(
|
||||
isPresented: $isEditingRelationshipNote,
|
||||
content: {
|
||||
EditRelationshipNoteView(accountDetailViewModel: viewModel)
|
||||
})
|
||||
}
|
||||
)
|
||||
.edgesIgnoringSafeArea(.top)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
@ -160,13 +165,16 @@ public struct AccountDetailView: View {
|
|||
private func makeHeaderView(proxy: ScrollViewProxy?) -> some View {
|
||||
switch viewModel.accountState {
|
||||
case .loading:
|
||||
AccountDetailHeaderView(viewModel: viewModel,
|
||||
AccountDetailHeaderView(
|
||||
viewModel: viewModel,
|
||||
account: .placeholder(),
|
||||
scrollViewProxy: proxy)
|
||||
scrollViewProxy: proxy
|
||||
)
|
||||
.redacted(reason: .placeholder)
|
||||
.allowsHitTesting(false)
|
||||
case let .data(account):
|
||||
AccountDetailHeaderView(viewModel: viewModel,
|
||||
AccountDetailHeaderView(
|
||||
viewModel: viewModel,
|
||||
account: account,
|
||||
scrollViewProxy: proxy)
|
||||
case let .error(error):
|
||||
|
@ -237,16 +245,20 @@ public struct AccountDetailView: View {
|
|||
.font(.scaledFootnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fontWeight(.semibold)
|
||||
.listRowInsets(.init(top: 0,
|
||||
.listRowInsets(
|
||||
.init(
|
||||
top: 0,
|
||||
leading: 12,
|
||||
bottom: 0,
|
||||
trailing: .layoutPadding))
|
||||
trailing: .layoutPadding)
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
#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)
|
||||
|
@ -278,9 +290,12 @@ 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,
|
||||
routerPath.presentedSheet = .mentionStatusEditor(
|
||||
account: account,
|
||||
visibility: preferences.postVisibility)
|
||||
#endif
|
||||
}
|
||||
|
@ -290,7 +305,8 @@ public struct AccountDetailView: View {
|
|||
}
|
||||
|
||||
Menu {
|
||||
AccountDetailContextMenu(showBlockConfirmation: $showBlockConfirmation,
|
||||
AccountDetailContextMenu(
|
||||
showBlockConfirmation: $showBlockConfirmation,
|
||||
showTranslateView: $showTranslateView,
|
||||
viewModel: viewModel)
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -55,7 +57,6 @@ import SwiftUI
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
var tabs: [Tab] {
|
||||
if isCurrentUser {
|
||||
return Tab.currentAccountTabs
|
||||
|
@ -151,7 +152,8 @@ import SwiftUI
|
|||
if let followButtonViewModel {
|
||||
followButtonViewModel.relationship = relationship
|
||||
} else {
|
||||
followButtonViewModel = .init(client: client,
|
||||
followButtonViewModel = .init(
|
||||
client: client,
|
||||
accountId: accountId,
|
||||
relationship: relationship,
|
||||
shouldDisplayNotify: true,
|
||||
|
@ -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,
|
||||
return try await .init(
|
||||
account: account,
|
||||
featuredTags: featuredTags,
|
||||
relationships: relationships)
|
||||
} catch {
|
||||
return try await .init(account: account,
|
||||
return try await .init(
|
||||
account: account,
|
||||
featuredTags: [],
|
||||
relationships: relationships)
|
||||
}
|
||||
}
|
||||
return try await .init(account: account,
|
||||
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,7 +212,9 @@ import SwiftUI
|
|||
accountIdToFetch = accountId
|
||||
}
|
||||
statuses =
|
||||
try await client.get(endpoint: Accounts.statuses(id: accountIdToFetch,
|
||||
try await client.get(
|
||||
endpoint: Accounts.statuses(
|
||||
id: accountIdToFetch,
|
||||
sinceId: nil,
|
||||
tag: nil,
|
||||
onlyMedia: selectedTab == .media,
|
||||
|
@ -217,7 +227,9 @@ import SwiftUI
|
|||
}
|
||||
if selectedTab == .statuses {
|
||||
pinned =
|
||||
try await client.get(endpoint: Accounts.statuses(id: accountId,
|
||||
try await client.get(
|
||||
endpoint: Accounts.statuses(
|
||||
id: accountId,
|
||||
sinceId: nil,
|
||||
tag: nil,
|
||||
onlyMedia: false,
|
||||
|
@ -227,8 +239,10 @@ import SwiftUI
|
|||
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,7 +262,9 @@ import SwiftUI
|
|||
accountIdToFetch = accountId
|
||||
}
|
||||
let newStatuses: [Status] =
|
||||
try await client.get(endpoint: Accounts.statuses(id: accountIdToFetch,
|
||||
try await client.get(
|
||||
endpoint: Accounts.statuses(
|
||||
id: accountIdToFetch,
|
||||
sinceId: lastId,
|
||||
tag: nil,
|
||||
onlyMedia: selectedTab == .media,
|
||||
|
@ -262,23 +278,27 @@ import SwiftUI
|
|||
}
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
|
||||
if selectedTab == .boosts {
|
||||
statusesState = .display(statuses: boosts,
|
||||
statusesState = .display(
|
||||
statuses: boosts,
|
||||
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
|
||||
} else {
|
||||
statusesState = .display(statuses: statuses,
|
||||
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,14 +308,18 @@ 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,
|
||||
statusesState = .display(
|
||||
statuses: favorites,
|
||||
nextPageState: favoritesNextPage != nil ? .hasNextPage : .none)
|
||||
case .bookmarks:
|
||||
statusesState = .display(statuses: bookmarks,
|
||||
statusesState = .display(
|
||||
statuses: bookmarks,
|
||||
nextPageState: bookmarksNextPage != nil ? .hasNextPage : .none)
|
||||
}
|
||||
}
|
||||
|
@ -303,8 +327,8 @@ import SwiftUI
|
|||
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,7 +353,9 @@ 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,
|
||||
let results: SearchResults? = try await client.get(
|
||||
endpoint: Search.search(
|
||||
query: acct,
|
||||
type: .accounts,
|
||||
offset: nil,
|
||||
following: nil),
|
||||
|
@ -337,7 +363,8 @@ extension AccountDetailViewModel {
|
|||
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 {
|
||||
|
@ -347,7 +374,9 @@ extension AccountDetailViewModel {
|
|||
|
||||
func followPremiumAccount() async throws {
|
||||
if let premiumAccount {
|
||||
premiumRelationship = try await client?.post(endpoint: Accounts.follow(id: premiumAccount.id,
|
||||
premiumRelationship = try await client?.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: premiumAccount.id,
|
||||
notify: false,
|
||||
reblogs: true))
|
||||
}
|
||||
|
|
|
@ -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,7 +50,9 @@ 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)
|
||||
EmojiTextApp(
|
||||
.init(stringValue: viewModel.account.safeDisplayName), emojis: viewModel.account.emojis
|
||||
)
|
||||
.font(.scaledSubheadline)
|
||||
.emojiText.size(Font.scaledSubheadlineFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset)
|
||||
|
@ -58,7 +63,9 @@ public struct AccountsListRow: View {
|
|||
|
||||
// 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))")
|
||||
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 {
|
||||
|
@ -71,7 +78,9 @@ public struct AccountsListRow: View {
|
|||
.font(.scaledFootnote)
|
||||
.emojiText.size(Font.scaledFootnoteFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledFootnoteFont.emojiBaselineOffset)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
}
|
||||
|
@ -81,12 +90,15 @@ public struct AccountsListRow: View {
|
|||
.font(.scaledCaption)
|
||||
.emojiText.size(Font.scaledFootnoteFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledFootnoteFont.emojiBaselineOffset)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
|
||||
if isFollowRequest {
|
||||
FollowRequestButtons(account: viewModel.account,
|
||||
FollowRequestButtons(
|
||||
account: viewModel.account,
|
||||
requestUpdated: requestUpdated)
|
||||
}
|
||||
}
|
||||
|
@ -95,7 +107,9 @@ public struct AccountsListRow: View {
|
|||
let relationShip = viewModel.relationShip
|
||||
{
|
||||
VStack(alignment: .center) {
|
||||
FollowButton(viewModel: .init(client: client,
|
||||
FollowButton(
|
||||
viewModel: .init(
|
||||
client: client,
|
||||
accountId: viewModel.account.id,
|
||||
relationship: relationShip,
|
||||
shouldDisplayNotify: false,
|
||||
|
@ -114,14 +128,17 @@ public struct AccountsListRow: View {
|
|||
.addTranslateView(isPresented: $showTranslateView, text: viewModel.account.note.asRawText)
|
||||
#endif
|
||||
.contextMenu {
|
||||
AccountDetailContextMenu(showBlockConfirmation: $showBlockConfirmation,
|
||||
AccountDetailContextMenu(
|
||||
showBlockConfirmation: $showBlockConfirmation,
|
||||
showTranslateView: $showTranslateView,
|
||||
viewModel: .init(account: viewModel.account))
|
||||
} preview: {
|
||||
List {
|
||||
AccountDetailHeaderView(viewModel: .init(account: viewModel.account),
|
||||
AccountDetailHeaderView(
|
||||
viewModel: .init(account: viewModel.account),
|
||||
account: viewModel.account,
|
||||
scrollViewProxy: nil)
|
||||
scrollViewProxy: nil
|
||||
)
|
||||
.applyAccountDetailsRowStyle(theme: theme)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
|
|
|
@ -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()
|
||||
|
@ -125,14 +127,18 @@ public struct AccountsListView: View {
|
|||
}
|
||||
Section {
|
||||
if accounts.isEmpty {
|
||||
PlaceholderView(iconName: "person.icloud",
|
||||
PlaceholderView(
|
||||
iconName: "person.icloud",
|
||||
title: "No accounts found",
|
||||
message: "This list of accounts is empty")
|
||||
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,
|
||||
AccountsListRow(
|
||||
viewModel: .init(
|
||||
account: account,
|
||||
relationShip: relationship))
|
||||
}
|
||||
}
|
||||
|
@ -166,7 +172,9 @@ public struct AccountsListView: View {
|
|||
|
||||
#Preview {
|
||||
List {
|
||||
AccountsListRow(viewModel: .init(account: .placeholder(),
|
||||
AccountsListRow(
|
||||
viewModel: .init(
|
||||
account: .placeholder(),
|
||||
relationShip: .placeholder()))
|
||||
}
|
||||
.listStyle(.plain)
|
||||
|
|
|
@ -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,7 +44,8 @@ public enum AccountsListMode {
|
|||
}
|
||||
|
||||
case loading
|
||||
case display(accounts: [Account],
|
||||
case display(
|
||||
accounts: [Account],
|
||||
relationships: [Relationship],
|
||||
nextPageState: PagingState)
|
||||
case error(error: Error)
|
||||
|
@ -72,19 +75,27 @@ 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,
|
||||
(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,
|
||||
(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,
|
||||
(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,
|
||||
(accounts, link) = try await client.getWithLink(
|
||||
endpoint: Statuses.favoritedBy(
|
||||
id: statusId,
|
||||
maxId: nil))
|
||||
case let .accountsList(accounts):
|
||||
self.accounts = accounts
|
||||
|
@ -97,9 +108,11 @@ public enum AccountsListMode {
|
|||
(accounts, link) = try await client.getWithLink(endpoint: Accounts.muteList)
|
||||
}
|
||||
nextPageId = link?.maxId
|
||||
relationships = try await client.get(endpoint:
|
||||
relationships = try await client.get(
|
||||
endpoint:
|
||||
Accounts.relationships(ids: accounts.map(\.id)))
|
||||
state = .display(accounts: accounts,
|
||||
state = .display(
|
||||
accounts: accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: link?.maxId != nil ? .hasNextPage : .none)
|
||||
} catch {}
|
||||
|
@ -111,16 +124,24 @@ public enum AccountsListMode {
|
|||
let link: LinkHandler?
|
||||
switch mode {
|
||||
case let .followers(accountId):
|
||||
(newAccounts, link) = try await client.getWithLink(endpoint: Accounts.followers(id: accountId,
|
||||
(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,
|
||||
(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,
|
||||
(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,
|
||||
(newAccounts, link) = try await client.getWithLink(
|
||||
endpoint: Statuses.favoritedBy(
|
||||
id: statusId,
|
||||
maxId: nextPageId))
|
||||
case .accountsList:
|
||||
newAccounts = []
|
||||
|
@ -139,7 +160,8 @@ public enum AccountsListMode {
|
|||
|
||||
relationships.append(contentsOf: newRelationships)
|
||||
self.nextPageId = link?.maxId
|
||||
state = .display(accounts: accounts,
|
||||
state = .display(
|
||||
accounts: accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: link?.maxId != nil ? .hasNextPage : .none)
|
||||
}
|
||||
|
@ -149,7 +171,9 @@ 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,
|
||||
var results: SearchResults = try await client.get(
|
||||
endpoint: Search.search(
|
||||
query: searchQuery,
|
||||
type: .accounts,
|
||||
offset: nil,
|
||||
following: true),
|
||||
|
@ -158,7 +182,8 @@ public enum AccountsListMode {
|
|||
try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map(\.id)))
|
||||
results.relationships = relationships
|
||||
withAnimation {
|
||||
state = .display(accounts: results.accounts,
|
||||
state = .display(
|
||||
accounts: results.accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: .none)
|
||||
}
|
||||
|
|
|
@ -40,11 +40,13 @@ public struct EditAccountView: View {
|
|||
.toolbar {
|
||||
toolbarContent
|
||||
}
|
||||
.alert("account.edit.error.save.title",
|
||||
.alert(
|
||||
"account.edit.error.save.title",
|
||||
isPresented: $viewModel.saveError,
|
||||
actions: {
|
||||
Button("alert.button.ok", action: {})
|
||||
}, message: { Text("account.edit.error.save.message") })
|
||||
}, message: { Text("account.edit.error.save.message") }
|
||||
)
|
||||
.task {
|
||||
viewModel.client = client
|
||||
await viewModel.fetchAccount()
|
||||
|
@ -138,7 +140,8 @@ public struct EditAccountView: View {
|
|||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
.listRowBackground(theme.secondaryBackgroundColor)
|
||||
.photosPicker(isPresented: $viewModel.isPhotoPickerPresented,
|
||||
.photosPicker(
|
||||
isPresented: $viewModel.isPhotoPickerPresented,
|
||||
selection: $viewModel.mediaPickers,
|
||||
maxSelectionCount: 1,
|
||||
matching: .any(of: [.images]),
|
||||
|
@ -188,7 +191,9 @@ 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")
|
||||
|
|
|
@ -94,7 +94,8 @@ import SwiftUI
|
|||
func save() async {
|
||||
isSaving = true
|
||||
do {
|
||||
let data = UpdateCredentialsData(displayName: displayName,
|
||||
let data = UpdateCredentialsData(
|
||||
displayName: displayName,
|
||||
note: note,
|
||||
source: .init(privacy: postPrivacy, sensitive: isSensitive),
|
||||
bot: isBot,
|
||||
|
@ -137,7 +138,8 @@ 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,
|
||||
let response = try await client.mediaUpload(
|
||||
endpoint: Accounts.updateCredentialsMedia,
|
||||
version: .v1,
|
||||
method: "PATCH",
|
||||
mimeType: "image/jpeg",
|
||||
|
@ -152,7 +154,8 @@ 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,
|
||||
let response = try await client.mediaUpload(
|
||||
endpoint: Accounts.updateCredentialsMedia,
|
||||
version: .v1,
|
||||
method: "PATCH",
|
||||
mimeType: "image/jpeg",
|
||||
|
@ -165,7 +168,10 @@ 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()
|
||||
|
||||
|
|
|
@ -15,7 +15,9 @@ public struct EditRelationshipNoteView: View {
|
|||
NavigationStack {
|
||||
Form {
|
||||
Section("account.relation.note.label") {
|
||||
TextField("account.relation.note.edit.placeholder", text: $viewModel.note, axis: .vertical)
|
||||
TextField(
|
||||
"account.relation.note.edit.placeholder", text: $viewModel.note, axis: .vertical
|
||||
)
|
||||
.frame(minHeight: 150, maxHeight: 150, alignment: .top)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
|
@ -31,11 +33,13 @@ public struct EditRelationshipNoteView: View {
|
|||
.toolbar {
|
||||
toolbarContent
|
||||
}
|
||||
.alert("account.relation.note.edit.error.save.title",
|
||||
.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") })
|
||||
}, message: { Text("account.relation.note.edit.error.save.message") }
|
||||
)
|
||||
.task {
|
||||
viewModel.client = client
|
||||
viewModel.relatedAccountId = accountDetailViewModel.accountId
|
||||
|
|
|
@ -19,7 +19,9 @@ import SwiftUI
|
|||
{
|
||||
isSaving = true
|
||||
do {
|
||||
_ = try await client!.post(endpoint: Accounts.relationshipNote(id: relatedAccountId!, json: RelationshipNoteData(note: note)))
|
||||
_ = try await client!.post(
|
||||
endpoint: Accounts.relationshipNote(
|
||||
id: relatedAccountId!, json: RelationshipNoteData(note: note)))
|
||||
} catch {
|
||||
isSaving = false
|
||||
saveError = true
|
||||
|
|
|
@ -29,7 +29,8 @@ struct EditFilterView: View {
|
|||
@FocusState private var focusedField: Fields?
|
||||
|
||||
private var data: ServerFilterData {
|
||||
let expiresIn: String? = switch expirySelection {
|
||||
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:
|
||||
|
@ -38,7 +39,8 @@ struct EditFilterView: View {
|
|||
String(expirySelection.rawValue + 50)
|
||||
}
|
||||
|
||||
return ServerFilterData(title: title,
|
||||
return ServerFilterData(
|
||||
title: title,
|
||||
context: contexts,
|
||||
filterAction: filterAction,
|
||||
expiresIn: expiresIn)
|
||||
|
@ -100,9 +102,11 @@ struct EditFilterView: View {
|
|||
}
|
||||
}
|
||||
if expirySelection != .infinite {
|
||||
DatePicker("filter.edit.expiry.date-time",
|
||||
DatePicker(
|
||||
"filter.edit.expiry.date-time",
|
||||
selection: Binding<Date>(get: { expiresAt ?? Date() }, set: { expiresAt = $0 }),
|
||||
displayedComponents: [.date, .hourAndMinute])
|
||||
displayedComponents: [.date, .hourAndMinute]
|
||||
)
|
||||
.disabled(expirySelection != .custom)
|
||||
}
|
||||
}
|
||||
|
@ -208,9 +212,12 @@ struct EditFilterView: View {
|
|||
private var contextsSection: some View {
|
||||
Section("filter.edit.contexts") {
|
||||
ForEach(ServerFilter.Context.allCases, id: \.self) { context in
|
||||
Toggle(isOn: .init(get: {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
contexts.contains(where: { $0 == context })
|
||||
}, set: { _ in
|
||||
},
|
||||
set: { _ in
|
||||
if let index = contexts.firstIndex(of: context) {
|
||||
contexts.remove(at: index)
|
||||
} else {
|
||||
|
@ -219,7 +226,8 @@ struct EditFilterView: View {
|
|||
Task {
|
||||
await saveFilter(client)
|
||||
}
|
||||
})) {
|
||||
})
|
||||
) {
|
||||
Label(context.name, systemImage: context.iconName)
|
||||
}
|
||||
.disabled(isSavingFilter)
|
||||
|
@ -277,10 +285,12 @@ struct EditFilterView: View {
|
|||
do {
|
||||
isSavingFilter = true
|
||||
if let filter {
|
||||
self.filter = try await client.put(endpoint: ServerFilters.editFilter(id: filter.id, json: data),
|
||||
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),
|
||||
let newFilter: ServerFilter = try await client.post(
|
||||
endpoint: ServerFilters.createFilter(json: data),
|
||||
forceVersion: .v2)
|
||||
filter = newFilter
|
||||
}
|
||||
|
@ -292,8 +302,9 @@ 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,
|
||||
let keyword: ServerFilter.Keyword = try await client.post(
|
||||
endpoint: ServerFilters.addKeyword(
|
||||
filter: filterId,
|
||||
keyword: name,
|
||||
wholeWord: true),
|
||||
forceVersion: .v2)
|
||||
|
@ -305,7 +316,8 @@ 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),
|
||||
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 })
|
||||
|
|
|
@ -93,7 +93,8 @@ public struct FiltersListView: View {
|
|||
if let index = indexes.first {
|
||||
Task {
|
||||
do {
|
||||
let response = try await client.delete(endpoint: ServerFilters.filter(id: filters[index].id),
|
||||
let response = try await client.delete(
|
||||
endpoint: ServerFilters.filter(id: filters[index].id),
|
||||
forceVersion: .v2)
|
||||
if response?.statusCode == 200 {
|
||||
filters.remove(at: index)
|
||||
|
|
|
@ -3,8 +3,8 @@ import Combine
|
|||
import Foundation
|
||||
import Models
|
||||
import Network
|
||||
import Observation
|
||||
import OSLog
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
|
@ -16,12 +16,13 @@ import SwiftUI
|
|||
public let relationshipUpdated: (Relationship) -> Void
|
||||
public var relationship: Relationship
|
||||
|
||||
public init(client: Client,
|
||||
public init(
|
||||
client: Client,
|
||||
accountId: String,
|
||||
relationship: Relationship,
|
||||
shouldDisplayNotify: Bool,
|
||||
relationshipUpdated: @escaping ((Relationship) -> Void))
|
||||
{
|
||||
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
|
||||
|
@ -48,7 +50,8 @@ import SwiftUI
|
|||
}
|
||||
|
||||
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,7 +60,9 @@ import SwiftUI
|
|||
|
||||
func toggleNotify() async throws {
|
||||
do {
|
||||
relationship = try await client.post(endpoint: Accounts.follow(id: accountId,
|
||||
relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: accountId,
|
||||
notify: !relationship.notifying,
|
||||
reblogs: relationship.showingReblogs))
|
||||
relationshipUpdated(relationship)
|
||||
|
@ -68,7 +73,9 @@ import SwiftUI
|
|||
|
||||
func toggleReboosts() async throws {
|
||||
do {
|
||||
relationship = try await client.post(endpoint: Accounts.follow(id: accountId,
|
||||
relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: accountId,
|
||||
notify: relationship.notifying,
|
||||
reblogs: !relationship.showingReblogs))
|
||||
relationshipUpdated(relationship)
|
||||
|
@ -98,9 +105,13 @@ public struct FollowButton: View {
|
|||
if viewModel.relationship.requested == true {
|
||||
Text("account.follow.requested")
|
||||
} else {
|
||||
Text(viewModel.relationship.following ? "account.follow.following" : "account.follow.follow")
|
||||
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")
|
||||
.accessibilityValue(
|
||||
viewModel.relationship.following
|
||||
? "accessibility.general.toggle.on" : "accessibility.general.toggle.off")
|
||||
}
|
||||
}
|
||||
if viewModel.relationship.following,
|
||||
|
@ -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()
|
||||
|
|
|
@ -23,11 +23,14 @@ public struct AccountDetailMediaGridView: View {
|
|||
|
||||
public var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
LazyVGrid(columns: [.init(.flexible(minimum: 100), spacing: 4),
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
.init(.flexible(minimum: 100), spacing: 4),
|
||||
.init(.flexible(minimum: 100), spacing: 4)],
|
||||
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,
|
||||
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,7 +109,9 @@ public struct AccountDetailMediaGridView: View {
|
|||
|
||||
private func fetchNextPage() async throws {
|
||||
let newStatuses: [Status] =
|
||||
try await client.get(endpoint: Accounts.statuses(id: account.id,
|
||||
try await client.get(
|
||||
endpoint: Accounts.statuses(
|
||||
id: account.id,
|
||||
sinceId: mediaStatuses.last?.id,
|
||||
tag: nil,
|
||||
onlyMedia: true,
|
||||
|
|
|
@ -46,7 +46,8 @@ 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,
|
||||
statusesState = .display(
|
||||
statuses: statuses,
|
||||
nextPageState: nextPage?.maxId != nil ? .hasNextPage : .none)
|
||||
} catch {
|
||||
statusesState = .error(error: error)
|
||||
|
@ -59,7 +60,8 @@ 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,
|
||||
statusesState = .display(
|
||||
statuses: statuses,
|
||||
nextPageState: nextPage?.maxId != nil ? .hasNextPage : .none)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
@ -141,7 +141,11 @@ struct PremiumAcccountSubsciptionSheetView: View {
|
|||
if let subscription = subClubUser?.subscription,
|
||||
let accountName = appAccount.currentAccount.accountName,
|
||||
let premiumUsername = account.premiumUsername,
|
||||
let url = URL(string: "https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)") {
|
||||
let url = URL(
|
||||
string:
|
||||
"https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)"
|
||||
)
|
||||
{
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
@testable import Account
|
||||
import XCTest
|
||||
|
||||
@testable import Account
|
||||
|
||||
final class AccountTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// This is an example of a functional test case.
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "AppAccount",
|
||||
targets: ["AppAccount"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Network", path: "../Network"),
|
||||
|
@ -32,8 +32,8 @@ let package = Package(
|
|||
.product(name: "DesignSystem", package: "DesignSystem"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -4,7 +4,7 @@ import Models
|
|||
import Network
|
||||
import SwiftUI
|
||||
|
||||
public extension AppAccount {
|
||||
extension AppAccount {
|
||||
private static var keychain: KeychainSwift {
|
||||
let keychain = KeychainSwift()
|
||||
#if !DEBUG && !targetEnvironment(simulator)
|
||||
|
@ -13,17 +13,17 @@ public extension AppAccount {
|
|||
return keychain
|
||||
}
|
||||
|
||||
func save() throws {
|
||||
public func save() throws {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(self)
|
||||
Self.keychain.set(data, forKey: key, withAccess: .accessibleAfterFirstUnlock)
|
||||
}
|
||||
|
||||
func delete() {
|
||||
public func delete() {
|
||||
Self.keychain.delete(key)
|
||||
}
|
||||
|
||||
static func retrieveAll() -> [AppAccount] {
|
||||
public static func retrieveAll() -> [AppAccount] {
|
||||
let keychain = Self.keychain
|
||||
let decoder = JSONDecoder()
|
||||
let keys = keychain.allKeys
|
||||
|
@ -39,7 +39,7 @@ public extension AppAccount {
|
|||
return accounts
|
||||
}
|
||||
|
||||
static func deleteAll() {
|
||||
public static func deleteAll() {
|
||||
let keychain = Self.keychain
|
||||
let keys = keychain.allKeys
|
||||
for key in keys {
|
||||
|
|
|
@ -51,7 +51,8 @@ public struct AppAccountView: View {
|
|||
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
|
||||
|
|
|
@ -32,7 +32,10 @@ import SwiftUI
|
|||
}
|
||||
}
|
||||
|
||||
public init(appAccount: AppAccount, isCompact: Bool = false, isInSettings: Bool = true, showBadge: Bool = false) {
|
||||
public init(
|
||||
appAccount: AppAccount, isCompact: Bool = false, isInSettings: Bool = true,
|
||||
showBadge: Bool = false
|
||||
) {
|
||||
self.appAccount = appAccount
|
||||
self.isCompact = isCompact
|
||||
self.isInSettings = isInSettings
|
||||
|
|
|
@ -13,7 +13,8 @@ import SwiftUI
|
|||
public var currentAccount: AppAccount {
|
||||
didSet {
|
||||
Self.latestCurrentAccountKey = currentAccount.id
|
||||
currentClient = .init(server: currentAccount.server,
|
||||
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,7 +56,10 @@ import SwiftUI
|
|||
availableAccounts.removeAll(where: { $0.id == account.id })
|
||||
account.delete()
|
||||
if currentAccount.id == account.id {
|
||||
currentAccount = availableAccounts.first ?? AppAccount(server: AppInfo.defaultServer,
|
||||
currentAccount =
|
||||
availableAccounts.first
|
||||
?? AppAccount(
|
||||
server: AppInfo.defaultServer,
|
||||
accountName: nil,
|
||||
oauthToken: nil)
|
||||
}
|
||||
|
|
|
@ -31,10 +31,11 @@ public struct AppAccountsSelectorView: View {
|
|||
return baseHeight
|
||||
}
|
||||
|
||||
public init(routerPath: RouterPath,
|
||||
public init(
|
||||
routerPath: RouterPath,
|
||||
accountCreationEnabled: Bool = true,
|
||||
avatarConfig: AvatarView.FrameConfig? = nil)
|
||||
{
|
||||
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: {
|
||||
.sheet(
|
||||
isPresented: $isPresented,
|
||||
content: {
|
||||
accountsView.presentationDetents([.height(preferredHeight), .large])
|
||||
.presentationBackground(.ultraThinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.onAppear {
|
||||
refreshAccounts()
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
.onChange(of: currentAccount.account?.id) {
|
||||
refreshAccounts()
|
||||
}
|
||||
|
@ -92,7 +96,8 @@ 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
|
||||
|
@ -186,7 +191,8 @@ public struct AppAccountsSelectorView: View {
|
|||
private func refreshAccounts() {
|
||||
accountsViewModel = []
|
||||
for account in appAccounts.availableAccounts {
|
||||
let viewModel: AppAccountViewModel = .init(appAccount: account, isInSettings: false, showBadge: true)
|
||||
let viewModel: AppAccountViewModel = .init(
|
||||
appAccount: account, isInSettings: false, showBadge: true)
|
||||
accountsViewModel.append(viewModel)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "Conversations",
|
||||
targets: ["Conversations"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Models", path: "../Models"),
|
||||
|
@ -32,8 +32,8 @@ let package = Package(
|
|||
.product(name: "DesignSystem", package: "DesignSystem"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -37,8 +37,10 @@ public struct ConversationDetailView: View {
|
|||
loadingView
|
||||
}
|
||||
ForEach(viewModel.messages) { message in
|
||||
ConversationMessageView(message: message,
|
||||
conversation: viewModel.conversation)
|
||||
ConversationMessageView(
|
||||
message: message,
|
||||
conversation: viewModel.conversation
|
||||
)
|
||||
.padding(.vertical, 4)
|
||||
.id(message.id)
|
||||
}
|
||||
|
@ -124,14 +126,17 @@ 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)
|
||||
TextField(
|
||||
"conversations.new.message.placeholder", text: $viewModel.newMessageText, axis: .vertical
|
||||
)
|
||||
.focused($isMessageFieldFocused)
|
||||
.keyboardType(.default)
|
||||
.backgroundStyle(.thickMaterial)
|
||||
|
|
|
@ -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,7 +37,8 @@ import SwiftUI
|
|||
var finalText = conversation.accounts.map { "@\($0.acct)" }.joined(separator: " ")
|
||||
finalText += " "
|
||||
finalText += newMessageText
|
||||
let data = StatusData(status: finalText,
|
||||
let data = StatusData(
|
||||
status: finalText,
|
||||
visibility: .direct,
|
||||
inReplyToId: messages.last?.id)
|
||||
do {
|
||||
|
|
|
@ -39,7 +39,9 @@ struct ConversationMessageView: View {
|
|||
.emojiText.size(Font.scaledBodyFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
||||
.padding(6)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handleStatus(status: message, url: url)
|
||||
})
|
||||
}
|
||||
|
@ -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,7 +123,8 @@ struct ConversationMessageView: View {
|
|||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
Label(isLiked ? "status.action.unfavorite" : "status.action.favorite",
|
||||
Label(
|
||||
isLiked ? "status.action.unfavorite" : "status.action.favorite",
|
||||
systemImage: isLiked ? "star.fill" : "star")
|
||||
}
|
||||
Button {
|
||||
|
@ -138,8 +140,10 @@ struct ConversationMessageView: View {
|
|||
isBookmarked = status.bookmarked == true
|
||||
}
|
||||
} catch {}
|
||||
} } label: {
|
||||
Label(isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
|
||||
systemImage: isBookmarked ? "bookmark.fill" : "bookmark")
|
||||
}
|
||||
Divider()
|
||||
|
@ -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,7 +214,9 @@ struct ConversationMessageView: View {
|
|||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
openWindow(value: WindowDestinationMedia.mediaViewer(attachments: [attachement],
|
||||
openWindow(
|
||||
value: WindowDestinationMedia.mediaViewer(
|
||||
attachments: [attachement],
|
||||
selectedAttachment: attachement))
|
||||
#else
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||
|
|
|
@ -29,8 +29,11 @@ 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))
|
||||
EmojiTextApp(
|
||||
.init(
|
||||
stringValue: conversation.accounts.map(\.safeDisplayName).joined(separator: ", ")),
|
||||
emojis: conversation.accounts.flatMap(\.emojis)
|
||||
)
|
||||
.font(.scaledSubheadline)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.emojiText.size(Font.scaledSubheadlineFont.emojiSize)
|
||||
|
@ -53,7 +56,10 @@ struct ConversationsListRow: View {
|
|||
.font(.scaledFootnote)
|
||||
}
|
||||
}
|
||||
EmojiTextApp(conversation.lastStatus?.content ?? HTMLString(stringValue: ""), emojis: conversation.lastStatus?.emojis ?? [])
|
||||
EmojiTextApp(
|
||||
conversation.lastStatus?.content ?? HTMLString(stringValue: ""),
|
||||
emojis: conversation.lastStatus?.emojis ?? []
|
||||
)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.scaledBody)
|
||||
.foregroundColor(theme.labelColor)
|
||||
|
@ -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,7 +184,9 @@ struct ConversationsListRow: View {
|
|||
await viewModel.favorite(conversation: conversation)
|
||||
}
|
||||
} label: {
|
||||
Label(conversation.lastStatus?.favourited ?? false ? "status.action.unfavorite" : "status.action.favorite",
|
||||
Label(
|
||||
conversation.lastStatus?.favourited ?? false
|
||||
? "status.action.unfavorite" : "status.action.favorite",
|
||||
systemImage: conversation.lastStatus?.favourited ?? false ? "star.fill" : "star")
|
||||
}
|
||||
Button {
|
||||
|
@ -185,7 +194,9 @@ struct ConversationsListRow: View {
|
|||
await viewModel.bookmark(conversation: conversation)
|
||||
}
|
||||
} label: {
|
||||
Label(conversation.lastStatus?.bookmarked ?? false ? "status.action.unbookmark" : "status.action.bookmark",
|
||||
Label(
|
||||
conversation.lastStatus?.bookmarked ?? false
|
||||
? "status.action.unbookmark" : "status.action.bookmark",
|
||||
systemImage: conversation.lastStatus?.bookmarked ?? false ? "bookmark.fill" : "bookmark")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ public struct ConversationsListView: View {
|
|||
|
||||
@State private var viewModel = ConversationsListViewModel()
|
||||
|
||||
public init() { }
|
||||
public init() {}
|
||||
|
||||
private var conversations: Binding<[Conversation]> {
|
||||
if viewModel.isLoadingFirstPage {
|
||||
|
@ -44,14 +44,16 @@ public struct ConversationsListView: View {
|
|||
Divider()
|
||||
}
|
||||
} else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError {
|
||||
PlaceholderView(iconName: "tray",
|
||||
PlaceholderView(
|
||||
iconName: "tray",
|
||||
title: "conversations.empty.title",
|
||||
message: "conversations.empty.message")
|
||||
} else if viewModel.isError {
|
||||
ErrorView(title: "conversations.error.title",
|
||||
ErrorView(
|
||||
title: "conversations.error.title",
|
||||
message: "conversations.error.message",
|
||||
buttonTitle: "conversations.error.button")
|
||||
{
|
||||
buttonTitle: "conversations.error.button"
|
||||
) {
|
||||
await viewModel.fetchConversations()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,7 +64,8 @@ import SwiftUI
|
|||
|
||||
func favorite(conversation: Conversation) async {
|
||||
guard let client, let message = conversation.lastStatus else { return }
|
||||
let endpoint: Endpoint = if message.favourited ?? false {
|
||||
let endpoint: Endpoint =
|
||||
if message.favourited ?? false {
|
||||
Statuses.unfavorite(id: message.id)
|
||||
} else {
|
||||
Statuses.favorite(id: message.id)
|
||||
|
@ -75,7 +78,8 @@ import SwiftUI
|
|||
|
||||
func bookmark(conversation: Conversation) async {
|
||||
guard let client, let message = conversation.lastStatus else { return }
|
||||
let endpoint: Endpoint = if message.bookmarked ?? false {
|
||||
let endpoint: Endpoint =
|
||||
if message.bookmarked ?? false {
|
||||
Statuses.unbookmark(id: message.id)
|
||||
} else {
|
||||
Statuses.bookmark(id: message.id)
|
||||
|
@ -86,8 +90,12 @@ import SwiftUI
|
|||
} catch {}
|
||||
}
|
||||
|
||||
private func updateConversationWithNewLastStatus(conversation: Conversation, newLastStatus: Status) {
|
||||
let newConversation = Conversation(id: conversation.id, unread: conversation.unread, lastStatus: newLastStatus, accounts: conversation.accounts)
|
||||
private func updateConversationWithNewLastStatus(
|
||||
conversation: Conversation, newLastStatus: Status
|
||||
) {
|
||||
let newConversation = Conversation(
|
||||
id: conversation.id, unread: conversation.unread, lastStatus: newLastStatus,
|
||||
accounts: conversation.accounts)
|
||||
updateConversations(conversation: newConversation)
|
||||
}
|
||||
|
||||
|
@ -96,7 +104,9 @@ import SwiftUI
|
|||
conversations.remove(at: index)
|
||||
}
|
||||
conversations.insert(conversation, at: 0)
|
||||
conversations = conversations.sorted(by: { ($0.lastStatus?.createdAt.asDate ?? Date.now) > ($1.lastStatus?.createdAt.asDate ?? Date.now) })
|
||||
conversations = conversations.sorted(by: {
|
||||
($0.lastStatus?.createdAt.asDate ?? Date.now) > ($1.lastStatus?.createdAt.asDate ?? Date.now)
|
||||
})
|
||||
}
|
||||
|
||||
func handleEvent(event: any StreamEvent) {
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "DesignSystem",
|
||||
targets: ["DesignSystem"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Models", path: "../Models"),
|
||||
|
@ -33,8 +33,8 @@ let package = Package(
|
|||
.product(name: "EmojiText", package: "EmojiText"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -3,20 +3,20 @@ import Models
|
|||
import NukeUI
|
||||
import SwiftUI
|
||||
|
||||
public extension Account {
|
||||
extension Account {
|
||||
private struct Part: Identifiable {
|
||||
let id = UUID().uuidString
|
||||
let value: Substring
|
||||
}
|
||||
|
||||
var safeDisplayName: String {
|
||||
public var safeDisplayName: String {
|
||||
if let displayName, !displayName.isEmpty {
|
||||
return displayName
|
||||
}
|
||||
return "@\(username)"
|
||||
}
|
||||
|
||||
var displayNameWithoutEmojis: String {
|
||||
public var displayNameWithoutEmojis: String {
|
||||
var name = safeDisplayName
|
||||
for emoji in emojis {
|
||||
name = name.replacingOccurrences(of: ":\(emoji.shortcode):", with: "")
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import SwiftUI
|
||||
|
||||
public let availableColorsSets: [ColorSetCouple] =
|
||||
[.init(light: IceCubeLight(), dark: IceCubeDark()),
|
||||
[
|
||||
.init(light: 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: ThreadsLight(), dark: ThreadsDark()),
|
||||
]
|
||||
|
||||
public protocol ColorSet: Sendable {
|
||||
var name: ColorSetName { get }
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
extension View {
|
||||
@ViewBuilder public func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content)
|
||||
-> some View
|
||||
{
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public extension CGFloat {
|
||||
static var layoutPadding: CGFloat {
|
||||
extension CGFloat {
|
||||
public static var layoutPadding: CGFloat {
|
||||
Theme.shared.compactLayoutPadding ? 20 : 8
|
||||
}
|
||||
|
||||
static let dividerPadding: CGFloat = 2
|
||||
static let scrollToViewHeight: CGFloat = 1
|
||||
static let statusColumnsSpacing: CGFloat = 8
|
||||
static let statusComponentSpacing: CGFloat = 6
|
||||
static let secondaryColumnWidth: CGFloat = 400
|
||||
static let sidebarWidth: CGFloat = 90
|
||||
static let sidebarWidthExpanded: CGFloat = 220
|
||||
static let pollBarHeight: CGFloat = 30
|
||||
public static let dividerPadding: CGFloat = 2
|
||||
public static let scrollToViewHeight: CGFloat = 1
|
||||
public static let statusColumnsSpacing: CGFloat = 8
|
||||
public static let statusComponentSpacing: CGFloat = 6
|
||||
public static let secondaryColumnWidth: CGFloat = 400
|
||||
public static let sidebarWidth: CGFloat = 90
|
||||
public static let sidebarWidthExpanded: CGFloat = 220
|
||||
public static let pollBarHeight: CGFloat = 30
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import Env
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public extension Font {
|
||||
extension Font {
|
||||
// See https://gist.github.com/zacwest/916d31da5d03405809c4 for iOS values
|
||||
// Custom values for Mac
|
||||
private static let title = 28.0
|
||||
|
@ -45,84 +45,85 @@ public extension Font {
|
|||
UIFontMetrics.default.scaledValue(for: baseSize * Theme.shared.fontSizeScale)
|
||||
}
|
||||
|
||||
static var scaledTitle: Font {
|
||||
public static var scaledTitle: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: title), relativeTo: .title)
|
||||
}
|
||||
|
||||
static var scaledHeadline: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: headline), relativeTo: .headline).weight(.semibold)
|
||||
public static var scaledHeadline: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: headline), relativeTo: .headline).weight(
|
||||
.semibold)
|
||||
}
|
||||
|
||||
static var scaledHeadlineFont: UIFont {
|
||||
public static var scaledHeadlineFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: headline))
|
||||
}
|
||||
|
||||
static var scaledBodyFocused: Font {
|
||||
public static var scaledBodyFocused: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: body + 2), relativeTo: .body)
|
||||
}
|
||||
|
||||
static var scaledBodyFocusedFont: UIFont {
|
||||
public static var scaledBodyFocusedFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: body + 2))
|
||||
}
|
||||
|
||||
static var scaledBody: Font {
|
||||
public static var scaledBody: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: body), relativeTo: .body)
|
||||
}
|
||||
|
||||
static var scaledBodyFont: UIFont {
|
||||
public static var scaledBodyFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: body))
|
||||
}
|
||||
|
||||
static var scaledBodyUIFont: UIFont {
|
||||
public static var scaledBodyUIFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: body))
|
||||
}
|
||||
|
||||
static var scaledCallout: Font {
|
||||
public static var scaledCallout: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: callout), relativeTo: .callout)
|
||||
}
|
||||
|
||||
static var scaledCalloutFont: UIFont {
|
||||
public static var scaledCalloutFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: body))
|
||||
}
|
||||
|
||||
static var scaledSubheadline: Font {
|
||||
public static var scaledSubheadline: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: subheadline), relativeTo: .subheadline)
|
||||
}
|
||||
|
||||
static var scaledSubheadlineFont: UIFont {
|
||||
public static var scaledSubheadlineFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: subheadline))
|
||||
}
|
||||
|
||||
static var scaledFootnote: Font {
|
||||
public static var scaledFootnote: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: footnote), relativeTo: .footnote)
|
||||
}
|
||||
|
||||
static var scaledFootnoteFont: UIFont {
|
||||
public static var scaledFootnoteFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: footnote))
|
||||
}
|
||||
|
||||
static var scaledCaption: Font {
|
||||
public static var scaledCaption: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: caption), relativeTo: .caption)
|
||||
}
|
||||
|
||||
static var scaledCaptionFont: UIFont {
|
||||
public static var scaledCaptionFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: caption))
|
||||
}
|
||||
}
|
||||
|
||||
public extension UIFont {
|
||||
func rounded() -> UIFont {
|
||||
extension UIFont {
|
||||
public func rounded() -> UIFont {
|
||||
guard let descriptor = fontDescriptor.withDesign(.rounded) else {
|
||||
return self
|
||||
}
|
||||
return UIFont(descriptor: descriptor, size: pointSize)
|
||||
}
|
||||
|
||||
var emojiSize: CGFloat {
|
||||
public var emojiSize: CGFloat {
|
||||
pointSize
|
||||
}
|
||||
|
||||
var emojiBaselineOffset: CGFloat {
|
||||
public var emojiBaselineOffset: CGFloat {
|
||||
// Center emoji with capital letter size of font
|
||||
-(emojiSize - capHeight) / 2
|
||||
}
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import SwiftUI
|
||||
|
||||
public extension Color {
|
||||
static var brand: Color {
|
||||
extension Color {
|
||||
public static var brand: Color {
|
||||
Color(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
|
||||
}
|
||||
|
||||
static var primaryBackground: Color {
|
||||
public static var primaryBackground: Color {
|
||||
Color(red: 16 / 255, green: 21 / 255, blue: 35 / 255)
|
||||
}
|
||||
|
||||
static var secondaryBackground: Color {
|
||||
public static var secondaryBackground: Color {
|
||||
Color(red: 30 / 255, green: 35 / 255, blue: 62 / 255)
|
||||
}
|
||||
|
||||
static var label: Color {
|
||||
public static var label: Color {
|
||||
Color(.label)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
@ -7,8 +5,8 @@ import SwiftUI
|
|||
// images named in lower case are Apple's symbols
|
||||
// images inamed in CamelCase are custom
|
||||
|
||||
public extension Label where Title == Text, Icon == Image {
|
||||
init(_ title: LocalizedStringKey, imageNamed: String) {
|
||||
extension Label where Title == Text, Icon == Image {
|
||||
public init(_ title: LocalizedStringKey, imageNamed: String) {
|
||||
if imageNamed.lowercased() == imageNamed {
|
||||
self.init(title, systemImage: imageNamed)
|
||||
} else {
|
||||
|
|
|
@ -12,10 +12,11 @@ import UIKit
|
|||
public private(set) var windowHeight: CGFloat = UIScreen.main.bounds.size.height
|
||||
#endif
|
||||
|
||||
public func scene(_ scene: UIScene,
|
||||
public func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo _: UISceneSession,
|
||||
options _: UIScene.ConnectionOptions)
|
||||
{
|
||||
options _: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
window = windowScene.keyWindow
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -33,8 +34,11 @@ struct ThemeApplier: ViewModifier {
|
|||
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 })
|
||||
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)
|
||||
}
|
||||
|
@ -53,8 +57,11 @@ struct ThemeApplier: ViewModifier {
|
|||
}
|
||||
.onChange(of: colorScheme) { _, newColorScheme in
|
||||
if theme.followSystemColorScheme,
|
||||
let sets = availableColorsSets
|
||||
.first(where: { $0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet })
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -7,11 +7,14 @@ public struct CloseToolbarItem: ToolbarContent {
|
|||
|
||||
public var body: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(action: {
|
||||
Button(
|
||||
action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
})
|
||||
}
|
||||
)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,7 +115,9 @@ struct AccountPopoverView: View {
|
|||
Text(title)
|
||||
.font(.scaledFootnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.alignmentGuide(.bottomAvatar, computeValue: { dimension in
|
||||
.alignmentGuide(
|
||||
.bottomAvatar,
|
||||
computeValue: { dimension in
|
||||
dimension[.firstTextBaseline]
|
||||
})
|
||||
}
|
||||
|
@ -122,12 +127,14 @@ struct AccountPopoverView: View {
|
|||
}
|
||||
|
||||
private var adaptiveConfig: AvatarView.FrameConfig {
|
||||
let cornerRadius: CGFloat = if config == .badge || theme.avatarShape == .circle {
|
||||
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)
|
||||
return AvatarView.FrameConfig(
|
||||
width: config.width, height: config.height, cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -156,7 +163,8 @@ public struct AccountPopoverModifier: ViewModifier {
|
|||
return AnyView(content)
|
||||
}
|
||||
|
||||
return AnyView(content
|
||||
return AnyView(
|
||||
content
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
toggleTask.cancel()
|
||||
|
@ -190,8 +198,8 @@ public struct AccountPopoverModifier: ViewModifier {
|
|||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func accountPopover(_ account: Account) -> some View {
|
||||
extension View {
|
||||
public func accountPopover(_ account: Account) -> some View {
|
||||
modifier(AccountPopoverModifier(account))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,8 @@ public struct AvatarView: View {
|
|||
}
|
||||
|
||||
private var adaptiveConfig: FrameConfig {
|
||||
let cornerRadius: CGFloat = if config == .badge || theme.avatarShape == .circle {
|
||||
let cornerRadius: CGFloat =
|
||||
if config == .badge || theme.avatarShape == .circle {
|
||||
config.width / 2
|
||||
} else {
|
||||
config.cornerRadius
|
||||
|
@ -88,10 +89,14 @@ struct PreviewWrapper: View {
|
|||
id: UUID().uuidString,
|
||||
username: "@clattner_llvm",
|
||||
displayName: "Chris Lattner",
|
||||
avatar: URL(string: "https://pbs.twimg.com/profile_images/1484209565788897285/1n6Viahb_400x400.jpg")!,
|
||||
avatar: URL(
|
||||
string: "https://pbs.twimg.com/profile_images/1484209565788897285/1n6Viahb_400x400.jpg")!,
|
||||
header: URL(string: "https://pbs.twimg.com/profile_banners/2543588034/1656822255/1500x500")!,
|
||||
acct: "clattner_llvm@example.com",
|
||||
note: .init(stringValue: "Building beautiful things @Modular_AI 🔥, lifting the world of production AI/ML software into a new phase of innovation. We’re hiring! 🚀🧠"),
|
||||
note: .init(
|
||||
stringValue:
|
||||
"Building beautiful things @Modular_AI 🔥, lifting the world of production AI/ML software into a new phase of innovation. We’re hiring! 🚀🧠"
|
||||
),
|
||||
createdAt: ServerDate(),
|
||||
followersCount: 77100,
|
||||
followingCount: 167,
|
||||
|
@ -117,7 +122,8 @@ struct AvatarImage: View {
|
|||
if reasons == .placeholder {
|
||||
AvatarPlaceHolder(config: config)
|
||||
} else {
|
||||
LazyImage(request: ImageRequest(url: avatar, processors: [.resize(size: config.size)])
|
||||
LazyImage(
|
||||
request: ImageRequest(url: avatar, processors: [.resize(size: config.size)])
|
||||
) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
|
|
|
@ -10,7 +10,10 @@ public struct EmojiTextApp: View {
|
|||
private let append: (() -> Text)?
|
||||
private let lineLimit: Int?
|
||||
|
||||
public init(_ markdown: HTMLString, emojis: [Emoji], language: String? = nil, lineLimit: Int? = nil, append: (() -> Text)? = nil) {
|
||||
public init(
|
||||
_ markdown: HTMLString, emojis: [Emoji], language: String? = nil, lineLimit: Int? = nil,
|
||||
append: (() -> Text)? = nil
|
||||
) {
|
||||
self.markdown = markdown
|
||||
self.emojis = emojis.map { RemoteEmoji(shortcode: $0.shortcode, url: $0.url) }
|
||||
self.language = language
|
||||
|
|
|
@ -6,7 +6,10 @@ public struct ErrorView: View {
|
|||
public let buttonTitle: LocalizedStringKey
|
||||
public let onButtonPress: () async -> Void
|
||||
|
||||
public init(title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey, onButtonPress: @escaping (() async -> Void)) {
|
||||
public init(
|
||||
title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey,
|
||||
onButtonPress: @escaping (() async -> Void)
|
||||
) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.buttonTitle = buttonTitle
|
||||
|
@ -46,7 +49,9 @@ public struct ErrorView: View {
|
|||
}
|
||||
|
||||
#Preview {
|
||||
ErrorView(title: "Error",
|
||||
ErrorView(
|
||||
title: "Error",
|
||||
message: "Error loading. Please try again",
|
||||
buttonTitle: "Retry") {}
|
||||
buttonTitle: "Retry"
|
||||
) {}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,8 @@ import SwiftUI
|
|||
/// A LazyImage (Nuke) with a geometry reader under the hood in order to use a Resize Processor to optimize performances on lists.
|
||||
/// This views also allows smooth resizing of the images by debouncing the update of the ImageProcessor.
|
||||
public struct LazyResizableImage<Content: View>: View {
|
||||
public init(url: URL?, @ViewBuilder content: @escaping (LazyImageState, GeometryProxy) -> Content) {
|
||||
public init(url: URL?, @ViewBuilder content: @escaping (LazyImageState, GeometryProxy) -> Content)
|
||||
{
|
||||
imageURL = url
|
||||
self.content = content
|
||||
}
|
||||
|
|
|
@ -12,14 +12,16 @@ public struct PlaceholderView: View {
|
|||
}
|
||||
|
||||
public var body: some View {
|
||||
ContentUnavailableView(title,
|
||||
ContentUnavailableView(
|
||||
title,
|
||||
systemImage: iconName,
|
||||
description: Text(message))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PlaceholderView(iconName: "square.and.arrow.up.trianglebadge.exclamationmark",
|
||||
PlaceholderView(
|
||||
iconName: "square.and.arrow.up.trianglebadge.exclamationmark",
|
||||
title: "Nothing to see",
|
||||
message: "This is a preview. Please try again.")
|
||||
}
|
||||
|
|
|
@ -3,19 +3,21 @@ import Models
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public extension View {
|
||||
func statusEditorToolbarItem(routerPath _: RouterPath,
|
||||
visibility: Models.Visibility) -> some ToolbarContent
|
||||
{
|
||||
extension View {
|
||||
public func statusEditorToolbarItem(
|
||||
routerPath _: RouterPath,
|
||||
visibility: Models.Visibility
|
||||
) -> some ToolbarContent {
|
||||
StatusEditorToolbarItem(visibility: visibility)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public extension ToolbarContent {
|
||||
func statusEditorToolbarItem(routerPath _: RouterPath,
|
||||
visibility: Models.Visibility) -> some ToolbarContent
|
||||
{
|
||||
extension ToolbarContent {
|
||||
public func statusEditorToolbarItem(
|
||||
routerPath _: RouterPath,
|
||||
visibility: Models.Visibility
|
||||
) -> some ToolbarContent {
|
||||
StatusEditorToolbarItem(visibility: visibility)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,15 +6,18 @@ public struct TagChartView: View {
|
|||
@State private var sortedHistory: [History] = []
|
||||
|
||||
public init(tag: Tag) {
|
||||
_sortedHistory = .init(initialValue: tag.history.sorted {
|
||||
_sortedHistory = .init(
|
||||
initialValue: tag.history.sorted {
|
||||
Int($0.day) ?? 0 < Int($1.day) ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Chart(sortedHistory) { data in
|
||||
AreaMark(x: .value("day", sortedHistory.firstIndex(where: { $0.id == data.id }) ?? 0),
|
||||
y: .value("uses", Int(data.uses) ?? 0))
|
||||
AreaMark(
|
||||
x: .value("day", sortedHistory.firstIndex(where: { $0.id == data.id }) ?? 0),
|
||||
y: .value("uses", Int(data.uses) ?? 0)
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "Env",
|
||||
targets: ["Env"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Models", path: "../Models"),
|
||||
|
@ -29,10 +29,10 @@ let package = Package(
|
|||
.product(name: "Models", package: "Models"),
|
||||
.product(name: "Network", package: "Network"),
|
||||
.product(name: "KeychainSwift", package: "keychain-swift"),
|
||||
.product(name: "TelemetryDeck", package: "SwiftSDK")
|
||||
.product(name: "TelemetryDeck", package: "SwiftSDK"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
|
|
|
@ -46,6 +46,9 @@ public enum Duration: Int, CaseIterable {
|
|||
}
|
||||
|
||||
public static func pollDurations() -> [Duration] {
|
||||
[.fiveMinutes, .thirtyMinutes, .oneHour, .sixHours, .twelveHours, .oneDay, .threeDays, .sevenDays]
|
||||
[
|
||||
.fiveMinutes, .thirtyMinutes, .oneHour, .sixHours, .twelveHours, .oneDay, .threeDays,
|
||||
.sevenDays,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ import SwiftUI
|
|||
#if canImport(_Translation_SwiftUI)
|
||||
import Translation
|
||||
|
||||
public extension View {
|
||||
func addTranslateView(isPresented: Binding<Bool>, text: String) -> some View {
|
||||
extension View {
|
||||
public func addTranslateView(isPresented: Binding<Bool>, text: String) -> some View {
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
return self
|
||||
#else
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import UIKit
|
||||
|
||||
public extension Notification.Name {
|
||||
static let shareSheetClose = NSNotification.Name("shareSheetClose")
|
||||
static let refreshTimeline = Notification.Name("refreshTimeline")
|
||||
static let homeTimeline = Notification.Name("homeTimeline")
|
||||
static let trendingTimeline = Notification.Name("trendingTimeline")
|
||||
static let federatedTimeline = Notification.Name("federatedTimeline")
|
||||
static let localTimeline = Notification.Name("localTimeline")
|
||||
extension Notification.Name {
|
||||
public static let shareSheetClose = NSNotification.Name("shareSheetClose")
|
||||
public static let refreshTimeline = Notification.Name("refreshTimeline")
|
||||
public static let homeTimeline = Notification.Name("homeTimeline")
|
||||
public static let trendingTimeline = Notification.Name("trendingTimeline")
|
||||
public static let federatedTimeline = Notification.Name("federatedTimeline")
|
||||
public static let localTimeline = Notification.Name("localTimeline")
|
||||
}
|
||||
|
|
|
@ -2,8 +2,8 @@ import Network
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public extension View {
|
||||
func withPreviewsEnv() -> some View {
|
||||
extension View {
|
||||
public func withPreviewsEnv() -> some View {
|
||||
environment(RouterPath())
|
||||
.environment(Client(server: ""))
|
||||
.environment(CurrentAccount.shared)
|
||||
|
|
|
@ -18,7 +18,7 @@ public struct PushKeys: Sendable {
|
|||
static let keychainPrivateKey = "notifications_private_key"
|
||||
}
|
||||
|
||||
public init() { }
|
||||
public init() {}
|
||||
|
||||
private var keychain: KeychainSwift {
|
||||
let keychain = KeychainSwift()
|
||||
|
@ -36,14 +36,16 @@ public struct PushKeys: Sendable {
|
|||
return try P256.KeyAgreement.PrivateKey(rawRepresentation: data)
|
||||
} catch {
|
||||
let key = P256.KeyAgreement.PrivateKey()
|
||||
keychain.set(key.rawRepresentation.base64EncodedString(),
|
||||
keychain.set(
|
||||
key.rawRepresentation.base64EncodedString(),
|
||||
forKey: Constants.keychainPrivateKey,
|
||||
withAccess: .accessibleAfterFirstUnlock)
|
||||
return key
|
||||
}
|
||||
} else {
|
||||
let key = P256.KeyAgreement.PrivateKey()
|
||||
keychain.set(key.rawRepresentation.base64EncodedString(),
|
||||
keychain.set(
|
||||
key.rawRepresentation.base64EncodedString(),
|
||||
forKey: Constants.keychainPrivateKey,
|
||||
withAccess: .accessibleAfterFirstUnlock)
|
||||
return key
|
||||
|
@ -57,7 +59,8 @@ public struct PushKeys: Sendable {
|
|||
return data
|
||||
} else {
|
||||
let key = Self.makeRandomNotificationsAuthKey()
|
||||
keychain.set(key.base64EncodedString(),
|
||||
keychain.set(
|
||||
key.base64EncodedString(),
|
||||
forKey: Constants.keychainAuthKey,
|
||||
withAccess: .accessibleAfterFirstUnlock)
|
||||
return key
|
||||
|
@ -67,7 +70,9 @@ public struct PushKeys: Sendable {
|
|||
private static func makeRandomNotificationsAuthKey() -> Data {
|
||||
let byteCount = 16
|
||||
var bytes = Data(count: byteCount)
|
||||
_ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) }
|
||||
_ = bytes.withUnsafeMutableBytes {
|
||||
SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
|
@ -112,7 +117,8 @@ public struct HandledNotification: Equatable {
|
|||
}
|
||||
|
||||
public func requestPushNotifications() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { @Sendable _, _ in
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) {
|
||||
@Sendable _, _ in
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
|
@ -122,7 +128,8 @@ public struct HandledNotification: Equatable {
|
|||
public func setAccounts(accounts: [PushAccount]) {
|
||||
subscriptions = []
|
||||
for account in accounts {
|
||||
let sub = PushNotificationSubscriptionSettings(account: account,
|
||||
let sub = PushNotificationSubscriptionSettings(
|
||||
account: account,
|
||||
key: pushKeys.notificationsPrivateKeyAsKey.publicKey.x963Representation,
|
||||
authKey: pushKeys.notificationsAuthKeyAsKey,
|
||||
pushToken: pushToken)
|
||||
|
@ -132,7 +139,9 @@ public struct HandledNotification: Equatable {
|
|||
|
||||
public func updateSubscriptions(forceCreate: Bool) async {
|
||||
for subscription in subscriptions {
|
||||
await withTaskGroup(of: Void.self, body: { group in
|
||||
await withTaskGroup(
|
||||
of: Void.self,
|
||||
body: { group in
|
||||
group.addTask {
|
||||
await subscription.fetchSubscription()
|
||||
if await subscription.subscription != nil, !forceCreate {
|
||||
|
@ -148,23 +157,31 @@ public struct HandledNotification: Equatable {
|
|||
}
|
||||
|
||||
extension PushNotificationsService: UNUserNotificationCenterDelegate {
|
||||
public func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
||||
public func userNotificationCenter(
|
||||
_: UNUserNotificationCenter, didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
guard let plaintext = response.notification.request.content.userInfo["plaintext"] as? Data,
|
||||
let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext),
|
||||
let account = subscriptions.first(where: { $0.account.token.accessToken == mastodonPushNotification.accessToken })
|
||||
let mastodonPushNotification = try? JSONDecoder().decode(
|
||||
MastodonPushNotification.self, from: plaintext),
|
||||
let account = subscriptions.first(where: {
|
||||
$0.account.token.accessToken == mastodonPushNotification.accessToken
|
||||
})
|
||||
else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let client = Client(server: account.account.server, oauthToken: account.account.token)
|
||||
let notification: Models.Notification =
|
||||
try await client.get(endpoint: Notifications.notification(id: String(mastodonPushNotification.notificationID)))
|
||||
try await client.get(
|
||||
endpoint: Notifications.notification(id: String(mastodonPushNotification.notificationID)))
|
||||
handledNotification = .init(account: account.account, notification: notification)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||
public func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
return [.banner, .sound]
|
||||
}
|
||||
}
|
||||
|
@ -224,7 +241,9 @@ extension Data {
|
|||
listenerURL += "?sandbox=true"
|
||||
#endif
|
||||
subscription =
|
||||
try await client.post(endpoint: Push.createSub(endpoint: listenerURL,
|
||||
try await client.post(
|
||||
endpoint: Push.createSub(
|
||||
endpoint: listenerURL,
|
||||
p256dh: key,
|
||||
auth: authKey,
|
||||
mentions: isMentionNotificationEnabled,
|
||||
|
|
|
@ -11,7 +11,9 @@ import QuickLook
|
|||
|
||||
private init() {}
|
||||
|
||||
public func prepareFor(selectedMediaAttachment: MediaAttachment, mediaAttachments: [MediaAttachment]) {
|
||||
public func prepareFor(
|
||||
selectedMediaAttachment: MediaAttachment, mediaAttachments: [MediaAttachment]
|
||||
) {
|
||||
self.selectedMediaAttachment = selectedMediaAttachment
|
||||
self.mediaAttachments = mediaAttachments
|
||||
}
|
||||
|
|
|
@ -183,8 +183,8 @@ public enum SettingsStartingPoint {
|
|||
{
|
||||
navigate(to: .hashTag(tag: tag, account: nil))
|
||||
return .handled
|
||||
} else if url.lastPathComponent.first == "@" ||
|
||||
(url.host() == AppInfo.premiumInstance && url.pathComponents.contains("users")),
|
||||
} else if url.lastPathComponent.first == "@"
|
||||
|| (url.host() == AppInfo.premiumInstance && url.pathComponents.contains("users")),
|
||||
let host = url.host,
|
||||
!host.hasPrefix("www")
|
||||
{
|
||||
|
@ -261,7 +261,9 @@ public enum SettingsStartingPoint {
|
|||
|
||||
public func navigateToAccountFrom(acct: String, url: URL) async {
|
||||
guard let client else { return }
|
||||
let results: SearchResults? = try? await client.get(endpoint: Search.search(query: acct,
|
||||
let results: SearchResults? = try? await client.get(
|
||||
endpoint: Search.search(
|
||||
query: acct,
|
||||
type: .accounts,
|
||||
offset: nil,
|
||||
following: nil),
|
||||
|
@ -275,7 +277,9 @@ public enum SettingsStartingPoint {
|
|||
|
||||
public func navigateToAccountFrom(url: URL) async {
|
||||
guard let client else { return }
|
||||
let results: SearchResults? = try? await client.get(endpoint: Search.search(query: url.absoluteString,
|
||||
let results: SearchResults? = try? await client.get(
|
||||
endpoint: Search.search(
|
||||
query: url.absoluteString,
|
||||
type: .accounts,
|
||||
offset: nil,
|
||||
following: nil),
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue