diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 46601141..c6cb5c0f 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 9F24EEB829360C330042359D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9F24EEB729360C330042359D /* Preview Assets.xcassets */; }; 9F295540292B6C3400E0E81B /* Timeline in Frameworks */ = {isa = PBXBuildFile; productRef = 9F29553F292B6C3400E0E81B /* Timeline */; }; 9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F5295AE04800DE16D0 /* Tabs.swift */; }; + 9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */; }; + 9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */; }; 9F35DB44294F9A7D00B3281A /* Status in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB43294F9A7D00B3281A /* Status */; }; 9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4629506F6600B3281A /* NotificationTab.swift */; }; 9F35DB4A29506FA100B3281A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB4929506FA100B3281A /* Notifications */; }; @@ -36,6 +38,8 @@ 9F29553D292B67B600E0E81B /* Network */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Network; path = Packages/Network; sourceTree = ""; }; 9F29553E292B6AF600E0E81B /* Timeline */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Timeline; path = Packages/Timeline; sourceTree = ""; }; 9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; + 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountsView.swift; sourceTree = ""; }; + 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceInfoView.swift; sourceTree = ""; }; 9F35DB42294F9A2900B3281A /* Status */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Status; path = Packages/Status; sourceTree = ""; }; 9F35DB45294FA04C00B3281A /* DesignSystem */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DesignSystem; path = Packages/DesignSystem; sourceTree = ""; }; 9F35DB4629506F6600B3281A /* NotificationTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTab.swift; sourceTree = ""; }; @@ -170,6 +174,8 @@ children = ( 9FAE4ACA293783B000772766 /* SettingsTab.swift */, 9FE151A5293C90F900E9683D /* IconSelectorView.swift */, + 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */, + 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */, ); path = Settings; sourceTree = ""; @@ -259,6 +265,7 @@ buildActionMask = 2147483647; files = ( 9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */, + 9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */, 9F35DB4C2952005C00B3281A /* AccountTab.swift in Sources */, 9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */, 9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */, @@ -266,6 +273,7 @@ 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */, 9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */, 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */, + 9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */, 9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */, 9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */, 9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */, diff --git a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift new file mode 100644 index 00000000..83e3a48e --- /dev/null +++ b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift @@ -0,0 +1,137 @@ +import SwiftUI +import Network +import Models +import Env +import DesignSystem +import NukeUI +import Shimmer + +struct AddAccountView: View { + @Environment(\.openURL) private var openURL + @Environment(\.dismiss) private var dismiss + + @EnvironmentObject private var appAccountsManager: AppAccountsManager + @EnvironmentObject private var currentAccount: CurrentAccount + @EnvironmentObject private var currentInstance: CurrentInstance + @EnvironmentObject private var theme: Theme + + @State private var instanceName: String = "" + @State private var instance: Instance? + @State private var isSigninIn = false + @State private var signInClient: Client? + @State private var instances: [InstanceSocial] = [] + + var body: some View { + NavigationStack { + Form { + TextField("Instance url", text: $instanceName) + .listRowBackground(theme.primaryBackgroundColor) + if let instance { + Button { + isSigninIn = true + Task { + await signIn() + } + } label: { + if isSigninIn { + ProgressView() + } else { + Text("Sign in") + } + } + .listRowBackground(theme.primaryBackgroundColor) + InstanceInfoView(instance: instance) + } else { + instancesListView + } + } + .formStyle(.grouped) + .navigationTitle("Add account") + .navigationBarTitleDisplayMode(.inline) + .scrollContentBackground(.hidden) + .background(theme.secondaryBackgroundColor) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel", action: { dismiss() }) + } + } + .onAppear { + let client = InstanceSocialClient() + Task { + self.instances = await client.fetchInstances() + } + } + .onChange(of: instanceName) { newValue in + let client = Client(server: newValue) + Task { + do { + self.instance = try await client.get(endpoint: Instances.instance) + } catch { + self.instance = nil + } + } + } + .onOpenURL(perform: { url in + Task { + await continueSignIn(url: url) + } + }) + } + } + + private var instancesListView: some View { + Section("Suggestions") { + if instances.isEmpty { + ProgressView() + .listRowBackground(theme.primaryBackgroundColor) + } else { + ForEach(instanceName.isEmpty ? instances : instances.filter{ $0.name.contains(instanceName.lowercased()) }) { instance in + VStack(alignment: .leading, spacing: 4) { + Text(instance.name) + .font(.headline) + Text(instance.info?.shortDescription ?? "") + .font(.body) + .foregroundColor(.gray) + Text("\(instance.users) users βΈ± \(instance.statuses) posts") + .font(.footnote) + .foregroundColor(.gray) + } + .listRowBackground(theme.primaryBackgroundColor) + .onTapGesture { + self.instanceName = instance.name + } + } + } + } + } + + private func signIn() async { + do { + signInClient = .init(server: instanceName) + if let oauthURL = try await signInClient?.oauthURL() { + openURL(oauthURL) + } else { + isSigninIn = false + } + } catch { + isSigninIn = false + } + } + + private func continueSignIn(url: URL) async { + guard let client = signInClient else { + isSigninIn = false + return + } + do { + let oauthToken = try await client.continueOauthFlow(url: url) + appAccountsManager.add(account: AppAccount(server: client.server, oauthToken: oauthToken)) + await currentAccount.fetchCurrentAccount() + await currentInstance.fetchCurrentInstance() + isSigninIn = false + dismiss() + } catch { + isSigninIn = false + } + } +} diff --git a/IceCubesApp/App/Tabs/Settings/InstanceInfoView.swift b/IceCubesApp/App/Tabs/Settings/InstanceInfoView.swift new file mode 100644 index 00000000..58f1f43a --- /dev/null +++ b/IceCubesApp/App/Tabs/Settings/InstanceInfoView.swift @@ -0,0 +1,30 @@ +import SwiftUI +import Models +import DesignSystem +import NukeUI + +struct InstanceInfoView: View { + @EnvironmentObject private var theme: Theme + + let instance: Instance + + var body: some View { + Section("Instance info") { + LabeledContent("Name", value: instance.title) + Text(instance.shortDescription) + LabeledContent("Email", value: instance.email) + LabeledContent("Version", value: instance.version) + LabeledContent("Users", value: "\(instance.stats.userCount)") + LabeledContent("Posts", value: "\(instance.stats.statusCount)") + LabeledContent("Domains", value: "\(instance.stats.domainCount)") + } + .listRowBackground(theme.primaryBackgroundColor) + + Section("Instance rules") { + ForEach(instance.rules) { rule in + Text(rule.text) + } + } + .listRowBackground(theme.primaryBackgroundColor) + } +} diff --git a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift index 899b427e..bfebd7c9 100644 --- a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift +++ b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift @@ -6,16 +6,14 @@ import Account import Models import DesignSystem -struct SettingsTabs: View { - @Environment(\.openURL) private var openURL +struct SettingsTabs: View { @EnvironmentObject private var client: Client @EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentInstance: CurrentInstance @EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var theme: Theme - @State private var signInInProgress = false - @State private var signInServer = IceCubesApp.defaultServer + @State private var addAccountSheetPresented = false var body: some View { NavigationStack { @@ -25,11 +23,6 @@ struct SettingsTabs: View { themeSection instanceSection } - .onOpenURL(perform: { url in - Task { - await continueSignIn(url: url) - } - }) .scrollContentBackground(.hidden) .background(theme.secondaryBackgroundColor) .navigationTitle(Text("Settings")) @@ -37,10 +30,8 @@ struct SettingsTabs: View { } .task { if appAccountsManager.currentAccount.oauthToken != nil { - signInInProgress = true await currentAccount.fetchCurrentAccount() await currentInstance.fetchCurrentInstance() - signInInProgress = false } } } @@ -60,10 +51,8 @@ struct SettingsTabs: View { } } signOutButton - } else { - TextField("Mastodon server", text: $signInServer) - signInButton } + addAccountButton } .listRowBackground(theme.primaryBackgroundColor) } @@ -97,23 +86,7 @@ struct SettingsTabs: View { @ViewBuilder private var instanceSection: some View { if let instanceData = currentInstance.instance { - Section("Instance info") { - LabeledContent("Name", value: instanceData.title) - Text(instanceData.shortDescription) - LabeledContent("Email", value: instanceData.email) - LabeledContent("Version", value: instanceData.version) - LabeledContent("Users", value: "\(instanceData.stats.userCount)") - LabeledContent("Posts", value: "\(instanceData.stats.statusCount)") - LabeledContent("Domains", value: "\(instanceData.stats.domainCount)") - } - .listRowBackground(theme.primaryBackgroundColor) - - Section("Instance rules") { - ForEach(instanceData.rules) { rule in - Text(rule.text) - } - } - .listRowBackground(theme.primaryBackgroundColor) + InstanceInfoView(instance: instanceData) } } @@ -138,18 +111,14 @@ struct SettingsTabs: View { .listRowBackground(theme.primaryBackgroundColor) } - private var signInButton: some View { + private var addAccountButton: some View { Button { - signInInProgress = true - Task { - await signIn() - } + addAccountSheetPresented.toggle() } label: { - if signInInProgress { - ProgressView() - } else { - Text("Sign in") - } + Text("Add account") + } + .sheet(isPresented: $addAccountSheetPresented) { + AddAccountView() } } @@ -159,28 +128,5 @@ struct SettingsTabs: View { } label: { Text("Sign out").foregroundColor(.red) } - - } - - private func signIn() async { - do { - client.server = signInServer - let oauthURL = try await client.oauthURL() - openURL(oauthURL) - } catch { - signInInProgress = false - } - } - - private func continueSignIn(url: URL) async { - do { - let oauthToken = try await client.continueOauthFlow(url: url) - appAccountsManager.add(account: AppAccount(server: client.server, oauthToken: oauthToken)) - await currentAccount.fetchCurrentAccount() - await currentInstance.fetchCurrentInstance() - signInInProgress = false - } catch { - signInInProgress = false - } } } diff --git a/Packages/Models/Sources/Models/InstanceSocial.swift b/Packages/Models/Sources/Models/InstanceSocial.swift new file mode 100644 index 00000000..896de501 --- /dev/null +++ b/Packages/Models/Sources/Models/InstanceSocial.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct InstanceSocial: Decodable, Identifiable { + public struct Info: Decodable { + public let shortDescription: String + } + public let id: String + public let name: String + public let dead: Bool + public let users: String + public let activeUsers: Int? + public let statuses: String + public let thumbnail: URL? + public let info: Info? +} diff --git a/Packages/Network/Sources/Network/InstanceSocialClient.swift b/Packages/Network/Sources/Network/InstanceSocialClient.swift new file mode 100644 index 00000000..7f91ea66 --- /dev/null +++ b/Packages/Network/Sources/Network/InstanceSocialClient.swift @@ -0,0 +1,29 @@ +import Foundation +import Models + +public struct InstanceSocialClient { + private let authorization = "Bearer 8a4xx3D7Hzu1aFnf18qlkH8oU0oZ5ulabXxoS2FtQtwOy8G0DGQhr5PjTIjBnYAmFrSBuE2CcASjFocxJBonY8XGbLySB7MXd9ssrwlRHUXTQh3Z578lE1OfUtafvhML" + private let endpoint = URL(string: "https://instances.social/api/1.0/instances/list?count=1000&include_closed=false&include_dead=false&min_active_users=500")! + + struct Response: Decodable { + let instances: [InstanceSocial] + } + + public init() { + + } + + public func fetchInstances() async -> [InstanceSocial] { + do { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + var request: URLRequest = .init(url: endpoint) + request.setValue(authorization, forHTTPHeaderField: "Authorization") + let (data, _) = try await URLSession.shared.data(for: request) + let response = try decoder.decode(Response.self, from: data) + return response.instances + } catch { + return [] + } + } +}