Swiftformat

This commit is contained in:
Thomas Ricouard 2023-01-17 11:36:01 +01:00
parent 96344e2815
commit 7f6419ebae
161 changed files with 1777 additions and 1746 deletions

1
.swiftformat Normal file
View file

@ -0,0 +1 @@
--indent 2

View file

@ -644,7 +644,7 @@
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 730;
DEVELOPMENT_TEAM = Z6P74P6T99; DEVELOPMENT_TEAM = Z6P74P6T99;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IceCubesShareExtension/Info.plist; INFOPLIST_FILE = IceCubesShareExtension/Info.plist;
@ -674,7 +674,7 @@
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 730;
DEVELOPMENT_TEAM = Z6P74P6T99; DEVELOPMENT_TEAM = Z6P74P6T99;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IceCubesShareExtension/Info.plist; INFOPLIST_FILE = IceCubesShareExtension/Info.plist;

View file

@ -1,16 +1,16 @@
import Account
import AppAccount
import DesignSystem
import Env
import Lists
import Status
import SwiftUI import SwiftUI
import Timeline import Timeline
import Account
import Env
import Status
import DesignSystem
import Lists
import AppAccount
@MainActor @MainActor
extension View { extension View {
func withAppRouteur() -> some View { func withAppRouteur() -> some View {
self.navigationDestination(for: RouteurDestinations.self) { destination in navigationDestination(for: RouteurDestinations.self) { destination in
switch destination { switch destination {
case let .accountDetail(id): case let .accountDetail(id):
AccountDetailView(accountId: id) AccountDetailView(accountId: id)
@ -35,9 +35,9 @@ extension View {
} }
} }
} }
func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View { func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View {
self.sheet(item: sheetDestinations) { destination in sheet(item: sheetDestinations) { destination in
switch destination { switch destination {
case let .replyToStatusEditor(status): case let .replyToStatusEditor(status):
StatusEditorView(mode: .replyTo(status: status)) StatusEditorView(mode: .replyTo(status: status))
@ -69,10 +69,9 @@ extension View {
} }
} }
} }
func withEnvironments() -> some View { func withEnvironments() -> some View {
self environmentObject(CurrentAccount.shared)
.environmentObject(CurrentAccount.shared)
.environmentObject(UserPreferences.shared) .environmentObject(UserPreferences.shared)
.environmentObject(CurrentInstance.shared) .environmentObject(CurrentInstance.shared)
.environmentObject(Theme.shared) .environmentObject(Theme.shared)

View file

@ -1,18 +1,18 @@
import SwiftUI
import AVFoundation
import Timeline
import Network
import KeychainSwift
import Env
import DesignSystem
import RevenueCat
import AppAccount
import Account import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import Network
import RevenueCat
import SwiftUI
import Timeline
@main @main
struct IceCubesApp: App { struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@StateObject private var appAccountsManager = AppAccountsManager.shared @StateObject private var appAccountsManager = AppAccountsManager.shared
@StateObject private var currentInstance = CurrentInstance.shared @StateObject private var currentInstance = CurrentInstance.shared
@ -21,38 +21,38 @@ struct IceCubesApp: App {
@StateObject private var watcher = StreamWatcher() @StateObject private var watcher = StreamWatcher()
@StateObject private var quickLook = QuickLook() @StateObject private var quickLook = QuickLook()
@StateObject private var theme = Theme.shared @StateObject private var theme = Theme.shared
@State private var selectedTab: Tab = .timeline @State private var selectedTab: Tab = .timeline
@State private var selectSidebarItem: Tab? = .timeline @State private var selectSidebarItem: Tab? = .timeline
@State private var popToRootTab: Tab = .other @State private var popToRootTab: Tab = .other
@State private var sideBarLoadedTabs: [Tab] = [] @State private var sideBarLoadedTabs: [Tab] = []
private var availableTabs: [Tab] { private var availableTabs: [Tab] {
appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab() appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
appView appView
.applyTheme(theme) .applyTheme(theme)
.onAppear { .onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient) setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat() setupRevenueCat()
refreshPushSubs() refreshPushSubs()
} }
.environmentObject(appAccountsManager) .environmentObject(appAccountsManager)
.environmentObject(appAccountsManager.currentClient) .environmentObject(appAccountsManager.currentClient)
.environmentObject(quickLook) .environmentObject(quickLook)
.environmentObject(currentAccount) .environmentObject(currentAccount)
.environmentObject(currentInstance) .environmentObject(currentInstance)
.environmentObject(userPreferences) .environmentObject(userPreferences)
.environmentObject(theme) .environmentObject(theme)
.environmentObject(watcher) .environmentObject(watcher)
.environmentObject(PushNotificationsService.shared) .environmentObject(PushNotificationsService.shared)
.sheet(item: $quickLook.url, content: { url in .sheet(item: $quickLook.url, content: { url in
QuickLookPreview(selectedURL: url, urls: quickLook.urls) QuickLookPreview(selectedURL: url, urls: quickLook.urls)
.edgesIgnoringSafeArea(.bottom) .edgesIgnoringSafeArea(.bottom)
}) })
} }
.onChange(of: scenePhase) { scenePhase in .onChange(of: scenePhase) { scenePhase in
handleScenePhase(scenePhase: scenePhase) handleScenePhase(scenePhase: scenePhase)
@ -64,7 +64,7 @@ struct IceCubesApp: App {
} }
} }
} }
@ViewBuilder @ViewBuilder
private var appView: some View { private var appView: some View {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
@ -73,14 +73,14 @@ struct IceCubesApp: App {
tabBarView tabBarView
} }
} }
private func badgeFor(tab: Tab) -> Int { private func badgeFor(tab: Tab) -> Int {
if tab == .notifications && selectedTab != tab { if tab == .notifications && selectedTab != tab {
return watcher.unreadNotificationsCount + userPreferences.pushNotificationsCount return watcher.unreadNotificationsCount + userPreferences.pushNotificationsCount
} }
return 0 return 0
} }
private var sidebarView: some View { private var sidebarView: some View {
SideBarView(selectedTab: $selectedTab, SideBarView(selectedTab: $selectedTab,
popToRootTab: $popToRootTab, popToRootTab: $popToRootTab,
@ -107,7 +107,7 @@ struct IceCubesApp: App {
} }
} }
} }
private var tabBarView: some View { private var tabBarView: some View {
TabView(selection: .init(get: { TabView(selection: .init(get: {
selectedTab selectedTab
@ -132,14 +132,14 @@ struct IceCubesApp: App {
} }
} }
} }
private func setNewClientsInEnv(client: Client) { private func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client) currentAccount.setClient(client: client)
currentInstance.setClient(client: client) currentInstance.setClient(client: client)
userPreferences.setClient(client: client) userPreferences.setClient(client: client)
watcher.setClient(client: client) watcher.setClient(client: client)
} }
private func handleScenePhase(scenePhase: ScenePhase) { private func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase { switch scenePhase {
case .background: case .background:
@ -156,33 +156,34 @@ struct IceCubesApp: App {
break break
} }
} }
private func setupRevenueCat() { private func setupRevenueCat() {
Purchases.logLevel = .error Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi") Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
} }
private func refreshPushSubs() { private func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications() PushNotificationsService.shared.requestPushNotifications()
} }
} }
class AppDelegate: NSObject, UIApplicationDelegate { class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, func application(_: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers) try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers)
return true return true
} }
func application(_ application: UIApplication, func application(_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
PushNotificationsService.shared.pushToken = deviceToken PushNotificationsService.shared.pushToken = deviceToken
Task { Task {
await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
} }
} }
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
}
} }

View file

@ -1,6 +1,6 @@
import UIKit
import SwiftUI
import QuickLook import QuickLook
import SwiftUI
import UIKit
extension URL: Identifiable { extension URL: Identifiable {
public var id: String { public var id: String {
@ -11,7 +11,7 @@ extension URL: Identifiable {
struct QuickLookPreview: UIViewControllerRepresentable { struct QuickLookPreview: UIViewControllerRepresentable {
let selectedURL: URL let selectedURL: URL
let urls: [URL] let urls: [URL]
func makeUIViewController(context: Context) -> UINavigationController { func makeUIViewController(context: Context) -> UINavigationController {
let controller = AppQLPreviewCpntroller() let controller = AppQLPreviewCpntroller()
controller.dataSource = context.coordinator controller.dataSource = context.coordinator
@ -19,30 +19,31 @@ struct QuickLookPreview: UIViewControllerRepresentable {
let nav = UINavigationController(rootViewController: controller) let nav = UINavigationController(rootViewController: controller)
return nav return nav
} }
func updateUIViewController( func updateUIViewController(
_ uiViewController: UINavigationController, context: Context) {} _: UINavigationController, context _: Context
) {}
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
return Coordinator(parent: self) return Coordinator(parent: self)
} }
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
let parent: QuickLookPreview let parent: QuickLookPreview
init(parent: QuickLookPreview) { init(parent: QuickLookPreview) {
self.parent = parent self.parent = parent
} }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { func numberOfPreviewItems(in _: QLPreviewController) -> Int {
return parent.urls.count return parent.urls.count
} }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { func previewController(_: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
return parent.urls[index] as QLPreviewItem return parent.urls[index] as QLPreviewItem
} }
func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode { func previewController(_: QLPreviewController, editingModeFor _: QLPreviewItem) -> QLPreviewItemEditingMode {
.createCopy .createCopy
} }
} }
@ -52,22 +53,22 @@ class AppQLPreviewCpntroller: QLPreviewController {
private var closeButton: UIBarButtonItem { private var closeButton: UIBarButtonItem {
.init(title: "Done", style: .plain, target: self, action: #selector(onCloseButton)) .init(title: "Done", style: .plain, target: self, action: #selector(onCloseButton))
} }
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
navigationItem.rightBarButtonItem = closeButton navigationItem.rightBarButtonItem = closeButton
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
navigationItem.rightBarButtonItem = closeButton navigationItem.rightBarButtonItem = closeButton
} }
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
navigationItem.rightBarButtonItem = closeButton navigationItem.rightBarButtonItem = closeButton
} }
@objc private func onCloseButton() { @objc private func onCloseButton() {
dismiss(animated: true) dismiss(animated: true)
} }

View file

@ -1,85 +1,85 @@
import SwiftUI
import SafariServices
import Env
import DesignSystem import DesignSystem
import Env
import SafariServices
import SwiftUI
extension View { extension View {
func withSafariRouteur() -> some View { func withSafariRouteur() -> some View {
modifier(SafariRouteur()) modifier(SafariRouteur())
} }
} }
private struct SafariRouteur: ViewModifier { private struct SafariRouteur: ViewModifier {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var routeurPath: RouterPath
@State private var safari: SFSafariViewController? @State private var safari: SFSafariViewController?
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.environment(\.openURL, OpenURLAction { url in .environment(\.openURL, OpenURLAction { url in
routeurPath.handle(url: url) routeurPath.handle(url: url)
}) })
.onAppear { .onAppear {
routeurPath.urlHandler = { url in routeurPath.urlHandler = { url in
guard preferences.preferredBrowser == .inAppSafari else { return .systemAction } guard preferences.preferredBrowser == .inAppSafari else { return .systemAction }
// SFSafariViewController only supports initial URLs with http:// or https:// schemes. // SFSafariViewController only supports initial URLs with http:// or https:// schemes.
guard let scheme = url.scheme, ["https", "http"].contains(scheme.lowercased()) else { guard let scheme = url.scheme, ["https", "http"].contains(scheme.lowercased()) else {
return .systemAction return .systemAction
} }
let safari = SFSafariViewController(url: url) let safari = SFSafariViewController(url: url)
safari.preferredBarTintColor = UIColor(theme.primaryBackgroundColor) safari.preferredBarTintColor = UIColor(theme.primaryBackgroundColor)
safari.preferredControlTintColor = UIColor(theme.tintColor) safari.preferredControlTintColor = UIColor(theme.tintColor)
self.safari = safari self.safari = safari
return .handled return .handled
}
}
.background {
SafariPresenter(safari: safari)
}
}
struct SafariPresenter: UIViewRepresentable {
var safari: SFSafariViewController?
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.isHidden = true
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
guard let safari = safari, let viewController = uiView.findTopViewController() else { return }
viewController.present(safari, animated: true)
} }
}
.background {
SafariPresenter(safari: safari)
}
}
struct SafariPresenter: UIViewRepresentable {
var safari: SFSafariViewController?
func makeUIView(context _: Context) -> UIView {
let view = UIView(frame: .zero)
view.isHidden = true
view.isUserInteractionEnabled = false
return view
} }
func updateUIView(_ uiView: UIView, context _: Context) {
guard let safari = safari, let viewController = uiView.findTopViewController() else { return }
viewController.present(safari, animated: true)
}
}
} }
private extension UIView { private extension UIView {
func findTopViewController() -> UIViewController? { func findTopViewController() -> UIViewController? {
if let nextResponder = self.next as? UIViewController { if let nextResponder = next as? UIViewController {
return nextResponder.topViewController() return nextResponder.topViewController()
} else if let nextResponder = self.next as? UIView { } else if let nextResponder = next as? UIView {
return nextResponder.findTopViewController() return nextResponder.findTopViewController()
} else { } else {
return nil return nil
}
} }
}
} }
private extension UIViewController { private extension UIViewController {
func topViewController() -> UIViewController? { func topViewController() -> UIViewController? {
if let nvc = self as? UINavigationController { if let nvc = self as? UINavigationController {
return nvc.visibleViewController?.topViewController() return nvc.visibleViewController?.topViewController()
} else if let tbc = self as? UITabBarController, let selected = tbc.selectedViewController { } else if let tbc = self as? UITabBarController, let selected = tbc.selectedViewController {
return selected.topViewController() return selected.topViewController()
} else if let presented = self.presentedViewController { } else if let presented = presentedViewController {
return presented.topViewController() return presented.topViewController()
}
return self
} }
return self
}
} }

View file

