Format all code using swift-format

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

View file

@ -6,11 +6,10 @@
//
import MobileCoreServices
import UIKit
import UniformTypeIdentifiers
import Models
import Network
import UIKit
import UniformTypeIdentifiers
// Sample code was sending this from a thread to another, let asume @Sendable for this
extension NSExtensionContext: @unchecked @retroactive Sendable {}
@ -79,11 +78,17 @@ extension ActionRequestHandler {
guard itemProvider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
continue
}
guard let dictionary = try await itemProvider.loadItem(forTypeIdentifier: UTType.propertyList.identifier) as? [String: Any] else {
guard
let dictionary = try await itemProvider.loadItem(
forTypeIdentifier: UTType.propertyList.identifier) as? [String: Any]
else {
throw Error.loadedItemHasWrongType
}
let input = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as! [String: Any]? ?? [:]
guard let absoluteStringUrl = input["url"] as? String, let url = URL(string: absoluteStringUrl) else {
let input =
dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as! [String: Any]? ?? [:]
guard let absoluteStringUrl = input["url"] as? String,
let url = URL(string: absoluteStringUrl)
else {
throw Error.urlNotFound
}
return url
@ -96,7 +101,8 @@ extension ActionRequestHandler {
private func output(wrapping deeplink: URL) -> [NSExtensionItem] {
let results = ["deeplink": deeplink.absoluteString]
let dictionary = [NSExtensionJavaScriptFinalizeArgumentKey: results]
let provider = NSItemProvider(item: dictionary as NSDictionary, typeIdentifier: UTType.propertyList.identifier)
let provider = NSItemProvider(
item: dictionary as NSDictionary, typeIdentifier: UTType.propertyList.identifier)
let item = NSExtensionItem()
item.attachments = [provider]
return [item]

View file

@ -1,6 +1,6 @@
import AVFoundation
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
@ -32,7 +32,8 @@ struct AppView: View {
#if os(visionOS)
tabBarView
#else
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac
{
sidebarView
} else {
tabBarView
@ -54,11 +55,15 @@ struct AppView: View {
@ViewBuilder
var tabBarView: some View {
TabView(selection: .init(get: {
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)

View file

@ -17,9 +17,12 @@ extension IceCubesApp {
.keyboardShortcut("n", modifiers: .shift)
Button("menu.new-post") {
#if targetEnvironment(macCatalyst)
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
openWindow(
value: WindowDestinationEditor.newStatusEditor(
visibility: userPreferences.postVisibility))
#else
appRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
appRouterPath.presentedSheet = .newStatusEditor(
visibility: userPreferences.postVisibility)
#endif
}
.keyboardShortcut("n", modifiers: .command)

View file

@ -27,15 +27,19 @@ extension IceCubesApp {
.environment(\.isSupporter, isSupporter)
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
if #available(iOS 18.0, *) {
MediaUIView(selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments)
MediaUIView(
selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments
)
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.presentationSizing(.page)
.withEnvironments()
} else {
MediaUIView(selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments)
MediaUIView(
selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments
)
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.withEnvironments()
@ -44,9 +48,11 @@ extension IceCubesApp {
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
if newValue != nil {
pushNotificationsService.handledNotification = nil
if appAccountsManager.currentAccount.oauthToken?.accessToken != newValue?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where:
{ $0.oauthToken?.accessToken == newValue?.account.token.accessToken })
if appAccountsManager.currentAccount.oauthToken?.accessToken
!= newValue?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where: {
$0.oauthToken?.accessToken == newValue?.account.token.accessToken
})
{
appAccountsManager.currentAccount = account
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
@ -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)
}
}

View file

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

View file

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

View file

@ -1,10 +1,10 @@
import AppAccount
import DesignSystem
import Env
import Models
import Observation
import SafariServices
import SwiftUI
import AppAccount
import WebKit
extension View {
@ -27,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)

View file

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

View file

@ -57,7 +57,8 @@ struct NotificationsTab: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
switch type {
case .follow, .follow_request:
routerPath.navigate(to: .accountDetailWithAccount(account: newValue.notification.account))
routerPath.navigate(
to: .accountDetailWithAccount(account: newValue.notification.account))
default:
if let status = newValue.notification.status {
routerPath.navigate(to: .statusDetailWithStatus(status: status))
@ -81,7 +82,9 @@ struct NotificationsTab: View {
private func clearNotifications() {
if selectedTab == .notifications || isSecondaryColumn {
if let token = appAccount.currentAccount.oauthToken, userPreferences.notificationsCount[token] ?? 0 > 0 {
if let token = appAccount.currentAccount.oauthToken,
userPreferences.notificationsCount[token] ?? 0 > 0
{
userPreferences.notificationsCount[token] = 0
}
if watcher.unreadNotificationsCount > 0 {

View file

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

View file

@ -47,20 +47,25 @@ struct AccountSettingsView: View {
}
.buttonStyle(.plain)
}
if let subscription = pushNotifications.subscriptions.first(where: { $0.account.token == appAccount.oauthToken }) {
if let subscription = pushNotifications.subscriptions.first(where: {
$0.account.token == appAccount.oauthToken
}) {
NavigationLink(destination: PushNotificationsView(subscription: subscription)) {
Label("settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
Label(
"settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
}
}
}
.listRowBackground(theme.primaryBackgroundColor)
Section {
Label("settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
Label(
"settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
Button("settings.account.action.delete-cache", role: .destructive) {
Task {
await timelineCache.clearCache(for: appAccountsManager.currentClient.id)
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id)
cachedPostsCount = await timelineCache.cachedPostsCount(
for: appAccountsManager.currentClient.id)
}
}
}
@ -81,7 +86,9 @@ struct AccountSettingsView: View {
Task {
let client = Client(server: appAccount.server, oauthToken: token)
await timelineCache.clearCache(for: client.id)
if let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) {
if let sub = pushNotifications.subscriptions.first(where: {
$0.account.token == token
}) {
await sub.deleteSubscription()
}
appAccountsManager.delete(account: appAccount)
@ -106,7 +113,8 @@ struct AccountSettingsView: View {
}
}
.task {
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id)
cachedPostsCount = await timelineCache.cachedPostsCount(
for: appAccountsManager.currentClient.id)
}
.navigationTitle(account.safeDisplayName)
#if !os(visionOS)

View file

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

View file

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

View file

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

View file

@ -17,7 +17,8 @@ struct IconSelectorView: View {
}
case primary = 0
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8, alt9, alt10, alt11, alt12, alt13, alt14, alt15
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8, alt9, alt10, alt11, alt12, alt13, alt14,
alt15
case alt16, alt17, alt18, alt19, alt20, alt21
case alt22, alt23, alt24, alt25, alt26
case alt27, alt28, alt29
@ -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))]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,9 @@ struct ToolbarTab: ToolbarContent {
if !isSecondaryColumn {
if horizontalSizeClass == .regular {
ToolbarItem(placement: .topBarLeading) {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
if UIDevice.current.userInterfaceIdiom == .pad
|| UIDevice.current.userInterfaceIdiom == .mac
{
Button {
withAnimation {
userPreferences.isSidebarExpanded.toggle()
@ -32,13 +34,16 @@ struct ToolbarTab: ToolbarContent {
}
}
}
statusEditorToolbarItem(routerPath: routerPath,
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)
}
}
}

View file

@ -4,7 +4,9 @@ import SwiftUI
@Observable
public class AppIntentService: @unchecked Sendable {
struct HandledIntent: Equatable {
static func == (lhs: AppIntentService.HandledIntent, rhs: AppIntentService.HandledIntent) -> Bool {
static func == (lhs: AppIntentService.HandledIntent, rhs: AppIntentService.HandledIntent)
-> Bool
{
lhs.id == rhs.id
}

View file

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

View file

@ -9,10 +9,12 @@ enum PostVisibility: String, AppEnum {
case direct, priv, unlisted, pub
public static var caseDisplayRepresentations: [PostVisibility: DisplayRepresentation] {
[.direct: "Private",
[
.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))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,18 +2,29 @@ import CryptoKit
import Foundation
extension NotificationService {
static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? {
static func decrypt(
payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey,
publicKey: P256.KeyAgreement.PublicKey
) -> Data? {
guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
return nil
}
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32)
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8),
outputByteCount: 32)
let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
let keyInfo = info(
type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation,
serverPublicKey: publicKey.x963Representation)
let key = HKDF<SHA256>.deriveKey(
inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceInfo = info(
type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation,
serverPublicKey: publicKey.x963Representation)
let nonce = HKDF<SHA256>.deriveKey(
inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceData = nonce.withUnsafeBytes(Array.init)

View file

@ -59,10 +59,11 @@ class ShareViewController: UIViewController {
}
}
NotificationCenter.default.addObserver(forName: .shareSheetClose,
NotificationCenter.default.addObserver(
forName: .shareSheetClose,
object: nil,
queue: nil)
{ [weak self] _ in
queue: nil
) { [weak self] _ in
self?.close()
}
}

View file

@ -14,7 +14,7 @@ let package = Package(
.library(
name: "Account",
targets: ["Account"]
),
)
],
dependencies: [
.package(name: "Network", path: "../Network"),
@ -36,7 +36,7 @@ let package = Package(
.product(name: "WrappingHStack", package: "WrappingHStack"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
.swiftLanguageMode(.v6)
]
),
.testTarget(

View file

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

View file

@ -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)&currency=\(subscription.currency)&theme=\(colorScheme)") {
let url = URL(
string:
"https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)&currency=\(subscription.currency)&theme=\(colorScheme)"
)
{
openURL(url)
} else {
isTipSheetPresented = true
@ -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)
}

View file

@ -52,8 +52,7 @@ public struct AccountDetailView: View {
.applyAccountDetailsRowStyle(theme: theme)
Picker("", selection: $viewModel.selectedTab) {
ForEach(viewModel.tabs, id: \.self)
{ tab in
ForEach(viewModel.tabs, id: \.self) { tab in
if tab == .boosts {
Image("Rocket")
.tag(tab)
@ -81,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
}
}

View file

@ -12,7 +12,9 @@ import SwiftUI
var isCurrentUser: Bool = false
enum AccountState {
case loading, data(account: Account), error(error: Error)
case loading
case data(account: Account)
case error(error: Error)
}
enum Tab: Int {
@ -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))
}

View file

@ -37,7 +37,10 @@ public struct AccountsListRow: View {
let isFollowRequest: Bool
let requestUpdated: (() -> Void)?
public init(viewModel: AccountsListRowViewModel, isFollowRequest: Bool = false, requestUpdated: (() -> Void)? = nil) {
public init(
viewModel: AccountsListRowViewModel, isFollowRequest: Bool = false,
requestUpdated: (() -> Void)? = nil
) {
self.viewModel = viewModel
self.isFollowRequest = isFollowRequest
self.requestUpdated = requestUpdated
@ -47,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)

View file

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

View file

@ -1,12 +1,14 @@
import Models
import Network
import Observation
import OSLog
import Observation
import SwiftUI
public enum AccountsListMode {
case following(accountId: String), followers(accountId: String)
case favoritedBy(statusId: String), rebloggedBy(statusId: String)
case following(accountId: String)
case followers(accountId: String)
case favoritedBy(statusId: String)
case rebloggedBy(statusId: String)
case accountsList(accounts: [Account])
case blocked, muted
@ -42,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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,8 +3,8 @@ import Combine
import Foundation
import Models
import Network
import Observation
import OSLog
import Observation
import SwiftUI
@MainActor
@ -16,12 +16,13 @@ import SwiftUI
public let relationshipUpdated: (Relationship) -> Void
public var relationship: Relationship
public init(client: Client,
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()

View file

@ -23,11 +23,14 @@ public struct AccountDetailMediaGridView: View {
public var body: some View {
ScrollView(.vertical) {
LazyVGrid(columns: [.init(.flexible(minimum: 100), spacing: 4),
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,

View file

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

View file

@ -1,10 +1,10 @@
import SwiftUI
import Models
import Env
import DesignSystem
import WrappingHStack
import AppAccount
import DesignSystem
import Env
import Models
import Network
import SwiftUI
import WrappingHStack
@MainActor
struct PremiumAcccountSubsciptionSheetView: View {
@ -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)&currency=\(subscription.currency)&theme=\(colorScheme)") {
let url = URL(
string:
"https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)&currency=\(subscription.currency)&theme=\(colorScheme)"
)
{
openURL(url)
}
}

View file

@ -1,6 +1,7 @@
@testable import Account
import XCTest
@testable import Account
final class AccountTests: XCTestCase {
func testExample() throws {
// This is an example of a functional test case.

View file

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

View file

@ -4,7 +4,7 @@ import Models
import Network
import SwiftUI
public extension AppAccount {
extension AppAccount {
private static var keychain: KeychainSwift {
let keychain = KeychainSwift()
#if !DEBUG && !targetEnvironment(simulator)
@ -13,17 +13,17 @@ public extension AppAccount {
return keychain
}
func save() throws {
public func save() throws {
let encoder = JSONEncoder()
let data = try encoder.encode(self)
Self.keychain.set(data, forKey: key, withAccess: .accessibleAfterFirstUnlock)
}
func delete() {
public func delete() {
Self.keychain.delete(key)
}
static func retrieveAll() -> [AppAccount] {
public static func retrieveAll() -> [AppAccount] {
let keychain = Self.keychain
let decoder = JSONDecoder()
let keys = keychain.allKeys
@ -39,7 +39,7 @@ public extension AppAccount {
return accounts
}
static func deleteAll() {
public static func deleteAll() {
let keychain = Self.keychain
let keys = keychain.allKeys
for key in keys {

View file

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

View file

@ -32,7 +32,10 @@ import SwiftUI
}
}
public init(appAccount: AppAccount, isCompact: Bool = false, isInSettings: Bool = true, showBadge: Bool = false) {
public init(
appAccount: AppAccount, isCompact: Bool = false, isInSettings: Bool = true,
showBadge: Bool = false
) {
self.appAccount = appAccount
self.isCompact = isCompact
self.isInSettings = isInSettings

View file

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

View file

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

View file

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

View file

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

View file

@ -23,7 +23,8 @@ import SwiftUI
func fetchMessages() async {
guard let client, let lastMessageId = messages.last?.id else { return }
do {
let context: StatusContext = try await client.get(endpoint: Statuses.context(id: lastMessageId))
let context: StatusContext = try await client.get(
endpoint: Statuses.context(id: lastMessageId))
isLoadingMessages = false
messages.insert(contentsOf: context.ancestors, at: 0)
messages.append(contentsOf: context.descendants)
@ -36,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 {

View file

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

View file

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

View file

@ -14,7 +14,7 @@ public struct ConversationsListView: View {
@State private var viewModel = ConversationsListViewModel()
public init() { }
public init() {}
private var conversations: Binding<[Conversation]> {
if viewModel.isLoadingFirstPage {
@ -44,14 +44,16 @@ public struct ConversationsListView: View {
Divider()
}
} else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError {
PlaceholderView(iconName: "tray",
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()
}
}

View file

@ -23,7 +23,8 @@ import SwiftUI
isLoadingFirstPage = true
}
do {
(conversations, nextPage) = try await client.getWithLink(endpoint: Conversations.conversations(maxId: nil))
(conversations, nextPage) = try await client.getWithLink(
endpoint: Conversations.conversations(maxId: nil))
if nextPage?.maxId == nil {
nextPage = nil
}
@ -39,7 +40,8 @@ import SwiftUI
do {
isLoadingNextPage = true
var nextMessages: [Conversation] = []
(nextMessages, nextPage) = try await client.getWithLink(endpoint: Conversations.conversations(maxId: maxId))
(nextMessages, nextPage) = try await client.getWithLink(
endpoint: Conversations.conversations(maxId: maxId))
conversations.append(contentsOf: nextMessages)
if nextPage?.maxId == nil {
nextPage = nil
@ -62,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) {

View file

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

View file

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

View file

@ -1,13 +1,15 @@
import SwiftUI
public let availableColorsSets: [ColorSetCouple] =
[.init(light: IceCubeLight(), dark: IceCubeDark()),
[
.init(light: 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 }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,10 +12,11 @@ import UIKit
public private(set) var windowHeight: CGFloat = UIScreen.main.bounds.size.height
#endif
public func scene(_ scene: UIScene,
public func scene(
_ scene: UIScene,
willConnectTo _: UISceneSession,
options _: UIScene.ConnectionOptions)
{
options _: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
window = windowScene.keyWindow

View file

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

View file

@ -1,10 +1,11 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
public extension View {
@MainActor func applyTheme(_ theme: Theme) -> some View {
extension View {
@MainActor public func applyTheme(_ theme: Theme) -> some View {
modifier(ThemeApplier(theme: theme))
}
}
@ -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)
}

View file

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

View file

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

View file

@ -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. Were hiring! 🚀🧠"),
note: .init(
stringValue:
"Building beautiful things @Modular_AI 🔥, lifting the world of production AI/ML software into a new phase of innovation. Were hiring! 🚀🧠"
),
createdAt: ServerDate(),
followersCount: 77100,
followingCount: 167,
@ -117,7 +122,8 @@ struct AvatarImage: View {
if reasons == .placeholder {
AvatarPlaceHolder(config: config)
} else {
LazyImage(request: ImageRequest(url: avatar, processors: [.resize(size: config.size)])
LazyImage(
request: ImageRequest(url: avatar, processors: [.resize(size: config.size)])
) { state in
if let image = state.image {
image

View file

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

View file

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

View file

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

View file

@ -12,14 +12,16 @@ public struct PlaceholderView: View {
}
public var body: some View {
ContentUnavailableView(title,
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.")
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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