@ -1,18 +1,18 @@
import SwiftUI
import Env
import Account import Account
import DesignSystem
import AppAccount import AppAccount
import DesignSystem
import Env
import SwiftUI
struct SideBarView<Content: View>: View { struct SideBarView<Content: View>: View {
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@Binding var selectedTab: Tab @Binding var selectedTab: Tab
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var tabs: [Tab] var tabs: [Tab]
@ViewBuilder var content: () -> Content @ViewBuilder var content: () -> Content
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
VStack(alignment: .center) { VStack(alignment: .center) {

View file

@ -1,11 +1,10 @@
import SwiftUI
import Env
import Models
import Shimmer
import Explore
import Env
import Network
import AppAccount import AppAccount
import Env
import Explore
import Models
import Network
import Shimmer
import SwiftUI
struct ExploreTab: View { struct ExploreTab: View {
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@ -13,7 +12,7 @@ struct ExploreTab: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var body: some View { var body: some View {
NavigationStack(path: $routeurPath.path) { NavigationStack(path: $routeurPath.path) {
ExploreView() ExploreView()

View file

@ -1,12 +1,11 @@
import SwiftUI
import Env
import Network
import Account import Account
import Models import AppAccount
import Shimmer
import Conversations import Conversations
import Env import Env
import AppAccount import Models
import Network
import Shimmer
import SwiftUI
struct MessagesTab: View { struct MessagesTab: View {
@EnvironmentObject private var watcher: StreamWatcher @EnvironmentObject private var watcher: StreamWatcher
@ -14,7 +13,7 @@ struct MessagesTab: View {
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var body: some View { var body: some View {
NavigationStack(path: $routeurPath.path) { NavigationStack(path: $routeurPath.path) {
ConversationsListView() ConversationsListView()

View file

@ -1,9 +1,9 @@
import SwiftUI import AppAccount
import Timeline
import Env import Env
import Network import Network
import Notifications import Notifications
import AppAccount import SwiftUI
import Timeline
struct NotificationsTab: View { struct NotificationsTab: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@ -12,7 +12,7 @@ struct NotificationsTab: View {
@EnvironmentObject private var userPreferences: UserPreferences @EnvironmentObject private var userPreferences: UserPreferences
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var body: some View { var body: some View {
NavigationStack(path: $routeurPath.path) { NavigationStack(path: $routeurPath.path) {
NotificationsListView() NotificationsListView()

View file

@ -1,23 +1,23 @@
import SwiftUI
import Network
import Models
import Env
import DesignSystem
import NukeUI
import Shimmer
import AppAccount import AppAccount
import Combine import Combine
import DesignSystem
import Env
import Models
import Network
import NukeUI
import Shimmer
import SwiftUI
struct AddAccountView: View { struct AddAccountView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance @EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var pushNotifications: PushNotificationsService @EnvironmentObject private var pushNotifications: PushNotificationsService
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@State private var instanceName: String = "" @State private var instanceName: String = ""
@State private var instance: Instance? @State private var instance: Instance?
@State private var isSigninIn = false @State private var isSigninIn = false
@ -26,9 +26,9 @@ struct AddAccountView: View {
@State private var instanceFetchError: String? @State private var instanceFetchError: String?
private let instanceNamePublisher = PassthroughSubject<String, Never>() private let instanceNamePublisher = PassthroughSubject<String, Never>()
@FocusState private var isInstanceURLFieldFocused: Bool @FocusState private var isInstanceURLFieldFocused: Bool
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@ -102,7 +102,7 @@ struct AddAccountView: View {
}) })
} }
} }
private var signInSection: some View { private var signInSection: some View {
Section { Section {
Button { Button {
@ -127,13 +127,13 @@ struct AddAccountView: View {
} }
.listRowBackground(theme.tintColor) .listRowBackground(theme.tintColor)
} }
private var instancesListView: some View { private var instancesListView: some View {
Section("Suggestions") { Section("Suggestions") {
if instances.isEmpty { if instances.isEmpty {
placeholderRow placeholderRow
} else { } 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 { Button {
self.instanceName = instance.name self.instanceName = instance.name
} label: { } label: {
@ -154,7 +154,7 @@ struct AddAccountView: View {
} }
} }
} }
private var placeholderRow: some View { private var placeholderRow: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Loading...") Text("Loading...")
@ -171,7 +171,7 @@ struct AddAccountView: View {
.shimmering() .shimmering()
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
private func signIn() async { private func signIn() async {
do { do {
signInClient = .init(server: instanceName) signInClient = .init(server: instanceName)
@ -184,7 +184,7 @@ struct AddAccountView: View {
isSigninIn = false isSigninIn = false
} }
} }
private func continueSignIn(url: URL) async { private func continueSignIn(url: URL) async {
guard let client = signInClient else { guard let client = signInClient else {
isSigninIn = false isSigninIn = false

View file

@ -1,13 +1,13 @@
import SwiftUI
import Models
import DesignSystem import DesignSystem
import Models
import Status import Status
import SwiftUI
struct DisplaySettingsView: View { struct DisplaySettingsView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@State private var isThemeSelectorPresented = false @State private var isThemeSelectorPresented = false
var body: some View { var body: some View {
Form { Form {
Section("Theme") { Section("Theme") {
@ -17,7 +17,7 @@ struct DisplaySettingsView: View {
ColorPicker("Secondary Background color", selection: $theme.secondaryBackgroundColor) ColorPicker("Secondary Background color", selection: $theme.secondaryBackgroundColor)
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
Section("Display") { Section("Display") {
Picker("Avatar position", selection: $theme.avatarPosition) { Picker("Avatar position", selection: $theme.avatarPosition) {
ForEach(Theme.AvatarPosition.allCases, id: \.rawValue) { position in ForEach(Theme.AvatarPosition.allCases, id: \.rawValue) { position in
@ -34,7 +34,7 @@ struct DisplaySettingsView: View {
Text(buttonStyle.description).tag(buttonStyle) Text(buttonStyle.description).tag(buttonStyle)
} }
} }
Picker("Status media style", selection: $theme.statusDisplayStyle) { Picker("Status media style", selection: $theme.statusDisplayStyle) {
ForEach(Theme.StatusDisplayStyle.allCases, id: \.rawValue) { buttonStyle in ForEach(Theme.StatusDisplayStyle.allCases, id: \.rawValue) { buttonStyle in
Text(buttonStyle.description).tag(buttonStyle) Text(buttonStyle.description).tag(buttonStyle)
@ -42,7 +42,7 @@ struct DisplaySettingsView: View {
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
Section { Section {
Button { Button {
theme.selectedSet = .iceCubeDark theme.selectedSet = .iceCubeDark
@ -59,7 +59,7 @@ struct DisplaySettingsView: View {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor) .background(theme.secondaryBackgroundColor)
} }
private var themeSelectorButton: some View { private var themeSelectorButton: some View {
NavigationLink(destination: ThemePreviewView()) { NavigationLink(destination: ThemePreviewView()) {
HStack { HStack {

View file

@ -1,12 +1,12 @@
import SwiftUI
import DesignSystem import DesignSystem
import SwiftUI
struct IconSelectorView: View { struct IconSelectorView: View {
enum Icon: Int, CaseIterable, Identifiable { enum Icon: Int, CaseIterable, Identifiable {
var id: String { var id: String {
"\(rawValue)" "\(rawValue)"
} }
init(string: String) { init(string: String) {
if string == Icon.primary.appIconName { if string == Icon.primary.appIconName {
self = .primary self = .primary
@ -14,12 +14,12 @@ struct IconSelectorView: View {
self = .init(rawValue: Int(String(string.last!))!)! self = .init(rawValue: Int(String(string.last!))!)!
} }
} }
case primary = 0 case primary = 0
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8 case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8
case alt9, alt10, alt11, alt12, alt13, alt14 case alt9, alt10, alt11, alt12, alt13, alt14
case alt15, alt16, alt17, alt18, alt19 case alt15, alt16, alt17, alt18, alt19
var appIconName: String { var appIconName: String {
switch self { switch self {
case .primary: case .primary:
@ -28,17 +28,17 @@ struct IconSelectorView: View {
return "AppIconAlternate\(rawValue)" return "AppIconAlternate\(rawValue)"
} }
} }
var iconName: String { var iconName: String {
"icon\(rawValue)" "icon\(rawValue)"
} }
} }
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: 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))] private let columns = [GridItem(.adaptive(minimum: 125, maximum: 1024))]
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading) { VStack(alignment: .leading) {

View file

@ -1,13 +1,13 @@
import SwiftUI
import Models
import DesignSystem import DesignSystem
import Models
import NukeUI import NukeUI
import SwiftUI
struct InstanceInfoView: View { struct InstanceInfoView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
let instance: Instance let instance: Instance
var body: some View { var body: some View {
Form { Form {
InstanceInfoSection(instance: instance) InstanceInfoSection(instance: instance)
@ -20,9 +20,9 @@ struct InstanceInfoView: View {
public struct InstanceInfoSection: View { public struct InstanceInfoSection: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
let instance: Instance let instance: Instance
public var body: some View { public var body: some View {
Section("Instance info") { Section("Instance info") {
LabeledContent("Name", value: instance.title) LabeledContent("Name", value: instance.title)
@ -34,7 +34,7 @@ public struct InstanceInfoSection: View {
LabeledContent("Domains", value: "\(instance.stats.domainCount)") LabeledContent("Domains", value: "\(instance.stats.domainCount)")
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
Section("Instance rules") { Section("Instance rules") {
ForEach(instance.rules) { rule in ForEach(instance.rules) { rule in
Text(rule.text) Text(rule.text)

View file

@ -1,19 +1,19 @@
import SwiftUI
import Models
import DesignSystem
import NukeUI
import Network
import UserNotifications
import Env
import AppAccount import AppAccount
import DesignSystem
import Env
import Models
import Network
import NukeUI
import SwiftUI
import UserNotifications
struct PushNotificationsView: View { struct PushNotificationsView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var pushNotifications: PushNotificationsService @EnvironmentObject private var pushNotifications: PushNotificationsService
@State private var subscriptions: [PushSubscription] = [] @State private var subscriptions: [PushSubscription] = []
var body: some View { var body: some View {
Form { Form {
Section { Section {
@ -24,7 +24,7 @@ struct PushNotificationsView: View {
Text("Receive push notifications on new activities") Text("Receive push notifications on new activities")
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
if pushNotifications.isPushEnabled { if pushNotifications.isPushEnabled {
Section { Section {
Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) { Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) {
@ -87,7 +87,7 @@ struct PushNotificationsView: View {
updateSubscriptions() updateSubscriptions()
} }
} }
private func updateSubscriptions() { private func updateSubscriptions() {
Task { Task {
await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts) await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts)

View file

@ -1,11 +1,11 @@
import Account
import AppAccount
import DesignSystem
import Env
import Models
import Network
import SwiftUI import SwiftUI
import Timeline import Timeline
import Env
import Network
import Account
import Models
import DesignSystem
import AppAccount
struct SettingsTabs: View { struct SettingsTabs: View {
@EnvironmentObject private var pushNotifications: PushNotificationsService @EnvironmentObject private var pushNotifications: PushNotificationsService
@ -14,13 +14,13 @@ struct SettingsTabs: View {
@EnvironmentObject private var currentInstance: CurrentInstance @EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@State private var addAccountSheetPresented = false @State private var addAccountSheetPresented = false
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
var body: some View { var body: some View {
NavigationStack(path: $routeurPath.path) { NavigationStack(path: $routeurPath.path) {
Form { Form {
@ -52,7 +52,7 @@ struct SettingsTabs: View {
} }
} }
} }
private var accountsSection: some View { private var accountsSection: some View {
Section("Accounts") { Section("Accounts") {
ForEach(appAccountsManager.availableAccounts) { account in ForEach(appAccountsManager.availableAccounts) { account in
@ -73,7 +73,7 @@ struct SettingsTabs: View {
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
@ViewBuilder @ViewBuilder
private var generalSection: some View { private var generalSection: some View {
Section("General") { Section("General") {
@ -106,7 +106,7 @@ struct SettingsTabs: View {
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
private var appSection: some View { private var appSection: some View {
Section("App") { Section("App") {
NavigationLink(destination: IconSelectorView()) { NavigationLink(destination: IconSelectorView()) {
@ -121,19 +121,19 @@ struct SettingsTabs: View {
} }
} }
} }
Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp")!) { Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp")!) {
Label("Source (GitHub link)", systemImage: "link") Label("Source (GitHub link)", systemImage: "link")
} }
.tint(theme.labelColor) .tint(theme.labelColor)
NavigationLink(destination: SupportAppView()) { NavigationLink(destination: SupportAppView()) {
Label("Support the app", systemImage: "wand.and.stars") Label("Support the app", systemImage: "wand.and.stars")
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
private var addAccountButton: some View { private var addAccountButton: some View {
Button { Button {
addAccountSheetPresented.toggle() addAccountSheetPresented.toggle()
@ -144,7 +144,7 @@ struct SettingsTabs: View {
AddAccountView() AddAccountView()
} }
} }
private var remoteLocalTimelinesView: some View { private var remoteLocalTimelinesView: some View {
Form { Form {
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in ForEach(preferences.remoteLocalTimelines, id: \.self) { server in

View file

@ -1,21 +1,21 @@
import SwiftUI
import Env
import DesignSystem import DesignSystem
import Env
import RevenueCat import RevenueCat
import Shimmer import Shimmer
import SwiftUI
struct SupportAppView: View { struct SupportAppView: View {
enum Tips: String, CaseIterable { enum Tips: String, CaseIterable {
case one, two, three case one, two, three
init(productId: String) { init(productId: String) {
self = .init(rawValue: String(productId.split(separator: ".")[2]))! self = .init(rawValue: String(productId.split(separator: ".")[2]))!
} }
var productId: String { var productId: String {
"icecubes.tipjar.\(rawValue)" "icecubes.tipjar.\(rawValue)"
} }
var title: String { var title: String {
switch self { switch self {
case .one: case .one:
@ -26,7 +26,7 @@ struct SupportAppView: View {
return "🤯 Generous Tip" return "🤯 Generous Tip"
} }
} }
var subtitle: String { var subtitle: String {
switch self { switch self {
case .one: case .one:
@ -38,15 +38,15 @@ struct SupportAppView: View {
} }
} }
} }
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@State private var loadingProducts: Bool = false @State private var loadingProducts: Bool = false
@State private var products: [StoreProduct] = [] @State private var products: [StoreProduct] = []
@State private var isProcessingPurchase: Bool = false @State private var isProcessingPurchase: Bool = false
@State private var purchaseSuccessDisplayed: Bool = false @State private var purchaseSuccessDisplayed: Bool = false
@State private var purchaseErrorDisplayed: Bool = false @State private var purchaseErrorDisplayed: Bool = false
var body: some View { var body: some View {
Form { Form {
Section { Section {
@ -65,7 +65,7 @@ struct SupportAppView: View {
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
Section { Section {
if loadingProducts { if loadingProducts {
HStack { HStack {
@ -133,7 +133,7 @@ struct SupportAppView: View {
}) })
.onAppear { .onAppear {
loadingProducts = true loadingProducts = true
Purchases.shared.getProducts(Tips.allCases.map{ $0.productId }) { products in Purchases.shared.getProducts(Tips.allCases.map { $0.productId }) { products in
self.products = products.sorted(by: { $0.price < $1.price }) self.products = products.sorted(by: { $0.price < $1.price })
withAnimation { withAnimation {
loadingProducts = false loadingProducts = false

View file

@ -1,22 +1,22 @@
import Foundation
import Status
import Account import Account
import Explore import Explore
import Foundation
import Status
import SwiftUI import SwiftUI
enum Tab: Int, Identifiable, Hashable { enum Tab: Int, Identifiable, Hashable {
case timeline, notifications, explore, messages, settings, other case timeline, notifications, explore, messages, settings, other
case trending, federated, local case trending, federated, local
case profile case profile
var id: Int { var id: Int {
rawValue rawValue
} }
static func loggedOutTab() -> [Tab] { static func loggedOutTab() -> [Tab] {
[.timeline, .settings] [.timeline, .settings]
} }
static func loggedInTabs() -> [Tab] { static func loggedInTabs() -> [Tab] {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
return [.timeline, .trending, .federated, .local, .notifications, .explore, .messages, .settings] return [.timeline, .trending, .federated, .local, .notifications, .explore, .messages, .settings]
@ -24,7 +24,7 @@ enum Tab: Int, Identifiable, Hashable {
return [.timeline, .notifications, .explore, .messages, .settings] return [.timeline, .notifications, .explore, .messages, .settings]
} }
} }
@ViewBuilder @ViewBuilder
func makeContentView(popToRootTab: Binding<Tab>) -> some View { func makeContentView(popToRootTab: Binding<Tab>) -> some View {
switch self { switch self {
@ -48,7 +48,7 @@ enum Tab: Int, Identifiable, Hashable {
EmptyView() EmptyView()
} }
} }
@ViewBuilder @ViewBuilder
var label: some View { var label: some View {
switch self { switch self {
@ -72,7 +72,7 @@ enum Tab: Int, Identifiable, Hashable {
EmptyView() EmptyView()
} }
} }
var iconName: String { var iconName: String {
switch self { switch self {
case .timeline: case .timeline:
@ -96,4 +96,3 @@ enum Tab: Int, Identifiable, Hashable {
} }
} }
} }

View file

@ -1,26 +1,26 @@
import SwiftUI import Combine
import Network
import Models
import Env
import DesignSystem import DesignSystem
import Env
import Models
import Network
import NukeUI import NukeUI
import Shimmer import Shimmer
import Combine import SwiftUI
struct AddRemoteTimelineView: View { struct AddRemoteTimelineView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@State private var instanceName: String = "" @State private var instanceName: String = ""
@State private var instance: Instance? @State private var instance: Instance?
@State private var instances: [InstanceSocial] = [] @State private var instances: [InstanceSocial] = []
private let instanceNamePublisher = PassthroughSubject<String, Never>() private let instanceNamePublisher = PassthroughSubject<String, Never>()
@FocusState private var isInstanceURLFieldFocused: Bool @FocusState private var isInstanceURLFieldFocused: Bool
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@ -44,7 +44,7 @@ struct AddRemoteTimelineView: View {
Text("Add") Text("Add")
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
instancesListView instancesListView
} }
.formStyle(.grouped) .formStyle(.grouped)
@ -76,14 +76,14 @@ struct AddRemoteTimelineView: View {
} }
} }
} }
private var instancesListView: some View { private var instancesListView: some View {
Section("Suggestions") { Section("Suggestions") {
if instances.isEmpty { if instances.isEmpty {
ProgressView() ProgressView()
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} else { } 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 { Button {
self.instanceName = instance.name self.instanceName = instance.name
} label: { } label: {

View file

@ -1,11 +1,11 @@
import SwiftUI import AppAccount
import Timeline
import Env
import Network
import Combine import Combine
import DesignSystem import DesignSystem
import Env
import Models import Models
import AppAccount import Network
import SwiftUI
import Timeline
struct TimelineTab: View { struct TimelineTab: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ -14,19 +14,19 @@ struct TimelineTab: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
@State private var timeline: TimelineFilter @State private var timeline: TimelineFilter
@State private var scrollToTopSignal: Int = 0 @State private var scrollToTopSignal: Int = 0
private let canFilterTimeline: Bool private let canFilterTimeline: Bool
init(popToRootTab: Binding<Tab>, timeline: TimelineFilter? = nil) { init(popToRootTab: Binding<Tab>, timeline: TimelineFilter? = nil) {
canFilterTimeline = timeline == nil canFilterTimeline = timeline == nil
self.timeline = timeline ?? .home self.timeline = timeline ?? .home
_popToRootTab = popToRootTab _popToRootTab = popToRootTab
} }
var body: some View { var body: some View {
NavigationStack(path: $routeurPath.path) { NavigationStack(path: $routeurPath.path) {
TimelineView(timeline: $timeline, scrollToTopSignal: $scrollToTopSignal) TimelineView(timeline: $timeline, scrollToTopSignal: $scrollToTopSignal)
@ -71,8 +71,7 @@ struct TimelineTab: View {
.withSafariRouteur() .withSafariRouteur()
.environmentObject(routeurPath) .environmentObject(routeurPath)
} }
@ViewBuilder @ViewBuilder
private var timelineFilterButton: some View { private var timelineFilterButton: some View {
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
@ -93,7 +92,7 @@ struct TimelineTab: View {
} }
} }
} }
if !currentAccount.tags.isEmpty { if !currentAccount.tags.isEmpty {
Menu("Followed Tags") { Menu("Followed Tags") {
ForEach(currentAccount.tags) { tag in ForEach(currentAccount.tags) { tag in
@ -105,7 +104,7 @@ struct TimelineTab: View {
} }
} }
} }
Menu("Local Timelines") { Menu("Local Timelines") {
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in ForEach(preferences.remoteLocalTimelines, id: \.self) { server in
Button { Button {
@ -121,7 +120,7 @@ struct TimelineTab: View {
} }
} }
} }
private var addAccountButton: some View { private var addAccountButton: some View {
Button { Button {
routeurPath.presentedSheet = .addAccount routeurPath.presentedSheet = .addAccount
@ -129,7 +128,7 @@ struct TimelineTab: View {
Image(systemName: "person.badge.plus") Image(systemName: "person.badge.plus")
} }
} }
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbarView: some ToolbarContent { private var toolbarView: some ToolbarContent {
if canFilterTimeline { if canFilterTimeline {

View file

@ -1,71 +1,75 @@
import UserNotifications
import KeychainSwift
import Env
import CryptoKit import CryptoKit
import Env
import KeychainSwift
import Models import Models
import UIKit import UIKit
import UserNotifications
@MainActor @MainActor
class NotificationService: UNNotificationServiceExtension { class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)? var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent? var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent { if let bestAttemptContent {
let privateKey = PushNotificationsService.shared.notificationsPrivateKeyAsKey let privateKey = PushNotificationsService.shared.notificationsPrivateKeyAsKey
let auth = PushNotificationsService.shared.notificationsAuthKeyAsKey let auth = PushNotificationsService.shared.notificationsAuthKeyAsKey
guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String, guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String,
let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64()) else { let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64())
else {
contentHandler(bestAttemptContent) contentHandler(bestAttemptContent)
return return
} }
guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String, guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String,
let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()), let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()),
let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) else { let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
else {
contentHandler(bestAttemptContent) contentHandler(bestAttemptContent)
return return
} }
guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String, guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String,
let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64()) else { let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64())
else {
contentHandler(bestAttemptContent) contentHandler(bestAttemptContent)
return return
} }
guard let plaintextData = NotificationService.decrypt(payload: payload, guard let plaintextData = NotificationService.decrypt(payload: payload,
salt: salt, salt: salt,
auth: auth, auth: auth,
privateKey: privateKey, privateKey: privateKey,
publicKey: publicKey), publicKey: publicKey),
let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else { let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData)
else {
contentHandler(bestAttemptContent) contentHandler(bestAttemptContent)
return return
} }
bestAttemptContent.title = notification.title bestAttemptContent.title = notification.title
bestAttemptContent.subtitle = "" bestAttemptContent.subtitle = ""
bestAttemptContent.body = notification.body.escape() bestAttemptContent.body = notification.body.escape()
bestAttemptContent.userInfo["plaintext"] = plaintextData bestAttemptContent.userInfo["plaintext"] = plaintextData
bestAttemptContent.sound = UNNotificationSound.init(named: UNNotificationSoundName(rawValue: "glass.wav")) bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "glass.wav"))
let preferences = UserPreferences.shared let preferences = UserPreferences.shared
preferences.pushNotificationsCount += 1 preferences.pushNotificationsCount += 1
bestAttemptContent.badge = .init(integerLiteral: preferences.pushNotificationsCount) bestAttemptContent.badge = .init(integerLiteral: preferences.pushNotificationsCount)
if let urlString = notification.icon, if let urlString = notification.icon,
let url = URL(string: urlString) { let url = URL(string: urlString)
{
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments")
try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil)
let filename = url.lastPathComponent let filename = url.lastPathComponent
let fileURL = temporaryDirectoryURL.appendingPathComponent(filename) let fileURL = temporaryDirectoryURL.appendingPathComponent(filename)
Task { Task {
if let (data, _) = try? await URLSession.shared.data(for: .init(url: url)) { if let (data, _) = try? await URLSession.shared.data(for: .init(url: url)) {
if let image = UIImage(data: data) { if let image = UIImage(data: data) {

View file

@ -1,26 +1,26 @@
import Foundation
import CryptoKit import CryptoKit
import Foundation
extension NotificationService { 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 { guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
return nil 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 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 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 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 nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceData = nonce.withUnsafeBytes(Array.init) let nonceData = nonce.withUnsafeBytes(Array.init)
guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else { guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else {
return nil return nil
} }
var _plaintext: Data? var _plaintext: Data?
do { do {
_plaintext = try AES.GCM.open(sealedBox, using: key) _plaintext = try AES.GCM.open(sealedBox, using: key)
@ -30,20 +30,20 @@ extension NotificationService {
guard let plaintext = _plaintext else { guard let plaintext = _plaintext else {
return nil return nil
} }
let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1]) let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1])
guard plaintext.count >= 2 + paddingLength else { guard plaintext.count >= 2 + paddingLength else {
print("1") print("1")
fatalError() fatalError()
} }
let unpadded = plaintext.suffix(from: paddingLength + 2) let unpadded = plaintext.suffix(from: paddingLength + 2)
return Data(unpadded) return Data(unpadded)
} }
static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data { private static func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data {
var info = Data() var info = Data()
info.append("Content-Encoding: ".data(using: .utf8)!) info.append("Content-Encoding: ".data(using: .utf8)!)
info.append(type.data(using: .utf8)!) info.append(type.data(using: .utf8)!)
info.append(0) info.append(0)
@ -55,32 +55,29 @@ extension NotificationService {
info.append(0) info.append(0)
info.append(65) info.append(65)
info.append(serverPublicKey) info.append(serverPublicKey)
return info return info
} }
} }
extension String { extension String {
func escape() -> String { func escape() -> String {
return self return replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<") .replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">") .replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"") .replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'") .replacingOccurrences(of: "&apos;", with: "'")
.replacingOccurrences(of: "&#39;", with: "") .replacingOccurrences(of: "&#39;", with: "")
} }
func URLSafeBase64ToBase64() -> String { func URLSafeBase64ToBase64() -> String {
var base64 = replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") var base64 = replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let countMod4 = count % 4 let countMod4 = count % 4
if countMod4 != 0 { if countMod4 != 0 {
base64.append(String(repeating: "=", count: 4 - countMod4)) base64.append(String(repeating: "=", count: 4 - countMod4))
} }
return base64 return base64
} }
} }

View file

@ -8,14 +8,14 @@
<dict> <dict>
<key>NSExtensionActivationRule</key> <key>NSExtensionActivationRule</key>
<dict> <dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key> <key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>4</integer> <integer>4</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict> </dict>
</dict> </dict>
<key>NSExtensionMainStoryboard</key> <key>NSExtensionMainStoryboard</key>

View file

@ -1,18 +1,18 @@
import Account
import AppAccount
import DesignSystem
import Env
import Network
import Status
import SwiftUI import SwiftUI
import UIKit import UIKit
import Status
import DesignSystem
import Account
import Network
import Env
import AppAccount
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
@IBOutlet var container: UIView! @IBOutlet var container: UIView!
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
let appAccountsManager = AppAccountsManager.shared let appAccountsManager = AppAccountsManager.shared
let client = appAccountsManager.currentClient let client = appAccountsManager.currentClient
let account = CurrentAccount.shared let account = CurrentAccount.shared
@ -22,7 +22,7 @@ class ShareViewController: UIViewController {
let colorScheme = traitCollection.userInterfaceStyle let colorScheme = traitCollection.userInterfaceStyle
let theme = Theme.shared let theme = Theme.shared
theme.setColor(withName: colorScheme == .dark ? .iceCubeDark : .iceCubeLight) theme.setColor(withName: colorScheme == .dark ? .iceCubeDark : .iceCubeLight)
if let item = extensionContext?.inputItems.first as? NSExtensionItem { if let item = extensionContext?.inputItems.first as? NSExtensionItem {
if let attachments = item.attachments { if let attachments = item.attachments {
let view = StatusEditorView(mode: .shareExtension(items: attachments)) let view = StatusEditorView(mode: .shareExtension(items: attachments))
@ -35,24 +35,24 @@ class ShareViewController: UIViewController {
.tint(theme.tintColor) .tint(theme.tintColor)
.preferredColorScheme(colorScheme == .light ? .light : .dark) .preferredColorScheme(colorScheme == .light ? .light : .dark)
let childView = UIHostingController(rootView: view) let childView = UIHostingController(rootView: view)
self.addChild(childView) addChild(childView)
childView.view.frame = self.container.bounds childView.view.frame = container.bounds
self.container.addSubview(childView.view) container.addSubview(childView.view)
childView.didMove(toParent: self) childView.didMove(toParent: self)
} }
} }
NotificationCenter.default.addObserver(forName: NotificationsName.shareSheetClose, NotificationCenter.default.addObserver(forName: NotificationsName.shareSheetClose,
object: nil, object: nil,
queue: nil) { _ in queue: nil) { _ in
self.close() self.close()
} }
} }
func close() { func close() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil) extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
} }

View file

@ -11,7 +11,8 @@ let package = Package(
products: [ products: [
.library( .library(
name: "Account", name: "Account",
targets: ["Account"]), targets: ["Account"]
),
], ],
dependencies: [ dependencies: [
.package(name: "Network", path: "../Network"), .package(name: "Network", path: "../Network"),
@ -25,9 +26,11 @@ let package = Package(
.product(name: "Network", package: "Network"), .product(name: "Network", package: "Network"),
.product(name: "Models", package: "Models"), .product(name: "Models", package: "Models"),
.product(name: "Status", package: "Status"), .product(name: "Status", package: "Status"),
]), ]
),
.testTarget( .testTarget(
name: "AccountTests", name: "AccountTests",
dependencies: ["Account"]), dependencies: ["Account"]
),
] ]
) )

View file

@ -1,23 +1,23 @@
import SwiftUI
import Models
import DesignSystem import DesignSystem
import Env
import Shimmer
import NukeUI
import EmojiText import EmojiText
import Env
import Models
import NukeUI
import Shimmer
import SwiftUI
struct AccountDetailHeaderView: View { struct AccountDetailHeaderView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var quickLook: QuickLook @EnvironmentObject private var quickLook: QuickLook
@EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var routeurPath: RouterPath
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@ObservedObject var viewModel: AccountDetailViewModel @ObservedObject var viewModel: AccountDetailViewModel
let account: Account let account: Account
let scrollViewProxy: ScrollViewProxy? let scrollViewProxy: ScrollViewProxy?
@Binding var scrollOffset: CGFloat @Binding var scrollOffset: CGFloat
private var bannerHeight: CGFloat { private var bannerHeight: CGFloat {
200 + (scrollOffset > 0 ? scrollOffset * 2 : 0) 200 + (scrollOffset > 0 ? scrollOffset * 2 : 0)
} }
@ -28,9 +28,9 @@ struct AccountDetailHeaderView: View {
accountInfoView accountInfoView
} }
} }
private var headerImageView: some View { private var headerImageView: some View {
GeometryReader { proxy in GeometryReader { _ in
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
if reasons.contains(.placeholder) { if reasons.contains(.placeholder) {
Rectangle() Rectangle()
@ -53,7 +53,7 @@ struct AccountDetailHeaderView: View {
} }
.frame(height: bannerHeight) .frame(height: bannerHeight)
} }
if viewModel.relationship?.followedBy == true { if viewModel.relationship?.followedBy == true {
Text("Follows You") Text("Follows You")
.font(.footnote) .font(.footnote)
@ -75,15 +75,15 @@ struct AccountDetailHeaderView: View {
} }
} }
} }
private var accountAvatarView: some View { private var accountAvatarView: some View {
HStack { HStack {
AvatarView(url: account.avatar, size: .account) AvatarView(url: account.avatar, size: .account)
.onTapGesture { .onTapGesture {
Task { Task {
await quickLook.prepareFor(urls: [account.avatar], selectedURL: account.avatar) await quickLook.prepareFor(urls: [account.avatar], selectedURL: account.avatar)
}
} }
}
Spacer() Spacer()
Group { Group {
Button { Button {
@ -102,17 +102,17 @@ struct AccountDetailHeaderView: View {
}.offset(y: 20) }.offset(y: 20)
} }
} }
private var accountInfoView: some View { private var accountInfoView: some View {
Group { Group {
accountAvatarView accountAvatarView
HStack { HStack {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
EmojiTextApp(account.safeDisplayName.asMarkdown, emojis: account.emojis) EmojiTextApp(account.safeDisplayName.asMarkdown, emojis: account.emojis)
.font(.headline) .font(.headline)
Text("@\(account.acct)") Text("@\(account.acct)")
.font(.callout) .font(.callout)
.foregroundColor(.gray) .foregroundColor(.gray)
} }
Spacer() Spacer()
if let relationship = viewModel.relationship, !viewModel.isCurrentUser { if let relationship = viewModel.relationship, !viewModel.isCurrentUser {
@ -133,7 +133,7 @@ struct AccountDetailHeaderView: View {
.padding(.horizontal, .layoutPadding) .padding(.horizontal, .layoutPadding)
.offset(y: -40) .offset(y: -40)
} }
private func makeCustomInfoLabel(title: String, count: Int) -> some View { private func makeCustomInfoLabel(title: String, count: Int) -> some View {
VStack { VStack {
Text("\(count)") Text("\(count)")

View file

@ -1,13 +1,13 @@
import SwiftUI import DesignSystem
import EmojiText
import Env
import Models import Models
import Network import Network
import Status
import Shimmer import Shimmer
import DesignSystem import Status
import Env import SwiftUI
import EmojiText
public struct AccountDetailView: View { public struct AccountDetailView: View {
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@EnvironmentObject private var watcher: StreamWatcher @EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@ -15,7 +15,7 @@ public struct AccountDetailView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var routeurPath: RouterPath
@StateObject private var viewModel: AccountDetailViewModel @StateObject private var viewModel: AccountDetailViewModel
@State private var scrollOffset: CGFloat = 0 @State private var scrollOffset: CGFloat = 0
@State private var isFieldsSheetDisplayed: Bool = false @State private var isFieldsSheetDisplayed: Bool = false
@ -23,17 +23,17 @@ public struct AccountDetailView: View {
@State private var isCreateListAlertPresented: Bool = false @State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = "" @State private var createListTitle: String = ""
@State private var isEditingAccount: Bool = false @State private var isEditingAccount: Bool = false
/// When coming from a URL like a mention tap in a status. /// When coming from a URL like a mention tap in a status.
public init(accountId: String) { public init(accountId: String) {
_viewModel = StateObject(wrappedValue: .init(accountId: accountId)) _viewModel = StateObject(wrappedValue: .init(accountId: accountId))
} }
/// When the account is already fetched by the parent caller. /// When the account is already fetched by the parent caller.
public init(account: Account) { public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account)) _viewModel = StateObject(wrappedValue: .init(account: account))
} }
public var body: some View { public var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollViewOffsetReader { offset in ScrollViewOffsetReader { offset in
@ -58,7 +58,7 @@ public struct AccountDetailView: View {
.offset(y: -20) .offset(y: -20)
} }
.id("status") .id("status")
switch viewModel.tabState { switch viewModel.tabState {
case .statuses: case .statuses:
if viewModel.selectedTab == .statuses { if viewModel.selectedTab == .statuses {
@ -94,9 +94,10 @@ public struct AccountDetailView: View {
await viewModel.fetchStatuses() await viewModel.fetchStatuses()
} }
} }
.onChange(of: watcher.latestEvent?.id) { id in .onChange(of: watcher.latestEvent?.id) { _ in
if let latestEvent = watcher.latestEvent, if let latestEvent = watcher.latestEvent,
viewModel.accountId == currentAccount.account?.id { viewModel.accountId == currentAccount.account?.id
{
viewModel.handleEvent(event: latestEvent, currentAccount: currentAccount) viewModel.handleEvent(event: latestEvent, currentAccount: currentAccount)
} }
} }
@ -117,7 +118,7 @@ public struct AccountDetailView: View {
toolbarContent toolbarContent
} }
} }
@ViewBuilder @ViewBuilder
private func makeHeaderView(proxy: ScrollViewProxy?) -> some View { private func makeHeaderView(proxy: ScrollViewProxy?) -> some View {
switch viewModel.accountState { switch viewModel.accountState {
@ -137,7 +138,7 @@ public struct AccountDetailView: View {
Text("Error: \(error.localizedDescription)") Text("Error: \(error.localizedDescription)")
} }
} }
@ViewBuilder @ViewBuilder
private var featuredTagsView: some View { private var featuredTagsView: some View {
if !viewModel.featuredTags.isEmpty || !viewModel.fields.isEmpty { if !viewModel.featuredTags.isEmpty || !viewModel.fields.isEmpty {
@ -178,7 +179,7 @@ public struct AccountDetailView: View {
} }
} }
} }
@ViewBuilder @ViewBuilder
private var familliarFollowers: some View { private var familliarFollowers: some View {
if !viewModel.familliarFollowers.isEmpty { if !viewModel.familliarFollowers.isEmpty {
@ -203,7 +204,7 @@ public struct AccountDetailView: View {
.padding(.bottom, 12) .padding(.bottom, 12)
} }
} }
private var fieldSheetView: some View { private var fieldSheetView: some View {
NavigationStack { NavigationStack {
List { List {
@ -244,7 +245,7 @@ public struct AccountDetailView: View {
} }
} }
} }
private var tagsListView: some View { private var tagsListView: some View {
Group { Group {
ForEach(currentAccount.tags) { tag in ForEach(currentAccount.tags) { tag in
@ -260,7 +261,7 @@ public struct AccountDetailView: View {
await currentAccount.fetchFollowedTags() await currentAccount.fetchFollowedTags()
} }
} }
private var listsListView: some View { private var listsListView: some View {
Group { Group {
ForEach(currentAccount.lists) { list in ForEach(currentAccount.lists) { list in
@ -309,7 +310,7 @@ public struct AccountDetailView: View {
Text("Enter the name for your list") Text("Enter the name for your list")
} }
} }
@ViewBuilder @ViewBuilder
private var pinnedPostsView: some View { private var pinnedPostsView: some View {
if !viewModel.pinned.isEmpty { if !viewModel.pinned.isEmpty {
@ -327,7 +328,7 @@ public struct AccountDetailView: View {
} }
} }
} }
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbarContent: some ToolbarContent { private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
@ -341,7 +342,7 @@ public struct AccountDetailView: View {
} }
} }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Menu { Menu {
if let account = viewModel.account { if let account = viewModel.account {
@ -360,7 +361,7 @@ public struct AccountDetailView: View {
} }
Divider() Divider()
} }
if viewModel.relationship?.following == true { if viewModel.relationship?.following == true {
Button { Button {
routeurPath.presentedSheet = .listAddAccount(account: account) routeurPath.presentedSheet = .listAddAccount(account: account)
@ -368,13 +369,13 @@ public struct AccountDetailView: View {
Label("Add/Remove from lists", systemImage: "list.bullet") Label("Add/Remove from lists", systemImage: "list.bullet")
} }
} }
if let url = account.url { if let url = account.url {
ShareLink(item: url) ShareLink(item: url)
} }
Divider() Divider()
if isCurrentUser { if isCurrentUser {
Button { Button {
isEditingAccount = true isEditingAccount = true
@ -396,4 +397,3 @@ struct AccountDetailView_Previews: PreviewProvider {
AccountDetailView(account: .placeholder()) AccountDetailView(account: .placeholder())
} }
} }

View file

@ -1,30 +1,30 @@
import SwiftUI
import Network
import Models
import Status
import Env import Env
import Models
import Network
import Status
import SwiftUI
@MainActor @MainActor
class AccountDetailViewModel: ObservableObject, StatusesFetcher { class AccountDetailViewModel: ObservableObject, StatusesFetcher {
let accountId: String let accountId: String
var client: Client? var client: Client?
var isCurrentUser: Bool = false var isCurrentUser: Bool = false
enum AccountState { enum AccountState {
case loading, data(account: Account), error(error: Error) case loading, data(account: Account), error(error: Error)
} }
enum Tab: Int { enum Tab: Int {
case statuses, favourites, bookmarks, followedTags, postsAndReplies, media, lists case statuses, favourites, bookmarks, followedTags, postsAndReplies, media, lists
static var currentAccountTabs: [Tab] { static var currentAccountTabs: [Tab] {
[.statuses, .favourites, .bookmarks, .followedTags, .lists] [.statuses, .favourites, .bookmarks, .followedTags, .lists]
} }
static var accountTabs: [Tab] { static var accountTabs: [Tab] {
[.statuses, .postsAndReplies, .media] [.statuses, .postsAndReplies, .media]
} }
var iconName: String { var iconName: String {
switch self { switch self {
case .statuses: return "bubble.right" case .statuses: return "bubble.right"
@ -37,13 +37,13 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
} }
} }
} }
enum TabState { enum TabState {
case followedTags case followedTags
case statuses(statusesState: StatusesState) case statuses(statusesState: StatusesState)
case lists case lists
} }
@Published var accountState: AccountState = .loading @Published var accountState: AccountState = .loading
@Published var tabState: TabState = .statuses(statusesState: .loading) { @Published var tabState: TabState = .statuses(statusesState: .loading) {
didSet { didSet {
@ -57,8 +57,9 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
} }
} }
} }
@Published var statusesState: StatusesState = .loading @Published var statusesState: StatusesState = .loading
@Published var relationship: Relationshionship? @Published var relationship: Relationshionship?
@Published var pinned: [Status] = [] @Published var pinned: [Status] = []
@Published var favourites: [Status] = [] @Published var favourites: [Status] = []
@ -81,45 +82,45 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
} }
} }
} }
private(set) var account: Account? private(set) var account: Account?
private var tabTask: Task<Void, Never>? private var tabTask: Task<Void, Never>?
private(set) var statuses: [Status] = [] private(set) var statuses: [Status] = []
/// When coming from a URL like a mention tap in a status. /// When coming from a URL like a mention tap in a status.
init(accountId: String) { init(accountId: String) {
self.accountId = accountId self.accountId = accountId
self.isCurrentUser = false isCurrentUser = false
} }
/// When the account is already fetched by the parent caller. /// When the account is already fetched by the parent caller.
init(account: Account) { init(account: Account) {
self.accountId = account.id accountId = account.id
self.account = account self.account = account
self.accountState = .data(account: account) accountState = .data(account: account)
} }
struct AccountData { struct AccountData {
let account: Account let account: Account
let featuredTags: [FeaturedTag] let featuredTags: [FeaturedTag]
let relationships: [Relationshionship] let relationships: [Relationshionship]
let familliarFollowers: [FamilliarAccounts] let familliarFollowers: [FamilliarAccounts]
} }
func fetchAccount() async { func fetchAccount() async {
guard let client else { return } guard let client else { return }
do { do {
let data = try await fetchAccountData(accountId: accountId, client: client) let data = try await fetchAccountData(accountId: accountId, client: client)
accountState = .data(account: data.account) accountState = .data(account: data.account)
account = data.account account = data.account
fields = data.account.fields fields = data.account.fields
featuredTags = data.featuredTags featuredTags = data.featuredTags
featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt } featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt }
relationship = data.relationships.first relationship = data.relationships.first
familliarFollowers = data.familliarFollowers.first?.accounts ?? [] familliarFollowers = data.familliarFollowers.first?.accounts ?? []
} catch { } catch {
if let account { if let account {
accountState = .data(account: account) accountState = .data(account: account)
@ -128,7 +129,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
} }
} }
} }
func fetchAccountData(accountId: String, client: Client) async throws -> AccountData { func fetchAccountData(accountId: String, client: Client) async throws -> AccountData {
async let account: Account = client.get(endpoint: Accounts.accounts(id: accountId)) 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))
@ -145,26 +146,26 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
relationships: [], relationships: [],
familliarFollowers: []) familliarFollowers: [])
} }
func fetchStatuses() async { func fetchStatuses() async {
guard let client else { return } guard let client else { return }
do { do {
tabState = .statuses(statusesState: .loading) tabState = .statuses(statusesState: .loading)
statuses = statuses =
try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: nil,
tag: nil,
onlyMedia: selectedTab == .media ? true : nil,
excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil,
pinned: nil))
if selectedTab == .statuses {
pinned =
try await client.get(endpoint: Accounts.statuses(id: accountId, try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: nil, sinceId: nil,
tag: nil, tag: nil,
onlyMedia: nil, onlyMedia: selectedTab == .media ? true : nil,
excludeReplies: nil, excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil,
pinned: true)) pinned: nil))
if selectedTab == .statuses {
pinned =
try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: nil,
tag: nil,
onlyMedia: nil,
excludeReplies: nil,
pinned: true))
} }
if isCurrentUser { if isCurrentUser {
(favourites, favouritesNextPage) = try await client.getWithLink(endpoint: Accounts.favourites(sinceId: nil)) (favourites, favouritesNextPage) = try await client.getWithLink(endpoint: Accounts.favourites(sinceId: nil))
@ -175,7 +176,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
tabState = .statuses(statusesState: .error(error: error)) tabState = .statuses(statusesState: .error(error: error))
} }
} }
func fetchNextPage() async { func fetchNextPage() async {
guard let client else { return } guard let client else { return }
do { do {
@ -184,12 +185,12 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
guard let lastId = statuses.last?.id else { return } guard let lastId = statuses.last?.id else { return }
tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .loadingNextPage)) tabState = .statuses(statusesState: .display(statuses: statuses, nextPageState: .loadingNextPage))
let newStatuses: [Status] = let newStatuses: [Status] =
try await client.get(endpoint: Accounts.statuses(id: accountId, try await client.get(endpoint: Accounts.statuses(id: accountId,
sinceId: lastId, sinceId: lastId,
tag: nil, tag: nil,
onlyMedia: selectedTab == .media ? true : nil, onlyMedia: selectedTab == .media ? true : nil,
excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil, excludeReplies: selectedTab == .statuses && !isCurrentUser ? true : nil,
pinned: nil)) pinned: nil))
statuses.append(contentsOf: newStatuses) statuses.append(contentsOf: newStatuses)
tabState = .statuses(statusesState: .display(statuses: statuses, tabState = .statuses(statusesState: .display(statuses: statuses,
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)) nextPageState: newStatuses.count < 20 ? .none : .hasNextPage))
@ -212,7 +213,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
tabState = .statuses(statusesState: .error(error: error)) tabState = .statuses(statusesState: .error(error: error))
} }
} }
private func reloadTabState() { private func reloadTabState() {
switch selectedTab { switch selectedTab {
case .statuses, .postsAndReplies, .media: case .statuses, .postsAndReplies, .media:
@ -229,7 +230,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
tabState = .lists tabState = .lists
} }
} }
func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) { func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) {
if let event = event as? StreamEventUpdate { if let event = event as? StreamEventUpdate {
if event.status.account.id == currentAccount.account?.id { if event.status.account.id == currentAccount.account?.id {

View file

@ -1,17 +1,17 @@
import SwiftUI import DesignSystem
import EmojiText
import Env
import Models import Models
import Network import Network
import DesignSystem import SwiftUI
import Env
import EmojiText
@MainActor @MainActor
public class AccountsListRowViewModel: ObservableObject { public class AccountsListRowViewModel: ObservableObject {
var client: Client? var client: Client?
@Published var account: Account @Published var account: Account
@Published var relationShip: Relationshionship @Published var relationShip: Relationshionship
public init(account: Account, relationShip: Relationshionship) { public init(account: Account, relationShip: Relationshionship) {
self.account = account self.account = account
self.relationShip = relationShip self.relationShip = relationShip
@ -22,13 +22,13 @@ public struct AccountsListRow: View {
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var routeurPath: RouterPath
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject var viewModel: AccountsListRowViewModel @StateObject var viewModel: AccountsListRowViewModel
public init(viewModel: AccountsListRowViewModel) { public init(viewModel: AccountsListRowViewModel) {
_viewModel = StateObject(wrappedValue: viewModel) _viewModel = StateObject(wrappedValue: viewModel)
} }
public var body: some View { public var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
AvatarView(url: viewModel.account.avatar, size: .status) AvatarView(url: viewModel.account.avatar, size: .status)

View file

@ -1,25 +1,25 @@
import SwiftUI
import Network
import Models
import Env
import Shimmer
import DesignSystem import DesignSystem
import Env
import Models
import Network
import Shimmer
import SwiftUI
public struct AccountsListView: View { public struct AccountsListView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var viewModel: AccountsListViewModel @StateObject private var viewModel: AccountsListViewModel
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
public init(mode: AccountsListMode) { public init(mode: AccountsListMode) {
_viewModel = StateObject(wrappedValue: .init(mode: mode)) _viewModel = StateObject(wrappedValue: .init(mode: mode))
} }
public var body: some View { public var body: some View {
List { List {
switch viewModel.state { switch viewModel.state {
case .loading: case .loading:
ForEach(Account.placeholders()) { account in ForEach(Account.placeholders()) { _ in
AccountsListRow(viewModel: .init(account: .placeholder(), relationShip: .placeholder())) AccountsListRow(viewModel: .init(account: .placeholder(), relationShip: .placeholder()))
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.shimmering() .shimmering()
@ -30,10 +30,10 @@ public struct AccountsListView: View {
if let relationship = relationships.first(where: { $0.id == account.id }) { if let relationship = relationships.first(where: { $0.id == account.id }) {
AccountsListRow(viewModel: .init(account: account, AccountsListRow(viewModel: .init(account: account,
relationShip: relationship)) relationShip: relationship))
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
} }
switch nextPageState { switch nextPageState {
case .hasNextPage: case .hasNextPage:
loadingRow loadingRow
@ -43,14 +43,14 @@ public struct AccountsListView: View {
await viewModel.fetchNextPage() await viewModel.fetchNextPage()
} }
} }
case .loadingNextPage: case .loadingNextPage:
loadingRow loadingRow
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
case .none: case .none:
EmptyView() EmptyView()
} }
case let .error(error): case let .error(error):
Text(error.localizedDescription) Text(error.localizedDescription)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
@ -63,12 +63,12 @@ public struct AccountsListView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .task {
viewModel.client = client viewModel.client = client
guard !didAppear else { return} guard !didAppear else { return }
didAppear = true didAppear = true
await viewModel.fetch() await viewModel.fetch()
} }
} }
private var loadingRow: some View { private var loadingRow: some View {
HStack { HStack {
Spacer() Spacer()

View file

@ -1,11 +1,11 @@
import SwiftUI
import Models import Models
import Network import Network
import SwiftUI
public enum AccountsListMode { public enum AccountsListMode {
case following(accountId: String), followers(accountId: String) case following(accountId: String), followers(accountId: String)
case favouritedBy(statusId: String), rebloggedBy(statusId: String) case favouritedBy(statusId: String), rebloggedBy(statusId: String)
var title: String { var title: String {
switch self { switch self {
case .following: case .following:
@ -23,31 +23,32 @@ public enum AccountsListMode {
@MainActor @MainActor
class AccountsListViewModel: ObservableObject { class AccountsListViewModel: ObservableObject {
var client: Client? var client: Client?
let mode: AccountsListMode let mode: AccountsListMode
public enum State { public enum State {
public enum PagingState { public enum PagingState {
case hasNextPage, loadingNextPage, none case hasNextPage, loadingNextPage, none
} }
case loading case loading
case display(accounts: [Account], case display(accounts: [Account],
relationships: [Relationshionship], relationships: [Relationshionship],
nextPageState: PagingState) nextPageState: PagingState)
case error(error: Error) case error(error: Error)
} }
private var accounts: [Account] = [] private var accounts: [Account] = []
private var relationships: [Relationshionship] = [] private var relationships: [Relationshionship] = []
@Published var state = State.loading @Published var state = State.loading
private var nextPageId: String? private var nextPageId: String?
init(mode: AccountsListMode) { init(mode: AccountsListMode) {
self.mode = mode self.mode = mode
} }
func fetch() async { func fetch() async {
guard let client else { return } guard let client else { return }
do { do {
@ -69,13 +70,13 @@ class AccountsListViewModel: ObservableObject {
} }
nextPageId = link?.maxId nextPageId = link?.maxId
relationships = try await client.get(endpoint: relationships = try await client.get(endpoint:
Accounts.relationships(ids: accounts.map{ $0.id })) Accounts.relationships(ids: accounts.map { $0.id }))
state = .display(accounts: accounts, state = .display(accounts: accounts,
relationships: relationships, relationships: relationships,
nextPageState: link?.maxId != nil ? .hasNextPage : .none) nextPageState: link?.maxId != nil ? .hasNextPage : .none)
} catch { } } catch {}
} }
func fetchNextPage() async { func fetchNextPage() async {
guard let client, let nextPageId else { return } guard let client, let nextPageId else { return }
do { do {
@ -93,13 +94,13 @@ class AccountsListViewModel: ObservableObject {
(newAccounts, link) = try await client.getWithLink(endpoint: Statuses.rebloggedBy(id: statusId, (newAccounts, link) = try await client.getWithLink(endpoint: Statuses.rebloggedBy(id: statusId,
maxId: nextPageId)) maxId: nextPageId))
case let .favouritedBy(statusId): case let .favouritedBy(statusId):
(newAccounts, link) = try await client.getWithLink(endpoint: Statuses.favouritedBy(id: statusId, (newAccounts, link) = try await client.getWithLink(endpoint: Statuses.favouritedBy(id: statusId,
maxId: nextPageId)) maxId: nextPageId))
} }
accounts.append(contentsOf: newAccounts) accounts.append(contentsOf: newAccounts)
let newRelationships: [Relationshionship] = let newRelationships: [Relationshionship] =
try await client.get(endpoint: Accounts.relationships(ids: newAccounts.map{ $0.id })) try await client.get(endpoint: Accounts.relationships(ids: newAccounts.map { $0.id }))
relationships.append(contentsOf: newRelationships) relationships.append(contentsOf: newRelationships)
self.nextPageId = link?.maxId self.nextPageId = link?.maxId
state = .display(accounts: accounts, state = .display(accounts: accounts,

View file

@ -1,15 +1,15 @@
import SwiftUI import DesignSystem
import Models import Models
import Network import Network
import DesignSystem import SwiftUI
struct EditAccountView: View { struct EditAccountView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@StateObject private var viewModel = EditAccountViewModel() @StateObject private var viewModel = EditAccountViewModel()
public var body: some View { public var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@ -31,15 +31,15 @@ struct EditAccountView: View {
.alert("Error while saving your profile", .alert("Error while saving your profile",
isPresented: $viewModel.saveError, isPresented: $viewModel.saveError,
actions: { actions: {
Button("Ok", action: { }) Button("Ok", action: {})
}, message: { Text("Error while saving your profile, please try again.") }) }, message: { Text("Error while saving your profile, please try again.") })
.task { .task {
viewModel.client = client viewModel.client = client
await viewModel.fetchAccount() await viewModel.fetchAccount()
} }
} }
} }
private var loadingSection: some View { private var loadingSection: some View {
Section { Section {
HStack { HStack {
@ -50,7 +50,7 @@ struct EditAccountView: View {
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
@ViewBuilder @ViewBuilder
private var aboutSections: some View { private var aboutSections: some View {
Section("Display Name") { Section("Display Name") {
@ -63,7 +63,7 @@ struct EditAccountView: View {
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
private var postSettingsSection: some View { private var postSettingsSection: some View {
Section("Post settings") { Section("Post settings") {
Picker(selection: $viewModel.postPrivacy) { Picker(selection: $viewModel.postPrivacy) {
@ -80,7 +80,7 @@ struct EditAccountView: View {
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
private var accountSection: some View { private var accountSection: some View {
Section("Account settings") { Section("Account settings") {
Toggle(isOn: $viewModel.isLocked) { Toggle(isOn: $viewModel.isLocked) {
@ -95,7 +95,7 @@ struct EditAccountView: View {
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbarContent: some ToolbarContent { private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
@ -103,7 +103,7 @@ struct EditAccountView: View {
dismiss() dismiss()
} }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
Task { Task {

View file

@ -1,11 +1,11 @@
import SwiftUI
import Models import Models
import Network import Network
import SwiftUI
@MainActor @MainActor
class EditAccountViewModel: ObservableObject { class EditAccountViewModel: ObservableObject {
public var client: Client? public var client: Client?
@Published var displayName: String = "" @Published var displayName: String = ""
@Published var note: String = "" @Published var note: String = ""
@Published var postPrivacy = Models.Visibility.pub @Published var postPrivacy = Models.Visibility.pub
@ -13,13 +13,13 @@ class EditAccountViewModel: ObservableObject {
@Published var isBot: Bool = false @Published var isBot: Bool = false
@Published var isLocked: Bool = false @Published var isLocked: Bool = false
@Published var isDiscoverable: Bool = false @Published var isDiscoverable: Bool = false
@Published var isLoading: Bool = true @Published var isLoading: Bool = true
@Published var isSaving: Bool = false @Published var isSaving: Bool = false
@Published var saveError: Bool = false @Published var saveError: Bool = false
init() { } init() {}
func fetchAccount() async { func fetchAccount() async {
guard let client else { return } guard let client else { return }
do { do {
@ -34,20 +34,20 @@ class EditAccountViewModel: ObservableObject {
withAnimation { withAnimation {
isLoading = false isLoading = false
} }
} catch { } } catch {}
} }
func save() async { func save() async {
isSaving = true isSaving = true
do { do {
let response = let response =
try await client?.patch(endpoint: Accounts.updateCredentials(displayName: displayName, try await client?.patch(endpoint: Accounts.updateCredentials(displayName: displayName,
note: note, note: note,
privacy: postPrivacy, privacy: postPrivacy,
isSensitive: isSensitive, isSensitive: isSensitive,
isBot: isBot, isBot: isBot,
isLocked: isLocked, isLocked: isLocked,
isDiscoverable: isDiscoverable)) isDiscoverable: isDiscoverable))
if response?.statusCode != 200 { if response?.statusCode != 200 {
saveError = true saveError = true
} }
@ -57,5 +57,4 @@ class EditAccountViewModel: ObservableObject {
saveError = true saveError = true
} }
} }
} }

View file

@ -1,23 +1,23 @@
import Foundation import Foundation
import SwiftUI
import Models import Models
import Network import Network
import SwiftUI
@MainActor @MainActor
public class FollowButtonViewModel: ObservableObject { public class FollowButtonViewModel: ObservableObject {
var client: Client? var client: Client?
public let accountId: String public let accountId: String
public let shouldDisplayNotify: Bool public let shouldDisplayNotify: Bool
@Published private(set) public var relationship: Relationshionship @Published public private(set) var relationship: Relationshionship
@Published private(set) public var isUpdating: Bool = false @Published public private(set) var isUpdating: Bool = false
public init(accountId: String, relationship: Relationshionship, shouldDisplayNotify: Bool) { public init(accountId: String, relationship: Relationshionship, shouldDisplayNotify: Bool) {
self.accountId = accountId self.accountId = accountId
self.relationship = relationship self.relationship = relationship
self.shouldDisplayNotify = shouldDisplayNotify self.shouldDisplayNotify = shouldDisplayNotify
} }
func follow() async { func follow() async {
guard let client else { return } guard let client else { return }
isUpdating = true isUpdating = true
@ -28,7 +28,7 @@ public class FollowButtonViewModel: ObservableObject {
} }
isUpdating = false isUpdating = false
} }
func unfollow() async { func unfollow() async {
guard let client else { return } guard let client else { return }
isUpdating = true isUpdating = true
@ -39,7 +39,7 @@ public class FollowButtonViewModel: ObservableObject {
} }
isUpdating = false isUpdating = false
} }
func toggleNotify() async { func toggleNotify() async {
guard let client else { return } guard let client else { return }
do { do {
@ -53,11 +53,11 @@ public class FollowButtonViewModel: ObservableObject {
public struct FollowButton: View { public struct FollowButton: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var viewModel: FollowButtonViewModel @StateObject private var viewModel: FollowButtonViewModel
public init(viewModel: FollowButtonViewModel) { public init(viewModel: FollowButtonViewModel) {
_viewModel = StateObject(wrappedValue: viewModel) _viewModel = StateObject(wrappedValue: viewModel)
} }
public var body: some View { public var body: some View {
HStack { HStack {
Button { Button {

View file

@ -1,11 +1,11 @@
import XCTest
@testable import Account @testable import Account
import XCTest
final class AccountTests: XCTestCase { final class AccountTests: XCTestCase {
func testExample() throws { func testExample() throws {
// This is an example of a functional test case. // This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct // Use XCTAssert and related functions to verify your tests produce the correct
// results. // results.
XCTAssertEqual(Account().text, "Hello, World!") XCTAssertEqual(Account().text, "Hello, World!")
} }
} }

View file

@ -11,7 +11,8 @@ let package = Package(
products: [ products: [
.library( .library(
name: "AppAccount", name: "AppAccount",
targets: ["AppAccount"]), targets: ["AppAccount"]
),
], ],
dependencies: [ dependencies: [
.package(name: "Network", path: "../Network"), .package(name: "Network", path: "../Network"),
@ -27,6 +28,7 @@ let package = Package(
.product(name: "Models", package: "Models"), .product(name: "Models", package: "Models"),
.product(name: "Env", package: "Env"), .product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem"), .product(name: "DesignSystem", package: "DesignSystem"),
]) ]
),
] ]
) )

View file

@ -1,17 +1,17 @@
import SwiftUI import CryptoKit
import Network
import KeychainSwift import KeychainSwift
import Models import Models
import CryptoKit import Network
import SwiftUI
public struct AppAccount: Codable, Identifiable { public struct AppAccount: Codable, Identifiable {
public let server: String public let server: String
public let oauthToken: OauthToken? public let oauthToken: OauthToken?
public var id: String { public var id: String {
key key
} }
private static var keychain: KeychainSwift { private static var keychain: KeychainSwift {
let keychain = KeychainSwift() let keychain = KeychainSwift()
#if !DEBUG #if !DEBUG
@ -19,7 +19,7 @@ public struct AppAccount: Codable, Identifiable {
#endif #endif
return keychain return keychain
} }
public var key: String { public var key: String {
if let oauthToken { if let oauthToken {
return "\(server):\(oauthToken.createdAt)" return "\(server):\(oauthToken.createdAt)"
@ -27,22 +27,22 @@ public struct AppAccount: Codable, Identifiable {
return "\(server):anonymous:\(Date().timeIntervalSince1970)" return "\(server):anonymous:\(Date().timeIntervalSince1970)"
} }
} }
public init(server: String, oauthToken: OauthToken? = nil) { public init(server: String, oauthToken: OauthToken? = nil) {
self.server = server self.server = server
self.oauthToken = oauthToken self.oauthToken = oauthToken
} }
public func save() throws { public func save() throws {
let encoder = JSONEncoder() let encoder = JSONEncoder()
let data = try encoder.encode(self) let data = try encoder.encode(self)
Self.keychain.set(data, forKey: key) Self.keychain.set(data, forKey: key)
} }
public func delete() { public func delete() {
Self.keychain.delete(key) Self.keychain.delete(key)
} }
public static func retrieveAll() -> [AppAccount] { public static func retrieveAll() -> [AppAccount] {
migrateLegacyAccounts() migrateLegacyAccounts()
let keychain = Self.keychain let keychain = Self.keychain
@ -58,7 +58,7 @@ public struct AppAccount: Codable, Identifiable {
} }
return accounts return accounts
} }
public static func migrateLegacyAccounts() { public static func migrateLegacyAccounts() {
let keychain = KeychainSwift() let keychain = KeychainSwift()
let decoder = JSONDecoder() let decoder = JSONDecoder()
@ -71,7 +71,7 @@ public struct AppAccount: Codable, Identifiable {
} }
} }
} }
public static func deleteAll() { public static func deleteAll() {
let keychain = Self.keychain let keychain = Self.keychain
let keys = keychain.allKeys let keys = keychain.allKeys

View file

@ -1,17 +1,17 @@
import SwiftUI
import DesignSystem import DesignSystem
import Env
import EmojiText import EmojiText
import Env
import SwiftUI
public struct AppAccountView: View { public struct AppAccountView: View {
@EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var routeurPath: RouterPath
@EnvironmentObject var appAccounts: AppAccountsManager @EnvironmentObject var appAccounts: AppAccountsManager
@StateObject var viewModel: AppAccountViewModel @StateObject var viewModel: AppAccountViewModel
public init(viewModel: AppAccountViewModel) { public init(viewModel: AppAccountViewModel) {
_viewModel = .init(wrappedValue: viewModel) _viewModel = .init(wrappedValue: viewModel)
} }
public var body: some View { public var body: some View {
HStack { HStack {
if let account = viewModel.account { if let account = viewModel.account {
@ -43,7 +43,8 @@ public struct AppAccountView: View {
} }
.onTapGesture { .onTapGesture {
if appAccounts.currentAccount.id == viewModel.appAccount.id, if appAccounts.currentAccount.id == viewModel.appAccount.id,
let account = viewModel.account { let account = viewModel.account
{
routeurPath.navigate(to: .accountDetailWithAccount(account: account)) routeurPath.navigate(to: .accountDetailWithAccount(account: account))
} else { } else {
appAccounts.currentAccount = viewModel.appAccount appAccounts.currentAccount = viewModel.appAccount

View file

@ -1,23 +1,23 @@
import SwiftUI
import Models import Models
import Network import Network
import SwiftUI
@MainActor @MainActor
public class AppAccountViewModel: ObservableObject { public class AppAccountViewModel: ObservableObject {
let appAccount: AppAccount let appAccount: AppAccount
let client: Client let client: Client
@Published var account: Account? @Published var account: Account?
var acct: String { var acct: String {
"@\(account?.acct ?? "...")@\(appAccount.server)" "@\(account?.acct ?? "...")@\(appAccount.server)"
} }
public init(appAccount: AppAccount) { public init(appAccount: AppAccount) {
self.appAccount = appAccount self.appAccount = appAccount
self.client = .init(server: appAccount.server, oauthToken: appAccount.oauthToken) client = .init(server: appAccount.server, oauthToken: appAccount.oauthToken)
} }
func fetchAccount() async { func fetchAccount() async {
do { do {
account = try await client.get(endpoint: Accounts.verifyCredentials) account = try await client.get(endpoint: Accounts.verifyCredentials)

View file

@ -1,13 +1,13 @@
import SwiftUI
import Network
import Env import Env
import Models import Models
import Network
import SwiftUI
@MainActor @MainActor
public class AppAccountsManager: ObservableObject { public class AppAccountsManager: ObservableObject {
@AppStorage("latestCurrentAccountKey", store: UserPreferences.sharedDefault) @AppStorage("latestCurrentAccountKey", store: UserPreferences.sharedDefault)
static public var latestCurrentAccountKey: String = "" public static var latestCurrentAccountKey: String = ""
@Published public var currentAccount: AppAccount { @Published public var currentAccount: AppAccount {
didSet { didSet {
Self.latestCurrentAccountKey = currentAccount.id Self.latestCurrentAccountKey = currentAccount.id
@ -15,16 +15,17 @@ public class AppAccountsManager: ObservableObject {
oauthToken: currentAccount.oauthToken) oauthToken: currentAccount.oauthToken)
} }
} }
@Published public var availableAccounts: [AppAccount] @Published public var availableAccounts: [AppAccount]
@Published public var currentClient: Client @Published public var currentClient: Client
public var pushAccounts: [PushNotificationsService.PushAccounts] { public var pushAccounts: [PushNotificationsService.PushAccounts] {
availableAccounts.filter{ $0.oauthToken != nil} availableAccounts.filter { $0.oauthToken != nil }
.map{ .init(server: $0.server, token: $0.oauthToken!) } .map { .init(server: $0.server, token: $0.oauthToken!) }
} }
public static var shared = AppAccountsManager() public static var shared = AppAccountsManager()
internal init() { internal init() {
var defaultAccount = AppAccount(server: AppInfo.defaultServer, oauthToken: nil) var defaultAccount = AppAccount(server: AppInfo.defaultServer, oauthToken: nil)
let keychainAccounts = AppAccount.retrieveAll() let keychainAccounts = AppAccount.retrieveAll()
@ -37,15 +38,15 @@ public class AppAccountsManager: ObservableObject {
currentAccount = defaultAccount currentAccount = defaultAccount
currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken) currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken)
} }
public func add(account: AppAccount) { public func add(account: AppAccount) {
do { do {
try account.save() try account.save()
availableAccounts.append(account) availableAccounts.append(account)
currentAccount = account currentAccount = account
} catch { } } catch {}
} }
public func delete(account: AppAccount) { public func delete(account: AppAccount) {
availableAccounts.removeAll(where: { $0.id == account.id }) availableAccounts.removeAll(where: { $0.id == account.id })
account.delete() account.delete()

View file

@ -1,26 +1,27 @@
import SwiftUI
import Env
import DesignSystem import DesignSystem
import Env
import SwiftUI
public struct AppAccountsSelectorView: View { public struct AppAccountsSelectorView: View {
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var appAccounts: AppAccountsManager @EnvironmentObject private var appAccounts: AppAccountsManager
@ObservedObject var routeurPath: RouterPath @ObservedObject var routeurPath: RouterPath
@State private var accountsViewModel: [AppAccountViewModel] = [] @State private var accountsViewModel: [AppAccountViewModel] = []
private let accountCreationEnabled: Bool private let accountCreationEnabled: Bool
private let avatarSize: AvatarView.Size private let avatarSize: AvatarView.Size
public init(routeurPath: RouterPath, public init(routeurPath: RouterPath,
accountCreationEnabled: Bool = true, accountCreationEnabled: Bool = true,
avatarSize: AvatarView.Size = .badge) { avatarSize: AvatarView.Size = .badge)
{
self.routeurPath = routeurPath self.routeurPath = routeurPath
self.accountCreationEnabled = accountCreationEnabled self.accountCreationEnabled = accountCreationEnabled
self.avatarSize = avatarSize self.avatarSize = avatarSize
} }
public var body: some View { public var body: some View {
Group { Group {
if UIDevice.current.userInterfaceIdiom == .pad { if UIDevice.current.userInterfaceIdiom == .pad {
@ -43,7 +44,7 @@ public struct AppAccountsSelectorView: View {
refreshAccounts() refreshAccounts()
} }
} }
@ViewBuilder @ViewBuilder
private var labelView: some View { private var labelView: some View {
if let avatar = currentAccount.account?.avatar { if let avatar = currentAccount.account?.avatar {
@ -52,14 +53,15 @@ public struct AppAccountsSelectorView: View {
EmptyView() EmptyView()
} }
} }
@ViewBuilder @ViewBuilder
private var menuView: some View { private var menuView: some View {
ForEach(accountsViewModel, id: \.appAccount.id) { viewModel in ForEach(accountsViewModel, id: \.appAccount.id) { viewModel in
Section(viewModel.acct) { Section(viewModel.acct) {
Button { Button {
if let account = currentAccount.account, if let account = currentAccount.account,
viewModel.account?.id == account.id { viewModel.account?.id == account.id
{
routeurPath.navigate(to: .accountDetailWithAccount(account: account)) routeurPath.navigate(to: .accountDetailWithAccount(account: account))
} else { } else {
appAccounts.currentAccount = viewModel.appAccount appAccounts.currentAccount = viewModel.appAccount
@ -83,7 +85,7 @@ public struct AppAccountsSelectorView: View {
} }
} }
} }
private func refreshAccounts() { private func refreshAccounts() {
if accountsViewModel.isEmpty || appAccounts.availableAccounts.count != accountsViewModel.count { if accountsViewModel.isEmpty || appAccounts.availableAccounts.count != accountsViewModel.count {
accountsViewModel = [] accountsViewModel = []
@ -98,5 +100,4 @@ public struct AppAccountsSelectorView: View {
} }
} }
} }
} }

View file

@ -11,7 +11,8 @@ let package = Package(
products: [ products: [
.library( .library(
name: "Conversations", name: "Conversations",
targets: ["Conversations"]), targets: ["Conversations"]
),
], ],
dependencies: [ dependencies: [
.package(name: "Models", path: "../Models"), .package(name: "Models", path: "../Models"),
@ -27,7 +28,7 @@ let package = Package(
.product(name: "Network", package: "Network"), .product(name: "Network", package: "Network"),
.product(name: "Env", package: "Env"), .product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem"), .product(name: "DesignSystem", package: "DesignSystem"),
]), ]
),
] ]
) )

View file

@ -1,25 +1,25 @@
import SwiftUI
import Models
import Accounts import Accounts
import DesignSystem import DesignSystem
import Env import Env
import Models
import Network import Network
import SwiftUI
struct ConversationsListRow: View { struct ConversationsListRow: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
let conversation: Conversation let conversation: Conversation
@ObservedObject var viewModel: ConversationsListViewModel @ObservedObject var viewModel: ConversationsListViewModel
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
AvatarView(url: conversation.accounts.first!.avatar) AvatarView(url: conversation.accounts.first!.avatar)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text(conversation.accounts.map{ $0.safeDisplayName }.joined(separator: ", ")) Text(conversation.accounts.map { $0.safeDisplayName }.joined(separator: ", "))
.font(.headline) .font(.headline)
.foregroundColor(theme.labelColor) .foregroundColor(theme.labelColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
@ -52,7 +52,7 @@ struct ConversationsListRow: View {
contextMenu contextMenu
} }
} }
private var actionsView: some View { private var actionsView: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Button { Button {
@ -71,7 +71,7 @@ struct ConversationsListRow: View {
.padding(.leading, 48) .padding(.leading, 48)
.foregroundColor(.gray) .foregroundColor(.gray)
} }
@ViewBuilder @ViewBuilder
private var contextMenu: some View { private var contextMenu: some View {
Button { Button {
@ -81,7 +81,7 @@ struct ConversationsListRow: View {
} label: { } label: {
Label("Mark as read", systemImage: "eye") Label("Mark as read", systemImage: "eye")
} }
Button(role: .destructive) { Button(role: .destructive) {
Task { Task {
await viewModel.delete(conversation: conversation) await viewModel.delete(conversation: conversation)

View file

@ -1,27 +1,27 @@
import SwiftUI
import Network
import Models
import DesignSystem import DesignSystem
import Shimmer
import Env import Env
import Models
import Network
import Shimmer
import SwiftUI
public struct ConversationsListView: View { public struct ConversationsListView: View {
@EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var routeurPath: RouterPath
@EnvironmentObject private var watcher: StreamWatcher @EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@StateObject private var viewModel = ConversationsListViewModel() @StateObject private var viewModel = ConversationsListViewModel()
public init() { } public init() {}
private var conversations: [Conversation] { private var conversations: [Conversation] {
if viewModel.isLoadingFirstPage { if viewModel.isLoadingFirstPage {
return Conversation.placeholders() return Conversation.placeholders()
} }
return viewModel.conversations return viewModel.conversations
} }
public var body: some View { public var body: some View {
ScrollView { ScrollView {
LazyVStack { LazyVStack {
@ -61,7 +61,7 @@ public struct ConversationsListView: View {
.toolbar { .toolbar {
StatusEditorToolbarItem(visibility: .direct) StatusEditorToolbarItem(visibility: .direct)
} }
.onChange(of: watcher.latestEvent?.id) { id in .onChange(of: watcher.latestEvent?.id) { _ in
if let latestEvent = watcher.latestEvent { if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent) viewModel.handleEvent(event: latestEvent)
} }

View file

@ -5,13 +5,13 @@ import SwiftUI
@MainActor @MainActor
class ConversationsListViewModel: ObservableObject { class ConversationsListViewModel: ObservableObject {
var client: Client? var client: Client?
@Published var isLoadingFirstPage: Bool = true @Published var isLoadingFirstPage: Bool = true
@Published var conversations: [Conversation] = [] @Published var conversations: [Conversation] = []
@Published var isError: Bool = false @Published var isError: Bool = false
public init() { } public init() {}
func fetchConversations() async { func fetchConversations() async {
guard let client else { return } guard let client else { return }
if conversations.isEmpty { if conversations.isEmpty {
@ -25,18 +25,18 @@ class ConversationsListViewModel: ObservableObject {
isLoadingFirstPage = false isLoadingFirstPage = false
} }
} }
func markAsRead(conversation: Conversation) async { func markAsRead(conversation: Conversation) async {
guard let client else { return } guard let client else { return }
_ = try? await client.post(endpoint: Conversations.read(id: conversation.id)) _ = try? await client.post(endpoint: Conversations.read(id: conversation.id))
} }
func delete(conversation: Conversation) async { func delete(conversation: Conversation) async {
guard let client else { return } guard let client else { return }
_ = try? await client.delete(endpoint: Conversations.delete(id: conversation.id)) _ = try? await client.delete(endpoint: Conversations.delete(id: conversation.id))
await fetchConversations() await fetchConversations()
} }
func handleEvent(event: any StreamEvent) { func handleEvent(event: any StreamEvent) {
if let event = event as? StreamEventConversation { if let event = event as? StreamEventConversation {
if let index = conversations.firstIndex(where: { $0.id == event.conversation.id }) { if let index = conversations.firstIndex(where: { $0.id == event.conversation.id }) {

View file

@ -11,14 +11,15 @@ let package = Package(
products: [ products: [
.library( .library(
name: "DesignSystem", name: "DesignSystem",
targets: ["DesignSystem"]), targets: ["DesignSystem"]
),
], ],
dependencies: [ dependencies: [
.package(name: "Models", path: "../Models"), .package(name: "Models", path: "../Models"),
.package(name: "Env", path: "../Env"), .package(name: "Env", path: "../Env"),
.package(url: "https://github.com/markiv/SwiftUI-Shimmer", exact: "1.1.0"), .package(url: "https://github.com/markiv/SwiftUI-Shimmer", exact: "1.1.0"),
.package(url: "https://github.com/kean/Nuke", from: "11.5.0"), .package(url: "https://github.com/kean/Nuke", from: "11.5.0"),
.package(url: "https://github.com/divadretlaw/EmojiText", from: "1.1.0") .package(url: "https://github.com/divadretlaw/EmojiText", from: "1.1.0"),
], ],
targets: [ targets: [
.target( .target(
@ -29,8 +30,8 @@ let package = Package(
.product(name: "Shimmer", package: "SwiftUI-Shimmer"), .product(name: "Shimmer", package: "SwiftUI-Shimmer"),
.product(name: "NukeUI", package: "Nuke"), .product(name: "NukeUI", package: "Nuke"),
.product(name: "Nuke", package: "Nuke"), .product(name: "Nuke", package: "Nuke"),
.product(name: "EmojiText", package: "EmojiText") .product(name: "EmojiText", package: "EmojiText"),
]), ]
),
] ]
) )

View file

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

View file

@ -24,68 +24,65 @@ public enum ColorSetName: String {
public struct IceCubeDark: ColorSet { public struct IceCubeDark: ColorSet {
public var name: ColorSetName = .iceCubeDark public var name: ColorSetName = .iceCubeDark
public var scheme: ColorScheme = .dark public var scheme: ColorScheme = .dark
public var tintColor: Color = Color(red: 187/255, green: 59/255, blue: 226/255) public var tintColor: Color = .init(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
public var primaryBackgroundColor: Color = Color(red: 16/255, green: 21/255, blue: 35/255) public var primaryBackgroundColor: Color = .init(red: 16 / 255, green: 21 / 255, blue: 35 / 255)
public var secondaryBackgroundColor: Color = Color(red: 30/255, green: 35/255, blue: 62/255) public var secondaryBackgroundColor: Color = .init(red: 30 / 255, green: 35 / 255, blue: 62 / 255)
public var labelColor: Color = .white public var labelColor: Color = .white
public init() {} public init() {}
} }
public struct IceCubeLight: ColorSet { public struct IceCubeLight: ColorSet {
public var name: ColorSetName = .iceCubeLight public var name: ColorSetName = .iceCubeLight
public var scheme: ColorScheme = .light public var scheme: ColorScheme = .light
public var tintColor: Color = Color(red: 187/255, green: 59/255, blue: 226/255) public var tintColor: Color = .init(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
public var primaryBackgroundColor: Color = .white public var primaryBackgroundColor: Color = .white
public var secondaryBackgroundColor: Color = Color(hex:0xF0F1F2) public var secondaryBackgroundColor: Color = .init(hex: 0xF0F1F2)
public var labelColor: Color = .black public var labelColor: Color = .black
public init() {} public init() {}
} }
public struct DesertDark: ColorSet { public struct DesertDark: ColorSet {
public var name: ColorSetName = .desertDark public var name: ColorSetName = .desertDark
public var scheme: ColorScheme = .dark public var scheme: ColorScheme = .dark
public var tintColor: Color = Color(hex: 0xdf915e) public var tintColor: Color = .init(hex: 0xDF915E)
public var primaryBackgroundColor: Color = Color(hex: 0x433744) public var primaryBackgroundColor: Color = .init(hex: 0x433744)
public var secondaryBackgroundColor: Color = Color(hex:0x654868) public var secondaryBackgroundColor: Color = .init(hex: 0x654868)
public var labelColor: Color = .white public var labelColor: Color = .white
public init() {} public init() {}
} }
public struct DesertLight: ColorSet { public struct DesertLight: ColorSet {
public var name: ColorSetName = .desertLight public var name: ColorSetName = .desertLight
public var scheme: ColorScheme = .light public var scheme: ColorScheme = .light
public var tintColor: Color = Color(hex: 0xdf915e) public var tintColor: Color = .init(hex: 0xDF915E)
public var primaryBackgroundColor: Color = Color(hex: 0xfcf2eb) public var primaryBackgroundColor: Color = .init(hex: 0xFCF2EB)
public var secondaryBackgroundColor: Color = Color(hex:0xeeede7) public var secondaryBackgroundColor: Color = .init(hex: 0xEEEDE7)
public var labelColor: Color = .black public var labelColor: Color = .black
public init() {} public init() {}
} }
public struct NemesisDark: ColorSet { public struct NemesisDark: ColorSet {
public var name: ColorSetName = .nemesisDark public var name: ColorSetName = .nemesisDark
public var scheme: ColorScheme = .dark public var scheme: ColorScheme = .dark
public var tintColor: Color = Color(hex: 0x17a2f2) public var tintColor: Color = .init(hex: 0x17A2F2)
public var primaryBackgroundColor: Color = Color(hex: 0x000000) public var primaryBackgroundColor: Color = .init(hex: 0x000000)
public var secondaryBackgroundColor: Color = Color(hex:0x151e2b) public var secondaryBackgroundColor: Color = .init(hex: 0x151E2B)
public var labelColor: Color = .white public var labelColor: Color = .white
public init() {} public init() {}
} }
public struct NemesisLight: ColorSet { public struct NemesisLight: ColorSet {
public var name: ColorSetName = .nemesisLight public var name: ColorSetName = .nemesisLight
public var scheme: ColorScheme = .light public var scheme: ColorScheme = .light
public var tintColor: Color = Color(hex: 0x17a2f2) public var tintColor: Color = .init(hex: 0x17A2F2)
public var primaryBackgroundColor: Color = Color(hex: 0xffffff) public var primaryBackgroundColor: Color = .init(hex: 0xFFFFFF)
public var secondaryBackgroundColor: Color = Color(hex:0xe8ecef) public var secondaryBackgroundColor: Color = .init(hex: 0xE8ECEF)
public var labelColor: Color = .black public var labelColor: Color = .black
public init() {} public init() {}
} }

View file

@ -1,8 +1,8 @@
import Foundation import Foundation
extension CGFloat { public extension CGFloat {
public static let layoutPadding: CGFloat = 20 static let layoutPadding: CGFloat = 20
public static let dividerPadding: CGFloat = 2 static let dividerPadding: CGFloat = 2
public static let statusColumnsSpacing: CGFloat = 8 static let statusColumnsSpacing: CGFloat = 8
public static let maxColumnWidth: CGFloat = 650 static let maxColumnWidth: CGFloat = 650
} }

View file

@ -1,28 +1,28 @@
import SwiftUI import SwiftUI
extension Color { public extension Color {
public static var brand: Color { static var brand: Color {
Color(red: 187/255, green: 59/255, blue: 226/255) Color(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
} }
public static var primaryBackground: Color { static var primaryBackground: Color {
Color(red: 16/255, green: 21/255, blue: 35/255) Color(red: 16 / 255, green: 21 / 255, blue: 35 / 255)
} }
public static var secondaryBackground: Color { static var secondaryBackground: Color {
Color(red: 30/255, green: 35/255, blue: 62/255) Color(red: 30 / 255, green: 35 / 255, blue: 62 / 255)
} }
public static var label: Color { static var label: Color {
Color("label", bundle: .module) Color("label", bundle: .module)
} }
} }
extension Color: RawRepresentable { extension Color: RawRepresentable {
public init?(rawValue: Int) { public init?(rawValue: Int) {
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF
let blue = Double(rawValue & 0x0000FF) / 0xFF let blue = Double(rawValue & 0x0000FF) / 0xFF
self = Color(red: red, green: green, blue: blue) self = Color(red: red, green: green, blue: blue)
} }
@ -42,11 +42,10 @@ extension Color: RawRepresentable {
} }
extension Color { extension Color {
init(hex: Int, opacity: Double = 1.0) { init(hex: Int, opacity: Double = 1.0) {
let red = Double((hex & 0xff0000) >> 16) / 255.0 let red = Double((hex & 0xFF0000) >> 16) / 255.0
let green = Double((hex & 0xff00) >> 8) / 255.0 let green = Double((hex & 0xFF00) >> 8) / 255.0
let blue = Double((hex & 0xff) >> 0) / 255.0 let blue = Double((hex & 0xFF) >> 0) / 255.0
self.init(.sRGB, red: red, green: green, blue: blue, opacity: opacity) self.init(.sRGB, red: red, green: green, blue: blue, opacity: opacity)
} }
} }

View file

@ -7,10 +7,10 @@ public class Theme: ObservableObject {
case avatarPosition, avatarShape, statusActionsDisplay, statusDisplayStyle case avatarPosition, avatarShape, statusActionsDisplay, statusDisplayStyle
case selectedSet, selectedScheme case selectedSet, selectedScheme
} }
public enum AvatarPosition: String, CaseIterable { public enum AvatarPosition: String, CaseIterable {
case leading, top case leading, top
public var description: LocalizedStringKey { public var description: LocalizedStringKey {
switch self { switch self {
case .leading: case .leading:
@ -33,7 +33,7 @@ public class Theme: ObservableObject {
} }
} }
} }
public enum StatusActionsDisplay: String, CaseIterable { public enum StatusActionsDisplay: String, CaseIterable {
case full, discret, none case full, discret, none
@ -48,7 +48,7 @@ public class Theme: ObservableObject {
} }
} }
} }
public enum StatusDisplayStyle: String, CaseIterable { public enum StatusDisplayStyle: String, CaseIterable {
case large, compact case large, compact
@ -61,7 +61,7 @@ public class Theme: ObservableObject {
} }
} }
} }
@AppStorage("is_previously_set") private var isSet: Bool = false @AppStorage("is_previously_set") private var isSet: Bool = false
@AppStorage(ThemeKey.selectedScheme.rawValue) public var selectedScheme: ColorScheme = .dark @AppStorage(ThemeKey.selectedScheme.rawValue) public var selectedScheme: ColorScheme = .dark
@AppStorage(ThemeKey.tint.rawValue) public var tintColor: Color = .black @AppStorage(ThemeKey.tint.rawValue) public var tintColor: Color = .black
@ -79,19 +79,19 @@ public class Theme: ObservableObject {
@Published public var selectedSet: ColorSetName = .iceCubeDark @Published public var selectedSet: ColorSetName = .iceCubeDark
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
public static let shared = Theme() public static let shared = Theme()
private init() { private init() {
selectedSet = storedSet selectedSet = storedSet
// If theme is never set before set the default store. This should only execute once after install. // If theme is never set before set the default store. This should only execute once after install.
if !isSet { if !isSet {
setColor(withName: .iceCubeDark) setColor(withName: .iceCubeDark)
isSet = true isSet = true
} }
avatarPosition = AvatarPosition(rawValue: rawAvatarPosition) ?? .top avatarPosition = AvatarPosition(rawValue: rawAvatarPosition) ?? .top
avatarShape = AvatarShape(rawValue: rawAvatarShape) ?? .rounded avatarShape = AvatarShape(rawValue: rawAvatarShape) ?? .rounded
@ -110,7 +110,7 @@ public class Theme: ObservableObject {
self?.rawAvatarShape = shape self?.rawAvatarShape = shape
} }
.store(in: &cancellables) .store(in: &cancellables)
// Workaround, since @AppStorage can't be directly observed // Workaround, since @AppStorage can't be directly observed
$selectedSet $selectedSet
.dropFirst() .dropFirst()
@ -119,7 +119,7 @@ public class Theme: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
public static var allColorSet: [ColorSet] { public static var allColorSet: [ColorSet] {
[ [
IceCubeDark(), IceCubeDark(),
@ -127,17 +127,17 @@ public class Theme: ObservableObject {
DesertDark(), DesertDark(),
DesertLight(), DesertLight(),
NemesisDark(), NemesisDark(),
NemesisLight() NemesisLight(),
] ]
} }
public func setColor(withName name: ColorSetName) { public func setColor(withName name: ColorSetName) {
let colorSet = Theme.allColorSet.filter { $0.name == name }.first ?? IceCubeDark() let colorSet = Theme.allColorSet.filter { $0.name == name }.first ?? IceCubeDark()
self.selectedScheme = colorSet.scheme selectedScheme = colorSet.scheme
self.tintColor = colorSet.tintColor tintColor = colorSet.tintColor
self.primaryBackgroundColor = colorSet.primaryBackgroundColor primaryBackgroundColor = colorSet.primaryBackgroundColor
self.secondaryBackgroundColor = colorSet.secondaryBackgroundColor secondaryBackgroundColor = colorSet.secondaryBackgroundColor
self.labelColor = colorSet.labelColor labelColor = colorSet.labelColor
self.storedSet = name storedSet = name
} }
} }

View file

@ -1,68 +1,68 @@
import SwiftUI import SwiftUI
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#endif #endif
public extension View { public extension View {
func applyTheme(_ theme: Theme) -> some View { func applyTheme(_ theme: Theme) -> some View {
modifier(ThemeApplier(theme: theme)) modifier(ThemeApplier(theme: theme))
} }
} }
struct ThemeApplier: ViewModifier { struct ThemeApplier: ViewModifier {
@ObservedObject var theme: Theme @ObservedObject var theme: Theme
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.tint(theme.tintColor) .tint(theme.tintColor)
.preferredColorScheme(theme.selectedScheme == ColorScheme.dark ? .dark : .light) .preferredColorScheme(theme.selectedScheme == ColorScheme.dark ? .dark : .light)
#if canImport(UIKit)
.onAppear {
setWindowTint(theme.tintColor)
setWindowUserInterfaceStyle(theme.selectedScheme)
setBarsColor(theme.primaryBackgroundColor)
}
.onChange(of: theme.tintColor) { newValue in
setWindowTint(newValue)
}
.onChange(of: theme.selectedScheme) { newValue in
setWindowUserInterfaceStyle(newValue)
}
.onChange(of: theme.primaryBackgroundColor) { newValue in
setBarsColor(newValue)
}
#endif
}
#if canImport(UIKit) #if canImport(UIKit)
private func setWindowUserInterfaceStyle(_ colorScheme: ColorScheme) { .onAppear {
allWindows() setWindowTint(theme.tintColor)
.forEach { setWindowUserInterfaceStyle(theme.selectedScheme)
switch colorScheme { setBarsColor(theme.primaryBackgroundColor)
case .dark: }
$0.overrideUserInterfaceStyle = .dark .onChange(of: theme.tintColor) { newValue in
case .light: setWindowTint(newValue)
$0.overrideUserInterfaceStyle = .light }
} .onChange(of: theme.selectedScheme) { newValue in
} setWindowUserInterfaceStyle(newValue)
} }
.onChange(of: theme.primaryBackgroundColor) { newValue in
private func setWindowTint(_ color: Color) { setBarsColor(newValue)
allWindows() }
.forEach {
$0.tintColor = UIColor(color)
}
}
private func setBarsColor(_ color: Color) {
UINavigationBar.appearance().isTranslucent = true
UINavigationBar.appearance().barTintColor = UIColor(color)
}
private func allWindows() -> [UIWindow] {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
}
#endif #endif
}
#if canImport(UIKit)
private func setWindowUserInterfaceStyle(_ colorScheme: ColorScheme) {
allWindows()
.forEach {
switch colorScheme {
case .dark:
$0.overrideUserInterfaceStyle = .dark
case .light:
$0.overrideUserInterfaceStyle = .light
}
}
}
private func setWindowTint(_ color: Color) {
allWindows()
.forEach {
$0.tintColor = UIColor(color)
}
}
private func setBarsColor(_ color: Color) {
UINavigationBar.appearance().isTranslucent = true
UINavigationBar.appearance().barTintColor = UIColor(color)
}
private func allWindows() -> [UIWindow] {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
}
#endif
} }

View file

@ -1,6 +1,6 @@
import SwiftUI
import Shimmer
import NukeUI import NukeUI
import Shimmer
import SwiftUI
public struct AvatarView: View { public struct AvatarView: View {
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@ -8,7 +8,7 @@ public struct AvatarView: View {
public enum Size { public enum Size {
case account, status, embed, badge, boost case account, status, embed, badge, boost
public var size: CGSize { public var size: CGSize {
switch self { switch self {
case .account: case .account:
@ -23,7 +23,7 @@ public struct AvatarView: View {
return .init(width: 12, height: 12) return .init(width: 12, height: 12)
} }
} }
var cornerRadius: CGFloat { var cornerRadius: CGFloat {
switch self { switch self {
case .badge, .boost: case .badge, .boost:
@ -33,36 +33,36 @@ public struct AvatarView: View {
} }
} }
} }
public let url: URL public let url: URL
public let size: Size public let size: Size
public init(url: URL, size: Size = .status) { public init(url: URL, size: Size = .status) {
self.url = url self.url = url
self.size = size self.size = size
} }
public var body: some View { public var body: some View {
Group { Group {
if reasons == .placeholder { if reasons == .placeholder {
RoundedRectangle(cornerRadius: size.cornerRadius) RoundedRectangle(cornerRadius: size.cornerRadius)
.fill(.gray) .fill(.gray)
.frame(width: size.size.width, height: size.size.height) .frame(width: size.size.width, height: size.size.height)
} else { } else {
LazyImage(url: url) { state in LazyImage(url: url) { state in
if let image = state.image { if let image = state.image {
image image
.resizingMode(.aspectFit) .resizingMode(.aspectFit)
} else if state.isLoading { } else if state.isLoading {
placeholderView placeholderView
.shimmering() .shimmering()
} else { } else {
placeholderView placeholderView
}
} }
.processors([.resize(size: size.size), .roundedCorners(radius: size.cornerRadius)])
.frame(width: size.size.width, height: size.size.height)
} }
.processors([.resize(size: size.size), .roundedCorners(radius: size.cornerRadius)])
.frame(width: size.size.width, height: size.size.height)
}
} }
.clipShape(clipShape) .clipShape(clipShape)
.overlay( .overlay(
@ -78,7 +78,7 @@ public struct AvatarView: View {
return AnyShape(RoundedRectangle(cornerRadius: size.cornerRadius)) return AnyShape(RoundedRectangle(cornerRadius: size.cornerRadius))
} }
} }
@ViewBuilder @ViewBuilder
private var placeholderView: some View { private var placeholderView: some View {
if size == .badge { if size == .badge {

View file

@ -1,20 +1,20 @@
import Foundation
import EmojiText import EmojiText
import Models import Foundation
import HTML2Markdown import HTML2Markdown
import Models
import SwiftUI import SwiftUI
public struct EmojiTextApp: View { public struct EmojiTextApp: View {
private let markdown: String private let markdown: String
private let emojis: [any CustomEmoji] private let emojis: [any CustomEmoji]
private let append: (() -> Text)? private let append: (() -> Text)?
public init(_ markdown: HTMLString, emojis: [Emoji], append: (() -> Text)? = nil) { public init(_ markdown: HTMLString, emojis: [Emoji], append: (() -> Text)? = nil) {
self.markdown = markdown self.markdown = markdown
self.emojis = emojis.map { RemoteEmoji(shortcode: $0.shortcode, url: $0.url) } self.emojis = emojis.map { RemoteEmoji(shortcode: $0.shortcode, url: $0.url) }
self.append = append self.append = append
} }
public var body: some View { public var body: some View {
if let append { if let append {
EmojiText(markdown: markdown, emojis: emojis) EmojiText(markdown: markdown, emojis: emojis)

View file

@ -4,13 +4,13 @@ public struct EmptyView: View {
public let iconName: String public let iconName: String
public let title: String public let title: String
public let message: String public let message: String
public init(iconName: String, title: String, message: String) { public init(iconName: String, title: String, message: String) {
self.iconName = iconName self.iconName = iconName
self.title = title self.title = title
self.message = message self.message = message
} }
public var body: some View { public var body: some View {
VStack { VStack {
Image(systemName: iconName) Image(systemName: iconName)

View file

@ -4,15 +4,15 @@ public struct ErrorView: View {
public let title: String public let title: String
public let message: String public let message: String
public let buttonTitle: String public let buttonTitle: String
public let onButtonPress: (() -> Void) public let onButtonPress: () -> Void
public init(title: String, message: String, buttonTitle: String, onButtonPress: @escaping (() -> Void)) { public init(title: String, message: String, buttonTitle: String, onButtonPress: @escaping (() -> Void)) {
self.title = title self.title = title
self.message = message self.message = message
self.buttonTitle = buttonTitle self.buttonTitle = buttonTitle
self.onButtonPress = onButtonPress self.onButtonPress = onButtonPress
} }
public var body: some View { public var body: some View {
VStack { VStack {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")

View file

@ -15,7 +15,7 @@ public struct ScrollViewOffsetReader<Content: View>: View {
self.onOffsetChange = onOffsetChange self.onOffsetChange = onOffsetChange
self.content = content self.content = content
} }
public var body: some View { public var body: some View {
ScrollView { ScrollView {
offsetReader offsetReader
@ -40,5 +40,5 @@ public struct ScrollViewOffsetReader<Content: View>: View {
private struct OffsetPreferenceKey: PreferenceKey { private struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {} static func reduce(value _: inout CGFloat, nextValue _: () -> CGFloat) {}
} }

View file

@ -1,10 +1,10 @@
import SwiftUI
import Env import Env
import Models import Models
import SwiftUI
@MainActor @MainActor
extension View { public extension View {
public func statusEditorToolbarItem(routeurPath: RouterPath, visibility: Models.Visibility) -> some ToolbarContent { func statusEditorToolbarItem(routeurPath: RouterPath, visibility: Models.Visibility) -> some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
routeurPath.presentedSheet = .newStatusEditor(visibility: visibility) routeurPath.presentedSheet = .newStatusEditor(visibility: visibility)
@ -18,11 +18,11 @@ extension View {
public struct StatusEditorToolbarItem: ToolbarContent { public struct StatusEditorToolbarItem: ToolbarContent {
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
let visibility: Models.Visibility let visibility: Models.Visibility
public init(visibility: Models.Visibility) { public init(visibility: Models.Visibility) {
self.visibility = visibility self.visibility = visibility
} }
public var body: some ToolbarContent { public var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {

View file

@ -1,16 +1,16 @@
import Env
import Models import Models
import SwiftUI import SwiftUI
import Env
public struct TagRowView: View { public struct TagRowView: View {
@EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var routeurPath: RouterPath
let tag: Tag let tag: Tag
public init(tag: Tag) { public init(tag: Tag) {
self.tag = tag self.tag = tag
} }
public var body: some View { public var body: some View {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {

View file

@ -1,24 +1,24 @@
import SwiftUI
import Combine import Combine
import SwiftUI
public struct ThemePreviewView: View { public struct ThemePreviewView: View {
private let gutterSpace: Double = 8 private let gutterSpace: Double = 8
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
public init() {} public init() {}
public var body: some View { public var body: some View {
ScrollView { ScrollView {
HStack (spacing: gutterSpace) { HStack(spacing: gutterSpace) {
ThemeBoxView(color: IceCubeDark()) ThemeBoxView(color: IceCubeDark())
ThemeBoxView(color: IceCubeLight()) ThemeBoxView(color: IceCubeLight())
} }
HStack (spacing: gutterSpace) { HStack(spacing: gutterSpace) {
ThemeBoxView(color: DesertDark()) ThemeBoxView(color: DesertDark())
ThemeBoxView(color: DesertLight()) ThemeBoxView(color: DesertLight())
} }
HStack (spacing: gutterSpace) { HStack(spacing: gutterSpace) {
ThemeBoxView(color: NemesisDark()) ThemeBoxView(color: NemesisDark())
ThemeBoxView(color: NemesisLight()) ThemeBoxView(color: NemesisLight())
} }
@ -31,13 +31,12 @@ public struct ThemePreviewView: View {
} }
struct ThemeBoxView: View { struct ThemeBoxView: View {
@EnvironmentObject var theme: Theme @EnvironmentObject var theme: Theme
private let gutterSpace = 8.0 private let gutterSpace = 8.0
@State private var isSelected = false @State private var isSelected = false
var color: ColorSet var color: ColorSet
var body: some View { var body: some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
Rectangle() Rectangle()
@ -45,19 +44,19 @@ struct ThemeBoxView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.cornerRadius(4) .cornerRadius(4)
.shadow(radius: 2, x: 2, y: 4) .shadow(radius: 2, x: 2, y: 4)
VStack (spacing: gutterSpace) { VStack(spacing: gutterSpace) {
Text(color.name.rawValue) Text(color.name.rawValue)
.foregroundColor(color.tintColor) .foregroundColor(color.tintColor)
.font(.system(size: 20)) .font(.system(size: 20))
.fontWeight(.bold) .fontWeight(.bold)
Text("Toots preview") Text("Toots preview")
.foregroundColor(color.labelColor) .foregroundColor(color.labelColor)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background(color.primaryBackgroundColor) .background(color.primaryBackgroundColor)
Text("#icecube, #techhub") Text("#icecube, #techhub")
.foregroundColor(color.tintColor) .foregroundColor(color.tintColor)
if isSelected { if isSelected {
@ -95,4 +94,3 @@ struct ThemeBoxView: View {
} }
} }
} }

View file

@ -11,11 +11,12 @@ let package = Package(
products: [ products: [
.library( .library(
name: "Env", name: "Env",
targets: ["Env"]), targets: ["Env"]
),
], ],
dependencies: [ dependencies: [
.package(name: "Models", path: "../Models"), .package(name: "Models", path: "../Models"),
.package(name: "Network", path: "../Network") .package(name: "Network", path: "../Network"),
], ],
targets: [ targets: [
.target( .target(
@ -23,6 +24,7 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "Models", package: "Models"), .product(name: "Models", package: "Models"),
.product(name: "Network", package: "Network"), .product(name: "Network", package: "Network"),
]), ]
),
] ]
) )

View file

@ -7,21 +7,21 @@ public class CurrentAccount: ObservableObject {
@Published public private(set) var account: Account? @Published public private(set) var account: Account?
@Published public private(set) var lists: [List] = [] @Published public private(set) var lists: [List] = []
@Published public private(set) var tags: [Tag] = [] @Published public private(set) var tags: [Tag] = []
private var client: Client? private var client: Client?
static public let shared = CurrentAccount() public static let shared = CurrentAccount()
private init() { } private init() {}
public func setClient(client: Client) { public func setClient(client: Client) {
self.client = client self.client = client
Task(priority: .userInitiated) { Task(priority: .userInitiated) {
await fetchUserData() await fetchUserData()
} }
} }
private func fetchUserData() async { private func fetchUserData() async {
await withTaskGroup(of: Void.self) { group in await withTaskGroup(of: Void.self) { group in
group.addTask { await self.fetchConnections() } group.addTask { await self.fetchConnections() }
@ -30,15 +30,15 @@ public class CurrentAccount: ObservableObject {
group.addTask { await self.fetchFollowedTags() } group.addTask { await self.fetchFollowedTags() }
} }
} }
public func fetchConnections() async { public func fetchConnections() async {
guard let client = client else { return } guard let client = client else { return }
do { do {
let connections: [String] = try await client.get(endpoint: Instances.peers) let connections: [String] = try await client.get(endpoint: Instances.peers)
client.addConnections(connections) client.addConnections(connections)
} catch { } } catch {}
} }
public func fetchCurrentAccount() async { public func fetchCurrentAccount() async {
guard let client = client, client.isAuth else { guard let client = client, client.isAuth else {
account = nil account = nil
@ -46,7 +46,7 @@ public class CurrentAccount: ObservableObject {
} }
account = try? await client.get(endpoint: Accounts.verifyCredentials) account = try? await client.get(endpoint: Accounts.verifyCredentials)
} }
public func fetchLists() async { public func fetchLists() async {
guard let client, client.isAuth else { return } guard let client, client.isAuth else { return }
do { do {
@ -55,7 +55,7 @@ public class CurrentAccount: ObservableObject {
lists = [] lists = []
} }
} }
public func fetchFollowedTags() async { public func fetchFollowedTags() async {
guard let client, client.isAuth else { return } guard let client, client.isAuth else { return }
do { do {
@ -64,15 +64,15 @@ public class CurrentAccount: ObservableObject {
tags = [] tags = []
} }
} }
public func createList(title: String) async { public func createList(title: String) async {
guard let client else { return } guard let client else { return }
do { do {
let list: Models.List = try await client.post(endpoint: Lists.createList(title: title)) let list: Models.List = try await client.post(endpoint: Lists.createList(title: title))
lists.append(list) lists.append(list)
} catch { } } catch {}
} }
public func deleteList(list: Models.List) async { public func deleteList(list: Models.List) async {
guard let client else { return } guard let client else { return }
lists.removeAll(where: { $0.id == list.id }) lists.removeAll(where: { $0.id == list.id })
@ -81,7 +81,7 @@ public class CurrentAccount: ObservableObject {
lists.append(list) lists.append(list)
} }
} }
public func followTag(id: String) async -> Tag? { public func followTag(id: String) async -> Tag? {
guard let client else { return nil } guard let client else { return nil }
do { do {
@ -92,12 +92,12 @@ public class CurrentAccount: ObservableObject {
return nil return nil
} }
} }
public func unfollowTag(id: String) async -> Tag? { public func unfollowTag(id: String) async -> Tag? {
guard let client else { return nil } guard let client else { return nil }
do { do {
let tag: Tag = try await client.post(endpoint: Tags.unfollow(id: id)) let tag: Tag = try await client.post(endpoint: Tags.unfollow(id: id))
tags.removeAll{ $0.id == tag.id } tags.removeAll { $0.id == tag.id }
return tag return tag
} catch { } catch {
return nil return nil

View file

@ -5,20 +5,20 @@ import Network
@MainActor @MainActor
public class CurrentInstance: ObservableObject { public class CurrentInstance: ObservableObject {
@Published public private(set) var instance: Instance? @Published public private(set) var instance: Instance?
private var client: Client? private var client: Client?
static public let shared = CurrentInstance() public static let shared = CurrentInstance()
private init() { } private init() {}
public func setClient(client: Client) { public func setClient(client: Client) {
self.client = client self.client = client
Task { Task {
await fetchCurrentInstance() await fetchCurrentInstance()
} }
} }
public func fetchCurrentInstance() async { public func fetchCurrentInstance() async {
guard let client = client else { return } guard let client = client else { return }
Task { Task {

View file

@ -9,7 +9,7 @@ extension Array: RawRepresentable where Element: Codable {
} }
self = result self = result
} }
public var rawValue: String { public var rawValue: String {
guard let data = try? JSONEncoder().encode(self), guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8) let result = String(data: data, encoding: .utf8)

View file

@ -1,6 +1,6 @@
import Foundation import Foundation
public enum PreferredBrowser: Int, CaseIterable { public enum PreferredBrowser: Int, CaseIterable {
case inAppSafari case inAppSafari
case safari case safari
} }

View file

@ -1,10 +1,10 @@
import Foundation
import UserNotifications
import SwiftUI
import KeychainSwift
import CryptoKit import CryptoKit
import Foundation
import KeychainSwift
import Models import Models
import Network import Network
import SwiftUI
import UserNotifications
@MainActor @MainActor
public class PushNotificationsService: ObservableObject { public class PushNotificationsService: ObservableObject {
@ -13,21 +13,21 @@ public class PushNotificationsService: ObservableObject {
static let keychainAuthKey = "notifications_auth_key" static let keychainAuthKey = "notifications_auth_key"
static let keychainPrivateKey = "notifications_private_key" static let keychainPrivateKey = "notifications_private_key"
} }
public struct PushAccounts { public struct PushAccounts {
public let server: String public let server: String
public let token: OauthToken public let token: OauthToken
public init(server: String, token: OauthToken) { public init(server: String, token: OauthToken) {
self.server = server self.server = server
self.token = token self.token = token
} }
} }
public static let shared = PushNotificationsService() public static let shared = PushNotificationsService()
@Published public var pushToken: Data? @Published public var pushToken: Data?
@AppStorage("user_push_is_on") public var isUserPushEnabled: Bool = true @AppStorage("user_push_is_on") public var isUserPushEnabled: Bool = true
@Published public var isPushEnabled: Bool = false { @Published public var isPushEnabled: Bool = false {
didSet { didSet {
@ -36,15 +36,16 @@ public class PushNotificationsService: ObservableObject {
} }
} }
} }
@Published public var isFollowNotificationEnabled: Bool = true @Published public var isFollowNotificationEnabled: Bool = true
@Published public var isFavoriteNotificationEnabled: Bool = true @Published public var isFavoriteNotificationEnabled: Bool = true
@Published public var isReblogNotificationEnabled: Bool = true @Published public var isReblogNotificationEnabled: Bool = true
@Published public var isMentionNotificationEnabled: Bool = true @Published public var isMentionNotificationEnabled: Bool = true
@Published public var isPollNotificationEnabled: Bool = true @Published public var isPollNotificationEnabled: Bool = true
@Published public var isNewPostsNotificationEnabled: Bool = true @Published public var isNewPostsNotificationEnabled: Bool = true
private var subscriptions: [PushSubscription] = [] private var subscriptions: [PushSubscription] = []
private var keychain: KeychainSwift { private var keychain: KeychainSwift {
let keychain = KeychainSwift() let keychain = KeychainSwift()
#if !DEBUG #if !DEBUG
@ -52,15 +53,15 @@ public class PushNotificationsService: ObservableObject {
#endif #endif
return keychain return keychain
} }
public func requestPushNotifications() { public func requestPushNotifications() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { (_, _) in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in
DispatchQueue.main.async { DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications() UIApplication.shared.registerForRemoteNotifications()
} }
} }
} }
public func fetchSubscriptions(accounts: [PushAccounts]) async { public func fetchSubscriptions(accounts: [PushAccounts]) async {
subscriptions = [] subscriptions = []
for account in accounts { for account in accounts {
@ -68,11 +69,11 @@ public class PushNotificationsService: ObservableObject {
do { do {
let sub: PushSubscription = try await client.get(endpoint: Push.subscription) let sub: PushSubscription = try await client.get(endpoint: Push.subscription)
subscriptions.append(sub) subscriptions.append(sub)
} catch { } } catch {}
} }
refreshSubscriptionsUI() refreshSubscriptionsUI()
} }
public func updateSubscriptions(accounts: [PushAccounts]) async { public func updateSubscriptions(accounts: [PushAccounts]) async {
subscriptions = [] subscriptions = []
let key = notificationsPrivateKeyAsKey.publicKey.x963Representation let key = notificationsPrivateKeyAsKey.publicKey.x963Representation
@ -80,15 +81,15 @@ public class PushNotificationsService: ObservableObject {
guard let pushToken = pushToken, isUserPushEnabled else { return } guard let pushToken = pushToken, isUserPushEnabled else { return }
for account in accounts { for account in accounts {
let client = Client(server: account.server, oauthToken: account.token) let client = Client(server: account.server, oauthToken: account.token)
do { do {
var listenerURL = Constants.endpoint var listenerURL = Constants.endpoint
listenerURL += "/push/" listenerURL += "/push/"
listenerURL += pushToken.hexString listenerURL += pushToken.hexString
listenerURL += "/\(account.server)" listenerURL += "/\(account.server)"
#if DEBUG #if DEBUG
listenerURL += "?sandbox=true" listenerURL += "?sandbox=true"
#endif #endif
let sub: PushSubscription = let sub: PushSubscription =
try await client.post(endpoint: Push.createSub(endpoint: listenerURL, try await client.post(endpoint: Push.createSub(endpoint: listenerURL,
p256dh: key, p256dh: key,
auth: authKey, auth: authKey,
@ -98,23 +99,23 @@ public class PushNotificationsService: ObservableObject {
follow: isFollowNotificationEnabled, follow: isFollowNotificationEnabled,
favourite: isFavoriteNotificationEnabled, favourite: isFavoriteNotificationEnabled,
poll: isPollNotificationEnabled)) poll: isPollNotificationEnabled))
subscriptions.append(sub) subscriptions.append(sub)
} catch { } } catch {}
} }
refreshSubscriptionsUI() refreshSubscriptionsUI()
} }
public func deleteSubscriptions(accounts: [PushAccounts]) async { public func deleteSubscriptions(accounts: [PushAccounts]) async {
for account in accounts { for account in accounts {
let client = Client(server: account.server, oauthToken: account.token) let client = Client(server: account.server, oauthToken: account.token)
do { do {
_ = try await client.delete(endpoint: Push.subscription) _ = try await client.delete(endpoint: Push.subscription)
} catch { } } catch {}
} }
await fetchSubscriptions(accounts: accounts) await fetchSubscriptions(accounts: accounts)
refreshSubscriptionsUI() refreshSubscriptionsUI()
} }
private func refreshSubscriptionsUI() { private func refreshSubscriptionsUI() {
if let sub = subscriptions.first { if let sub = subscriptions.first {
isPushEnabled = true isPushEnabled = true
@ -128,12 +129,13 @@ public class PushNotificationsService: ObservableObject {
isPushEnabled = false isPushEnabled = false
} }
} }
// MARK: - Key management // MARK: - Key management
public var notificationsPrivateKeyAsKey: P256.KeyAgreement.PrivateKey { public var notificationsPrivateKeyAsKey: P256.KeyAgreement.PrivateKey {
if let key = keychain.get(Constants.keychainPrivateKey), if let key = keychain.get(Constants.keychainPrivateKey),
let data = Data(base64Encoded: key) { let data = Data(base64Encoded: key)
{
do { do {
return try P256.KeyAgreement.PrivateKey(rawRepresentation: data) return try P256.KeyAgreement.PrivateKey(rawRepresentation: data)
} catch { } catch {
@ -151,10 +153,11 @@ public class PushNotificationsService: ObservableObject {
return key return key
} }
} }
public var notificationsAuthKeyAsKey: Data { public var notificationsAuthKeyAsKey: Data {
if let key = keychain.get(Constants.keychainAuthKey), if let key = keychain.get(Constants.keychainAuthKey),
let data = Data(base64Encoded: key) { let data = Data(base64Encoded: key)
{
return data return data
} else { } else {
let key = Self.makeRandomeNotificationsAuthKey() let key = Self.makeRandomeNotificationsAuthKey()
@ -164,8 +167,8 @@ public class PushNotificationsService: ObservableObject {
return key return key
} }
} }
static private func makeRandomeNotificationsAuthKey() -> Data { private static func makeRandomeNotificationsAuthKey() -> Data {
let byteCount = 16 let byteCount = 16
var bytes = Data(count: byteCount) var bytes = Data(count: byteCount)
_ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) }
@ -178,4 +181,3 @@ extension Data {
return map { String(format: "%02.2hhx", arguments: [$0]) }.joined() return map { String(format: "%02.2hhx", arguments: [$0]) }.joined()
} }
} }

View file

@ -7,11 +7,9 @@ public class QuickLook: ObservableObject {
@Published public private(set) var urls: [URL] = [] @Published public private(set) var urls: [URL] = []
@Published public private(set) var isPreparing: Bool = false @Published public private(set) var isPreparing: Bool = false
@Published public private(set) var latestError: Error? @Published public private(set) var latestError: Error?
public init() { public init() {}
}
public func prepareFor(urls: [URL], selectedURL: URL) async { public func prepareFor(urls: [URL], selectedURL: URL) async {
withAnimation { withAnimation {
isPreparing = true isPreparing = true
@ -43,7 +41,7 @@ public class QuickLook: ObservableObject {
latestError = error latestError = error
} }
} }
private func localPathFor(url: URL) async throws -> URL { private func localPathFor(url: URL) async throws -> URL {
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()) let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
let path = tempDir.appendingPathComponent(url.lastPathComponent) let path = tempDir.appendingPathComponent(url.lastPathComponent)

View file

@ -1,7 +1,7 @@
import Foundation import Foundation
import SwiftUI
import Models import Models
import Network import Network
import SwiftUI
public enum RouteurDestinations: Hashable { public enum RouteurDestinations: Hashable {
case accountDetail(id: String) case accountDetail(id: String)
@ -26,7 +26,7 @@ public enum SheetDestinations: Identifiable {
case listAddAccount(account: Account) case listAddAccount(account: Account)
case addAccount case addAccount
case addRemoteLocalTimeline case addRemoteLocalTimeline
public var id: String { public var id: String {
switch self { switch self {
case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor, .mentionStatusEditor: case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor, .mentionStatusEditor:
@ -47,19 +47,20 @@ public enum SheetDestinations: Identifiable {
public class RouterPath: ObservableObject { public class RouterPath: ObservableObject {
public var client: Client? public var client: Client?
public var urlHandler: ((URL) -> OpenURLAction.Result)? public var urlHandler: ((URL) -> OpenURLAction.Result)?
@Published public var path: [RouteurDestinations] = [] @Published public var path: [RouteurDestinations] = []
@Published public var presentedSheet: SheetDestinations? @Published public var presentedSheet: SheetDestinations?
public init() {} public init() {}
public func navigate(to: RouteurDestinations) { public func navigate(to: RouteurDestinations) {
path.append(to) path.append(to)
} }
public func handleStatus(status: AnyStatus, url: URL) -> OpenURLAction.Result { public func handleStatus(status: AnyStatus, url: URL) -> OpenURLAction.Result {
if url.pathComponents.contains(where: { $0 == "tags" }), if url.pathComponents.contains(where: { $0 == "tags" }),
let tag = url.pathComponents.last { let tag = url.pathComponents.last
{
navigate(to: .hashTag(tag: tag, account: nil)) navigate(to: .hashTag(tag: tag, account: nil))
return .handled return .handled
} else if let mention = status.mentions.first(where: { $0.url == url }) { } else if let mention = status.mentions.first(where: { $0.url == url }) {
@ -68,7 +69,8 @@ public class RouterPath: ObservableObject {
} else if let client = client, } else if let client = client,
client.isAuth, client.isAuth,
client.hasConnection(with: url), client.hasConnection(with: url),
let id = Int(url.lastPathComponent) { let id = Int(url.lastPathComponent)
{
if url.absoluteString.contains(client.server) { if url.absoluteString.contains(client.server) {
navigate(to: .statusDetail(id: String(id))) navigate(to: .statusDetail(id: String(id)))
} else { } else {
@ -78,10 +80,11 @@ public class RouterPath: ObservableObject {
} }
return urlHandler?(url) ?? .systemAction return urlHandler?(url) ?? .systemAction
} }
public func handle(url: URL) -> OpenURLAction.Result { public func handle(url: URL) -> OpenURLAction.Result {
if url.pathComponents.contains(where: { $0 == "tags" }), if url.pathComponents.contains(where: { $0 == "tags" }),
let tag = url.pathComponents.last { let tag = url.pathComponents.last
{
navigate(to: .hashTag(tag: tag, account: nil)) navigate(to: .hashTag(tag: tag, account: nil))
return .handled return .handled
} else if url.lastPathComponent.first == "@", let host = url.host { } else if url.lastPathComponent.first == "@", let host = url.host {
@ -93,7 +96,7 @@ public class RouterPath: ObservableObject {
} }
return urlHandler?(url) ?? .systemAction return urlHandler?(url) ?? .systemAction
} }
public func navigateToAccountFrom(acct: String, url: URL) async { public func navigateToAccountFrom(acct: String, url: URL) async {
guard let client else { return } guard let client else { return }
Task { Task {
@ -109,7 +112,7 @@ public class RouterPath: ObservableObject {
} }
} }
} }
public func navigateToAccountFrom(url: URL) async { public func navigateToAccountFrom(url: URL) async {
guard let client else { return } guard let client else { return }
Task { Task {

View file

@ -3,28 +3,28 @@ import Models
import Network import Network
@MainActor @MainActor
public class StreamWatcher: ObservableObject { public class StreamWatcher: ObservableObject {
private var client: Client? private var client: Client?
private var task: URLSessionWebSocketTask? private var task: URLSessionWebSocketTask?
private var watchedStreams: [Stream] = [] private var watchedStreams: [Stream] = []
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
public enum Stream: String { public enum Stream: String {
case publicTimeline = "public" case publicTimeline = "public"
case user case user
case direct case direct
} }
@Published public var events: [any StreamEvent] = [] @Published public var events: [any StreamEvent] = []
@Published public var unreadNotificationsCount: Int = 0 @Published public var unreadNotificationsCount: Int = 0
@Published public var latestEvent: (any StreamEvent)? @Published public var latestEvent: (any StreamEvent)?
public init() { public init() {
decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.keyDecodingStrategy = .convertFromSnakeCase
} }
public func setClient(client: Client) { public func setClient(client: Client) {
if self.client != nil { if self.client != nil {
stopWatching() stopWatching()
@ -32,13 +32,13 @@ public class StreamWatcher: ObservableObject {
self.client = client self.client = client
connect() connect()
} }
private func connect() { private func connect() {
task = client?.makeWebSocketTask(endpoint: Streaming.streaming) task = client?.makeWebSocketTask(endpoint: Streaming.streaming)
task?.resume() task?.resume()
receiveMessage() receiveMessage()
} }
public func watch(streams: [Stream]) { public func watch(streams: [Stream]) {
if client?.isAuth == false { if client?.isAuth == false {
return return
@ -51,17 +51,17 @@ public class StreamWatcher: ObservableObject {
sendMessage(message: StreamMessage(type: "subscribe", stream: stream.rawValue)) sendMessage(message: StreamMessage(type: "subscribe", stream: stream.rawValue))
} }
} }
public func stopWatching() { public func stopWatching() {
task?.cancel() task?.cancel()
task = nil task = nil
} }
private func sendMessage(message: StreamMessage) { private func sendMessage(message: StreamMessage) {
task?.send(.data(try! encoder.encode(message)), task?.send(.data(try! encoder.encode(message)),
completionHandler: { _ in }) completionHandler: { _ in })
} }
private func receiveMessage() { private func receiveMessage() {
task?.receive(completionHandler: { result in task?.receive(completionHandler: { result in
switch result { switch result {
@ -86,13 +86,13 @@ public class StreamWatcher: ObservableObject {
} catch { } catch {
print("Error decoding streaming event: \(error.localizedDescription)") print("Error decoding streaming event: \(error.localizedDescription)")
} }
default: default:
break break
} }
self.receiveMessage() self.receiveMessage()
case .failure: case .failure:
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@ -103,7 +103,7 @@ public class StreamWatcher: ObservableObject {
} }
}) })
} }
private func rawEventToEvent(rawEvent: RawStreamEvent) -> (any StreamEvent)? { private func rawEventToEvent(rawEvent: RawStreamEvent) -> (any StreamEvent)? {
guard let payloadData = rawEvent.payload.data(using: .utf8) else { guard let payloadData = rawEvent.payload.data(using: .utf8) else {
return nil return nil

View file

@ -1,19 +1,19 @@
import SwiftUI
import Foundation import Foundation
import Models import Models
import Network import Network
import SwiftUI
@MainActor @MainActor
public class UserPreferences: ObservableObject { public class UserPreferences: ObservableObject {
public static let sharedDefault = UserDefaults.init(suiteName: "group.icecubesapps") public static let sharedDefault = UserDefaults(suiteName: "group.icecubesapps")
public static let shared = UserPreferences() public static let shared = UserPreferences()
private var client: Client? private var client: Client?
@AppStorage("remote_local_timeline") public var remoteLocalTimelines: [String] = [] @AppStorage("remote_local_timeline") public var remoteLocalTimelines: [String] = []
@AppStorage("preferred_browser") public var preferredBrowser: PreferredBrowser = .inAppSafari @AppStorage("preferred_browser") public var preferredBrowser: PreferredBrowser = .inAppSafari
@AppStorage("draft_posts") public var draftsPosts: [String] = [] @AppStorage("draft_posts") public var draftsPosts: [String] = []
public var pushNotificationsCount: Int { public var pushNotificationsCount: Int {
get { get {
Self.sharedDefault?.integer(forKey: "push_notifications_count") ?? 0 Self.sharedDefault?.integer(forKey: "push_notifications_count") ?? 0
@ -22,18 +22,18 @@ public class UserPreferences: ObservableObject {
Self.sharedDefault?.set(newValue, forKey: "push_notifications_count") Self.sharedDefault?.set(newValue, forKey: "push_notifications_count")
} }
} }
@Published public var serverPreferences: ServerPreferences? @Published public var serverPreferences: ServerPreferences?
private init() { } private init() {}
public func setClient(client: Client) { public func setClient(client: Client) {
self.client = client self.client = client
Task { Task {
await refreshServerPreferences() await refreshServerPreferences()
} }
} }
public func refreshServerPreferences() async { public func refreshServerPreferences() async {
guard let client, client.isAuth else { return } guard let client, client.isAuth else { return }
serverPreferences = try? await client.get(endpoint: Accounts.preferences) serverPreferences = try? await client.get(endpoint: Accounts.preferences)

View file

@ -11,7 +11,8 @@ let package = Package(
products: [ products: [
.library( .library(
name: "Explore", name: "Explore",
targets: ["Explore"]), targets: ["Explore"]
),
], ],
dependencies: [ dependencies: [
.package(name: "Account", path: "../Account"), .package(name: "Account", path: "../Account"),
@ -30,8 +31,8 @@ let package = Package(
.product(name: "Models", package: "Models"), .product(name: "Models", package: "Models"),
.product(name: "Env", package: "Env"), .product(name: "Env", package: "Env"),
.product(name: "Status", package: "Status"), .product(name: "Status", package: "Status"),
.product(name: "DesignSystem", package: "DesignSystem") .product(name: "DesignSystem", package: "DesignSystem"),
]) ]
),
] ]
) )

View file

@ -1,21 +1,21 @@
import SwiftUI
import Env
import Network
import DesignSystem
import Models
import Status
import Shimmer
import Account import Account
import DesignSystem
import Env
import Models
import Network
import Shimmer
import Status
import SwiftUI
public struct ExploreView: View { public struct ExploreView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var routeurPath: RouterPath @EnvironmentObject private var routeurPath: RouterPath
@StateObject private var viewModel = ExploreViewModel() @StateObject private var viewModel = ExploreViewModel()
public init() { } public init() {}
public var body: some View { public var body: some View {
List { List {
if !viewModel.searchQuery.isEmpty { if !viewModel.searchQuery.isEmpty {
@ -30,7 +30,7 @@ public struct ExploreView: View {
EmptyView(iconName: "magnifyingglass", EmptyView(iconName: "magnifyingglass",
title: "Search your instance", title: "Search your instance",
message: "From this screen you can search anything on \(client.server)") message: "From this screen you can search anything on \(client.server)")
.listRowBackground(theme.secondaryBackgroundColor) .listRowBackground(theme.secondaryBackgroundColor)
} else { } else {
if !viewModel.trendingTags.isEmpty { if !viewModel.trendingTags.isEmpty {
trendingTagsSection trendingTagsSection
@ -64,10 +64,10 @@ public struct ExploreView: View {
suggestedTokens: $viewModel.suggestedToken, suggestedTokens: $viewModel.suggestedToken,
prompt: Text("Search users, posts and tags"), prompt: Text("Search users, posts and tags"),
token: { token in token: { token in
Text(token.rawValue) Text(token.rawValue)
}) })
} }
private var loadingView: some View { private var loadingView: some View {
ForEach(Status.placeholders()) { status in ForEach(Status.placeholders()) { status in
StatusRowView(viewModel: .init(status: status, isCompact: false)) StatusRowView(viewModel: .init(status: status, isCompact: false))
@ -77,7 +77,7 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
} }
@ViewBuilder @ViewBuilder
private func makeSearchResultsView(results: SearchResults) -> some View { private func makeSearchResultsView(results: SearchResults) -> some View {
if !results.accounts.isEmpty { if !results.accounts.isEmpty {
@ -109,16 +109,16 @@ public struct ExploreView: View {
} }
} }
} }
private var suggestedAccountsSection: some View { private var suggestedAccountsSection: some View {
Section("Suggested Users") { Section("Suggested Users") {
ForEach(viewModel.suggestedAccounts ForEach(viewModel.suggestedAccounts
.prefix(upTo: viewModel.suggestedAccounts.count > 3 ? 3 : viewModel.suggestedAccounts.count)) { account in .prefix(upTo: viewModel.suggestedAccounts.count > 3 ? 3 : viewModel.suggestedAccounts.count)) { account in
if let relationship = viewModel.suggestedAccountsRelationShips.first(where: { $0.id == account.id }) { if let relationship = viewModel.suggestedAccountsRelationShips.first(where: { $0.id == account.id }) {
AccountsListRow(viewModel: .init(account: account, relationShip: relationship)) AccountsListRow(viewModel: .init(account: account, relationShip: relationship))
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
}
} }
}
NavigationLink { NavigationLink {
List { List {
ForEach(viewModel.suggestedAccounts) { account in ForEach(viewModel.suggestedAccounts) { account in
@ -140,15 +140,15 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
} }
private var trendingTagsSection: some View { private var trendingTagsSection: some View {
Section("Trending Tags") { Section("Trending Tags") {
ForEach(viewModel.trendingTags ForEach(viewModel.trendingTags
.prefix(upTo: viewModel.trendingTags.count > 5 ? 5 : viewModel.trendingTags.count)) { tag in .prefix(upTo: viewModel.trendingTags.count > 5 ? 5 : viewModel.trendingTags.count)) { tag in
TagRowView(tag: tag) TagRowView(tag: tag)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.padding(.vertical, 4) .padding(.vertical, 4)
} }
NavigationLink { NavigationLink {
List { List {
ForEach(viewModel.trendingTags) { tag in ForEach(viewModel.trendingTags) { tag in
@ -169,16 +169,16 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
} }
private var trendingPostsSection: some View { private var trendingPostsSection: some View {
Section("Trending Posts") { Section("Trending Posts") {
ForEach(viewModel.trendingStatuses ForEach(viewModel.trendingStatuses
.prefix(upTo: viewModel.trendingStatuses.count > 3 ? 3 : viewModel.trendingStatuses.count)) { status in .prefix(upTo: viewModel.trendingStatuses.count > 3 ? 3 : viewModel.trendingStatuses.count)) { status in
StatusRowView(viewModel: .init(status: status, isCompact: false)) StatusRowView(viewModel: .init(status: status, isCompact: false))
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
NavigationLink { NavigationLink {
List { List {
ForEach(viewModel.trendingStatuses) { status in ForEach(viewModel.trendingStatuses) { status in
@ -199,15 +199,15 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
} }
private var trendingLinksSection: some View { private var trendingLinksSection: some View {
Section("Trending Links") { Section("Trending Links") {
ForEach(viewModel.trendingLinks ForEach(viewModel.trendingLinks
.prefix(upTo: viewModel.trendingLinks.count > 3 ? 3 : viewModel.trendingLinks.count)) { card in .prefix(upTo: viewModel.trendingLinks.count > 3 ? 3 : viewModel.trendingLinks.count)) { card in
StatusCardView(card: card) StatusCardView(card: card)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
NavigationLink { NavigationLink {
List { List {
ForEach(viewModel.trendingLinks) { card in ForEach(viewModel.trendingLinks) { card in
@ -228,5 +228,4 @@ public struct ExploreView: View {
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
} }
} }

View file

@ -1,7 +1,7 @@
import SwiftUI import Combine
import Models import Models
import Network import Network
import Combine import SwiftUI
@MainActor @MainActor
class ExploreViewModel: ObservableObject { class ExploreViewModel: ObservableObject {
@ -17,16 +17,16 @@ class ExploreViewModel: ObservableObject {
} }
} }
} }
enum Token: String, Identifiable { enum Token: String, Identifiable {
case user = "@user" case user = "@user"
case statuses = "@posts" case statuses = "@posts"
case tag = "#hashtag" case tag = "#hashtag"
var id: String { var id: String {
rawValue rawValue
} }
var apiType: String { var apiType: String {
switch self { switch self {
case .user: case .user:
@ -42,7 +42,7 @@ class ExploreViewModel: ObservableObject {
var allSectionsEmpty: Bool { var allSectionsEmpty: Bool {
trendingLinks.isEmpty && trendingTags.isEmpty && trendingStatuses.isEmpty && suggestedAccounts.isEmpty trendingLinks.isEmpty && trendingTags.isEmpty && trendingStatuses.isEmpty && suggestedAccounts.isEmpty
} }
@Published var tokens: [Token] = [] @Published var tokens: [Token] = []
@Published var suggestedToken: [Token] = [] @Published var suggestedToken: [Token] = []
@Published var searchQuery = "" @Published var searchQuery = ""
@ -53,7 +53,7 @@ class ExploreViewModel: ObservableObject {
@Published var trendingTags: [Tag] = [] @Published var trendingTags: [Tag] = []
@Published var trendingStatuses: [Status] = [] @Published var trendingStatuses: [Status] = []
@Published var trendingLinks: [Card] = [] @Published var trendingLinks: [Card] = []
private var searchTask: Task<Void, Never>? private var searchTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -61,7 +61,7 @@ class ExploreViewModel: ObservableObject {
$searchQuery $searchQuery
.removeDuplicates() .removeDuplicates()
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink(receiveValue: { [weak self] newValue in .sink(receiveValue: { [weak self] _ in
guard let self else { return } guard let self else { return }
if self.searchQuery.starts(with: "@") { if self.searchQuery.starts(with: "@") {
@ -76,17 +76,17 @@ class ExploreViewModel: ObservableObject {
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func fetchTrending() async { func fetchTrending() async {
guard let client else { return } guard let client else { return }
do { do {
let data = try await fetchTrendingsData(client: client) let data = try await fetchTrendingsData(client: client)
self.suggestedAccounts = data.suggestedAccounts suggestedAccounts = data.suggestedAccounts
self.trendingTags = data.trendingTags trendingTags = data.trendingTags
self.trendingStatuses = data.trendingStatuses trendingStatuses = data.trendingStatuses
self.trendingLinks = data.trendingLinks trendingLinks = data.trendingLinks
self.suggestedAccountsRelationShips = try await client.get(endpoint: Accounts.relationships(ids: self.suggestedAccounts.map{ $0.id })) suggestedAccountsRelationShips = try await client.get(endpoint: Accounts.relationships(ids: suggestedAccounts.map { $0.id }))
withAnimation { withAnimation {
isLoaded = true isLoaded = true
} }
@ -94,14 +94,14 @@ class ExploreViewModel: ObservableObject {
isLoaded = true isLoaded = true
} }
} }
private struct TrendingData { private struct TrendingData {
let suggestedAccounts: [Account] let suggestedAccounts: [Account]
let trendingTags: [Tag] let trendingTags: [Tag]
let trendingStatuses: [Status] let trendingStatuses: [Status]
let trendingLinks: [Card] let trendingLinks: [Card]
} }
private func fetchTrendingsData(client: Client) async throws -> TrendingData { private func fetchTrendingsData(client: Client) async throws -> TrendingData {
async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions) async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions)
async let trendingTags: [Tag] = client.get(endpoint: Trends.tags) async let trendingTags: [Tag] = client.get(endpoint: Trends.tags)
@ -112,7 +112,7 @@ class ExploreViewModel: ObservableObject {
trendingStatuses: trendingStatuses, trendingStatuses: trendingStatuses,
trendingLinks: trendingLinks) trendingLinks: trendingLinks)
} }
func search() { func search() {
guard !searchQuery.isEmpty else { return } guard !searchQuery.isEmpty else { return }
searchTask?.cancel() searchTask?.cancel()
@ -127,10 +127,10 @@ class ExploreViewModel: ObservableObject {
following: nil), following: nil),
forceVersion: .v2) forceVersion: .v2)
let relationships: [Relationshionship] = let relationships: [Relationshionship] =
try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map{ $0.id })) try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map { $0.id }))
results.relationships = relationships results.relationships = relationships
self.results[searchQuery] = results self.results[searchQuery] = results
} catch { } } catch {}
} }
} }
} }

View file

@ -11,7 +11,8 @@ let package = Package(
products: [ products: [
.library( .library(
name: "Lists", name: "Lists",
targets: ["Lists"]), targets: ["Lists"]
),
], ],
dependencies: [ dependencies: [
.package(name: "Network", path: "../Network"), .package(name: "Network", path: "../Network"),
@ -26,8 +27,8 @@ let package = Package(
.product(name: "Network", package: "Network"), .product(name: "Network", package: "Network"),
.product(name: "Models", package: "Models"), .product(name: "Models", package: "Models"),
.product(name: "Env", package: "Env"), .product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem") .product(name: "DesignSystem", package: "DesignSystem"),
]), ]
),
] ]
) )

View file

@ -1,8 +1,8 @@
import SwiftUI
import Network
import DesignSystem import DesignSystem
import Env import Env
import Models import Models
import Network
import SwiftUI
public struct ListAddAccountView: View { public struct ListAddAccountView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ -10,15 +10,14 @@ public struct ListAddAccountView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var viewModel: ListAddAccountViewModel @StateObject private var viewModel: ListAddAccountViewModel
@State private var isCreateListAlertPresented: Bool = false @State private var isCreateListAlertPresented: Bool = false
@State private var createListTitle: String = "" @State private var createListTitle: String = ""
public init(account: Account) { public init(account: Account) {
_viewModel = StateObject(wrappedValue: .init(account: account)) _viewModel = StateObject(wrappedValue: .init(account: account))
} }
public var body: some View { public var body: some View {
NavigationStack { NavigationStack {
List { List {

View file

@ -1,20 +1,20 @@
import SwiftUI
import Models import Models
import Network import Network
import SwiftUI
@MainActor @MainActor
class ListAddAccountViewModel: ObservableObject { class ListAddAccountViewModel: ObservableObject {
let account: Account let account: Account
@Published var inLists: [Models.List] = [] @Published var inLists: [Models.List] = []
@Published var isLoadingInfo: Bool = true @Published var isLoadingInfo: Bool = true
var client: Client? var client: Client?
init(account: Account) { init(account: Account) {
self.account = account self.account = account
} }
func fetchInfo() async { func fetchInfo() async {
guard let client else { return } guard let client else { return }
isLoadingInfo = true isLoadingInfo = true
@ -27,7 +27,7 @@ class ListAddAccountViewModel: ObservableObject {
} }
} }
} }
func addToList(list: Models.List) async { func addToList(list: Models.List) async {
guard let client else { return } guard let client else { return }
let response = try? await client.post(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id])) let response = try? await client.post(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))
@ -35,7 +35,7 @@ class ListAddAccountViewModel: ObservableObject {
inLists.append(list) inLists.append(list)
} }
} }
func removeFromList(list: Models.List) async { func removeFromList(list: Models.List) async {
guard let client else { return } guard let client else { return }
let response = try? await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id])) let response = try? await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))

View file

@ -1,20 +1,20 @@
import SwiftUI
import Models
import DesignSystem import DesignSystem
import Network
import EmojiText import EmojiText
import Models
import Network
import SwiftUI
public struct ListEditView: View { public struct ListEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var viewModel: ListEditViewModel @StateObject private var viewModel: ListEditViewModel
public init(list: Models.List) { public init(list: Models.List) {
_viewModel = StateObject(wrappedValue: .init(list: list)) _viewModel = StateObject(wrappedValue: .init(list: list))
} }
public var body: some View { public var body: some View {
NavigationStack { NavigationStack {
List { List {

View file

@ -1,20 +1,20 @@
import SwiftUI
import Models import Models
import Network import Network
import SwiftUI
@MainActor @MainActor
public class ListEditViewModel: ObservableObject { public class ListEditViewModel: ObservableObject {
let list: Models.List let list: Models.List
var client: Client? var client: Client?
@Published var isLoadingAccounts: Bool = true @Published var isLoadingAccounts: Bool = true
@Published var accounts: [Account] = [] @Published var accounts: [Account] = []
init(list: Models.List) { init(list: Models.List) {
self.list = list self.list = list
} }
func fetchAccounts() async { func fetchAccounts() async {
guard let client else { return } guard let client else { return }
isLoadingAccounts = true isLoadingAccounts = true
@ -25,14 +25,14 @@ public class ListEditViewModel: ObservableObject {
isLoadingAccounts = false isLoadingAccounts = false
} }
} }
func delete(account: Account) async { func delete(account: Account) async {
guard let client else { return } guard let client else { return }
do { do {
let response = try await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id])) let response = try await client.delete(endpoint: Lists.updateAccounts(listId: list.id, accounts: [account.id]))
if response?.statusCode == 200 { if response?.statusCode == 200 {
accounts.removeAll(where: { $0.id == account.id }) accounts.removeAll(where: { $0.id == account.id })
} }
} catch { } } catch {}
} }
} }

View file

@ -11,7 +11,8 @@ let package = Package(
products: [ products: [
.library( .library(
name: "Models", name: "Models",
targets: ["Models"]), targets: ["Models"]
),
], ],
dependencies: [ dependencies: [
.package(url: "https://gitlab.com/mflint/HTML2Markdown", exact: "1.0.0"), .package(url: "https://gitlab.com/mflint/HTML2Markdown", exact: "1.0.0"),
@ -21,9 +22,11 @@ let package = Package(
.target( .target(
name: "Models", name: "Models",
dependencies: ["HTML2Markdown", dependencies: ["HTML2Markdown",
"SwiftSoup"]), "SwiftSoup"]
),
.testTarget( .testTarget(
name: "ModelsTests", name: "ModelsTests",
dependencies: ["Models"]), dependencies: ["Models"]
),
] ]
) )

View file

@ -1,21 +1,20 @@
import Foundation import Foundation
public struct Account: Codable, Identifiable, Equatable, Hashable { public struct Account: Codable, Identifiable, Equatable, Hashable {
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
public struct Field: Codable, Equatable, Identifiable { public struct Field: Codable, Equatable, Identifiable {
public var id: String { public var id: String {
value + name value + name
} }
public let name: String public let name: String
public let value: HTMLString public let value: HTMLString
public let verifiedAt: String? public let verifiedAt: String?
} }
public struct Source: Codable, Equatable { public struct Source: Codable, Equatable {
public let privacy: Visibility public let privacy: Visibility
public let sensitive: Bool public let sensitive: Bool
@ -23,7 +22,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
public let note: String public let note: String
public let fields: [Field] public let fields: [Field]
} }
public let id: String public let id: String
public let username: String public let username: String
public let displayName: String public let displayName: String
@ -43,7 +42,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
public let source: Source? public let source: Source?
public let bot: Bool public let bot: Bool
public let discoverable: Bool? public let discoverable: Bool?
public static func placeholder() -> Account { public static func placeholder() -> Account {
.init(id: UUID().uuidString, .init(id: UUID().uuidString,
username: "Username", username: "Username",
@ -65,7 +64,7 @@ public struct Account: Codable, Identifiable, Equatable, Hashable {
bot: false, bot: false,
discoverable: true) discoverable: true)
} }
public static func placeholders() -> [Account] { public static func placeholders() -> [Account] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]

View file

@ -5,8 +5,8 @@ import SwiftUI
public typealias HTMLString = String public typealias HTMLString = String
extension HTMLString { public extension HTMLString {
public var asMarkdown: String { var asMarkdown: String {
do { do {
let dom = try HTMLParser().parse(html: self) let dom = try HTMLParser().parse(html: self)
return dom.toMarkdown() return dom.toMarkdown()
@ -16,8 +16,8 @@ extension HTMLString {
return self return self
} }
} }
public var asRawText: String { var asRawText: String {
do { do {
let document: Document = try SwiftSoup.parse(self) let document: Document = try SwiftSoup.parse(self)
return try document.text() return try document.text()
@ -25,8 +25,8 @@ extension HTMLString {
return self return self
} }
} }
public func findStatusesURLs() -> [URL]? { func findStatusesURLs() -> [URL]? {
do { do {
let document: Document = try SwiftSoup.parse(self) let document: Document = try SwiftSoup.parse(self)
let links: Elements = try document.select("a") let links: Elements = try document.select("a")
@ -34,7 +34,8 @@ extension HTMLString {
for link in links { for link in links {
let href = try link.attr("href") let href = try link.attr("href")
if let url = URL(string: href), if let url = URL(string: href),
let _ = Int(url.lastPathComponent) { let _ = Int(url.lastPathComponent)
{
URLs.append(url) URLs.append(url)
} }
} }
@ -43,8 +44,8 @@ extension HTMLString {
return nil return nil
} }
} }
public var asSafeAttributedString: AttributedString { var asSafeAttributedString: AttributedString {
do { do {
let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true, let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true,
interpretedSyntax: .inlineOnlyPreservingWhitespace) interpretedSyntax: .inlineOnlyPreservingWhitespace)
@ -54,4 +55,3 @@ extension HTMLString {
} }
} }
} }

View file

@ -10,23 +10,23 @@ extension ServerDate {
dateFormatter.timeZone = .init(abbreviation: "UTC") dateFormatter.timeZone = .init(abbreviation: "UTC")
return dateFormatter return dateFormatter
} }
private static var createdAtRelativeFormatter: RelativeDateTimeFormatter { private static var createdAtRelativeFormatter: RelativeDateTimeFormatter {
let dateFormatter = RelativeDateTimeFormatter() let dateFormatter = RelativeDateTimeFormatter()
dateFormatter.unitsStyle = .abbreviated dateFormatter.unitsStyle = .abbreviated
return dateFormatter return dateFormatter
} }
private static var createdAtShortDateFormatted: DateFormatter { private static var createdAtShortDateFormatted: DateFormatter {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium dateFormatter.dateStyle = .medium
return dateFormatter return dateFormatter
} }
public var asDate: Date { public var asDate: Date {
Self.createdAtDateFormatter.date(from: self)! Self.createdAtDateFormatter.date(from: self)!
} }
public var formatted: String { public var formatted: String {
let calendar = Calendar(identifier: .gregorian) let calendar = Calendar(identifier: .gregorian)
if calendar.numberOfDaysBetween(asDate, and: Date()) > 1 { if calendar.numberOfDaysBetween(asDate, and: Date()) > 1 {
@ -37,13 +37,12 @@ extension ServerDate {
} }
} }
extension Calendar { extension Calendar {
func numberOfDaysBetween(_ from: Date, and to: Date) -> Int { func numberOfDaysBetween(_ from: Date, and to: Date) -> Int {
let fromDate = startOfDay(for: from) let fromDate = startOfDay(for: from)
let toDate = startOfDay(for: to) let toDate = startOfDay(for: to)
let numberOfDays = dateComponents([.day], from: fromDate, to: toDate) let numberOfDays = dateComponents([.day], from: fromDate, to: toDate)
return numberOfDays.day! return numberOfDays.day!
} }
} }

View file

@ -1,6 +1,6 @@
import Foundation import Foundation
public struct AppInfo { public enum AppInfo {
public static let clientName = "IceCubesApp" public static let clientName = "IceCubesApp"
public static let scheme = "icecubesapp://" public static let scheme = "icecubesapp://"
public static let scopes = "read write follow push" public static let scopes = "read write follow push"

View file

@ -4,7 +4,7 @@ public struct Card: Codable, Identifiable {
public var id: String { public var id: String {
url.absoluteString url.absoluteString
} }
public let url: URL public let url: URL
public let title: String? public let title: String?
public let description: String? public let description: String?

View file

@ -5,11 +5,11 @@ public struct Conversation: Identifiable, Decodable {
public let unread: Bool public let unread: Bool
public let lastStatus: Status public let lastStatus: Status
public let accounts: [Account] public let accounts: [Account]
public static func placeholder() -> Conversation { public static func placeholder() -> Conversation {
.init(id: UUID().uuidString, unread: false, lastStatus: .placeholder(), accounts: [.placeholder()]) .init(id: UUID().uuidString, unread: false, lastStatus: .placeholder(), accounts: [.placeholder()])
} }
public static func placeholders() -> [Conversation] { public static func placeholders() -> [Conversation] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]

View file

@ -1,15 +1,14 @@
import Foundation import Foundation
public struct Emoji: Codable, Hashable, Identifiable { public struct Emoji: Codable, Hashable, Identifiable {
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(shortcode) hasher.combine(shortcode)
} }
public var id: String { public var id: String {
shortcode shortcode
} }
public let shortcode: String public let shortcode: String
public let url: URL public let url: URL
public let staticUrl: URL public let staticUrl: URL

View file

@ -9,12 +9,12 @@ public struct Filter: Codable, Identifiable {
public enum Action: String, Codable { public enum Action: String, Codable {
case warn, hide case warn, hide
} }
public enum Context: String, Codable { public enum Context: String, Codable {
case home, notifications, account, thread case home, notifications, account, thread
case pub = "public" case pub = "public"
} }
public let id: String public let id: String
public let title: String public let title: String
public let context: [String] public let context: [String]

View file

@ -6,29 +6,29 @@ public struct Instance: Codable {
public let statusCount: Int public let statusCount: Int
public let domainCount: Int public let domainCount: Int
} }
public struct Configuration: Codable { public struct Configuration: Codable {
public struct Statuses: Codable { public struct Statuses: Codable {
public let maxCharacters: Int public let maxCharacters: Int
public let maxMediaAttachments: Int public let maxMediaAttachments: Int
} }
public struct Polls: Codable { public struct Polls: Codable {
public let maxOptions: Int public let maxOptions: Int
public let maxCharactersPerOption: Int public let maxCharactersPerOption: Int
public let minExpiration: Int public let minExpiration: Int
public let maxExpiration: Int public let maxExpiration: Int
} }
public let statuses: Statuses public let statuses: Statuses
public let polls: Polls public let polls: Polls
} }
public struct Rule: Codable, Identifiable { public struct Rule: Codable, Identifiable {
public let id: String public let id: String
public let text: String public let text: String
} }
public let title: String public let title: String
public let shortDescription: String public let shortDescription: String
public let email: String public let email: String

View file

@ -4,6 +4,7 @@ public struct InstanceSocial: Decodable, Identifiable {
public struct Info: Decodable { public struct Info: Decodable {
public let shortDescription: String public let shortDescription: String
} }
public let id: String public let id: String
public let name: String public let name: String
public let dead: Bool public let dead: Bool

View file

@ -1,17 +1,16 @@
import Foundation import Foundation
public struct MastodonPushNotification: Codable { public struct MastodonPushNotification: Codable {
public let accessToken: String public let accessToken: String
public let notificationID: Int public let notificationID: Int
public let notificationType: String public let notificationType: String
public let preferredLocale: String? public let preferredLocale: String?
public let icon: String? public let icon: String?
public let title: String public let title: String
public let body: String public let body: String
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case accessToken = "access_token" case accessToken = "access_token"
case notificationID = "notification_id" case notificationID = "notification_id"

View file

@ -1,31 +1,31 @@
import Foundation import Foundation
public struct MediaAttachement: Codable, Identifiable, Hashable { public struct MediaAttachement: Codable, Identifiable, Hashable {
public struct MetaContainer: Codable, Equatable { public struct MetaContainer: Codable, Equatable {
public struct Meta: Codable, Equatable { public struct Meta: Codable, Equatable {
public let width: Int? public let width: Int?
public let height: Int? public let height: Int?
} }
public let original: Meta? public let original: Meta?
} }
public enum SupportedType: String { public enum SupportedType: String {
case image, gifv, video, audio case image, gifv, video, audio
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
public let id: String public let id: String
public let type: String public let type: String
public var supportedType: SupportedType? { public var supportedType: SupportedType? {
SupportedType(rawValue: type) SupportedType(rawValue: type)
} }
public let url: URL? public let url: URL?
public let previewUrl: URL? public let previewUrl: URL?
public let description: String? public let description: String?
public let meta: MetaContainer? public let meta: MetaContainer?
} }

View file

@ -4,17 +4,17 @@ public struct Notification: Codable, Identifiable {
public enum NotificationType: String, CaseIterable { public enum NotificationType: String, CaseIterable {
case follow, follow_request, mention, reblog, status, favourite, poll, update case follow, follow_request, mention, reblog, status, favourite, poll, update
} }
public let id: String public let id: String
public let type: String public let type: String
public let createdAt: ServerDate public let createdAt: ServerDate
public let account: Account public let account: Account
public let status: Status? public let status: Status?
public var supportedType: NotificationType? { public var supportedType: NotificationType? {
.init(rawValue: type) .init(rawValue: type)
} }
public static func placeholder() -> Notification { public static func placeholder() -> Notification {
.init(id: UUID().uuidString, .init(id: UUID().uuidString,
type: NotificationType.favourite.rawValue, type: NotificationType.favourite.rawValue,
@ -22,9 +22,8 @@ public struct Notification: Codable, Identifiable {
account: .placeholder(), account: .placeholder(),
status: .placeholder()) status: .placeholder())
} }
public static func placeholders() -> [Notification] { public static func placeholders() -> [Notification] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
} }
} }

View file

@ -5,12 +5,12 @@ public struct Poll: Codable {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case title, votesCount case title, votesCount
} }
public var id = UUID().uuidString public var id = UUID().uuidString
public let title: String public let title: String
public let votesCount: Int public let votesCount: Int
} }
public let id: String public let id: String
public let expiresAt: ServerDate public let expiresAt: ServerDate
public let expired: Bool public let expired: Bool

View file

@ -9,7 +9,7 @@ public struct PushSubscription: Identifiable, Decodable {
public let poll: Bool public let poll: Bool
public let status: Bool public let status: Bool
} }
public let id: Int public let id: Int
public let endpoint: URL public let endpoint: URL
public let serverKey: String public let serverKey: String

View file

@ -14,8 +14,8 @@ public struct Relationshionship: Codable {
public let endorsed: Bool public let endorsed: Bool
public let note: String public let note: String
public let notifying: Bool public let notifying: Bool
static public func placeholder() -> Relationshionship { public static func placeholder() -> Relationshionship {
.init(id: UUID().uuidString, .init(id: UUID().uuidString,
following: false, following: false,
showingReblogs: false, showingReblogs: false,

View file

@ -4,7 +4,7 @@ public struct SearchResults: Decodable {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case accounts, statuses, hashtags case accounts, statuses, hashtags
} }
public let accounts: [Account] public let accounts: [Account]
public var relationships: [Relationshionship] = [] public var relationships: [Relationshionship] = []
public let statuses: [Status] public let statuses: [Status]

View file

@ -6,13 +6,13 @@ public struct ServerPreferences: Decodable {
public let postLanguage: String? public let postLanguage: String?
public let autoExpandmedia: AutoExpandMedia? public let autoExpandmedia: AutoExpandMedia?
public let autoExpandSpoilers: Bool? public let autoExpandSpoilers: Bool?
public enum AutoExpandMedia: String, Decodable { public enum AutoExpandMedia: String, Decodable {
case showAll = "show_all" case showAll = "show_all"
case hideAll = "hide_all" case hideAll = "hide_all"
case hideSensitive = "default" case hideSensitive = "default"
} }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case postVisibility = "posting:default:visibility" case postVisibility = "posting:default:visibility"
case postIsSensitive = "posting:default:sensitive" case postIsSensitive = "posting:default:sensitive"

View file

@ -4,12 +4,13 @@ public struct Application: Codable, Identifiable {
public var id: String { public var id: String {
name name
} }
public let name: String public let name: String
public let website: URL? public let website: URL?
} }
extension Application { public extension Application {
public init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self) let values = try decoder.container(keyedBy: CodingKeys.self)
name = try values.decodeIfPresent(String.self, forKey: .name) ?? "" name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
@ -53,12 +54,11 @@ public protocol AnyStatus {
var language: String? { get } var language: String? { get }
} }
public struct Status: AnyStatus, Codable, Identifiable { public struct Status: AnyStatus, Codable, Identifiable {
public var viewId: String { public var viewId: String {
id + createdAt + (editedAt ?? "") id + createdAt + (editedAt ?? "")
} }
public let id: String public let id: String
public let content: HTMLString public let content: HTMLString
public let account: Account public let account: Account
@ -85,7 +85,7 @@ public struct Status: AnyStatus, Codable, Identifiable {
public let filtered: [Filtered]? public let filtered: [Filtered]?
public let sensitive: Bool public let sensitive: Bool
public let language: String? public let language: String?
public static func placeholder() -> Status { public static func placeholder() -> Status {
.init(id: UUID().uuidString, .init(id: UUID().uuidString,
content: "This is a #toot\nWith some @content\nAnd some more content for your #eyes @only", content: "This is a #toot\nWith some @content\nAnd some more content for your #eyes @only",
@ -114,7 +114,7 @@ public struct Status: AnyStatus, Codable, Identifiable {
sensitive: false, sensitive: false,
language: nil) language: nil)
} }
public static func placeholders() -> [Status] { public static func placeholders() -> [Status] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
} }
@ -124,7 +124,7 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable {
public var viewId: String { public var viewId: String {
id + createdAt + (editedAt ?? "") id + createdAt + (editedAt ?? "")
} }
public let id: String public let id: String
public let content: String public let content: String
public let account: Account public let account: Account

View file

@ -6,7 +6,7 @@ public struct RawStreamEvent: Decodable {
public let payload: String public let payload: String
} }
public protocol StreamEvent: Identifiable{ public protocol StreamEvent: Identifiable {
var date: Date { get } var date: Date { get }
var id: String { get } var id: String { get }
} }

View file

@ -3,10 +3,9 @@ import Foundation
public struct StreamMessage: Encodable { public struct StreamMessage: Encodable {
public let type: String public let type: String
public let stream: String public let stream: String
public init(type: String, stream: String) { public init(type: String, stream: String) {
self.type = type self.type = type
self.stream = stream self.stream = stream
} }
} }

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