OAuth + Home timeline
This commit is contained in:
parent
eb92379ac7
commit
df2d383b8a
|
@ -13,8 +13,12 @@
|
|||
9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */; };
|
||||
9F398AA92935FFDB00A889F2 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AA82935FFDB00A889F2 /* Account */; };
|
||||
9F398AAB2935FFDB00A889F2 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AAA2935FFDB00A889F2 /* Models */; };
|
||||
9F398AB329360A4C00A889F2 /* TimelineTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AB229360A4C00A889F2 /* TimelineTabView.swift */; };
|
||||
9FBFE63D292A715500C250E9 /* IceCubesAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesAppApp.swift */; };
|
||||
9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AB229360A4C00A889F2 /* TimelineTab.swift */; };
|
||||
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4ACA293783B000772766 /* SettingsTab.swift */; };
|
||||
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; };
|
||||
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4AD029379AD600772766 /* AppAccount.swift */; };
|
||||
9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */; };
|
||||
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBFE63C292A715500C250E9 /* IceCubesApp.swift */; };
|
||||
9FBFE641292A715600C250E9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9FBFE640292A715600C250E9 /* Assets.xcassets */; };
|
||||
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */ = {isa = PBXBuildFile; productRef = 9FBFE64D292A72BD00C250E9 /* Network */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
@ -27,9 +31,13 @@
|
|||
9F398AA32935F90100A889F2 /* Models */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Models; path = Packages/Models; sourceTree = "<group>"; };
|
||||
9F398AA52935FE8A00A889F2 /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = "<group>"; };
|
||||
9F398AAC2936005300A889F2 /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Account; path = Packages/Account; sourceTree = "<group>"; };
|
||||
9F398AB229360A4C00A889F2 /* TimelineTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTabView.swift; sourceTree = "<group>"; };
|
||||
9F398AB229360A4C00A889F2 /* TimelineTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTab.swift; sourceTree = "<group>"; };
|
||||
9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = "<group>"; };
|
||||
9FAE4AD029379AD600772766 /* AppAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccount.swift; sourceTree = "<group>"; };
|
||||
9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccountsManager.swift; sourceTree = "<group>"; };
|
||||
9FBFE639292A715500C250E9 /* IceCubesApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IceCubesApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9FBFE63C292A715500C250E9 /* IceCubesAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesAppApp.swift; sourceTree = "<group>"; };
|
||||
9FBFE63C292A715500C250E9 /* IceCubesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesApp.swift; sourceTree = "<group>"; };
|
||||
9FBFE640292A715600C250E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesApp.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
@ -39,6 +47,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */,
|
||||
9F398AA92935FFDB00A889F2 /* Account in Frameworks */,
|
||||
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */,
|
||||
9F398AAB2935FFDB00A889F2 /* Models in Frameworks */,
|
||||
|
@ -53,9 +62,10 @@
|
|||
9F398AB429360A5800A889F2 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9FBFE63C292A715500C250E9 /* IceCubesAppApp.swift */,
|
||||
9FAE4ACF29379ACA00772766 /* AppAccounts */,
|
||||
9FAE4AC9293783A200772766 /* Tabs */,
|
||||
9FBFE63C292A715500C250E9 /* IceCubesApp.swift */,
|
||||
9F398AA52935FE8A00A889F2 /* AppRouteur.swift */,
|
||||
9F398AB229360A4C00A889F2 /* TimelineTabView.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
|
@ -69,6 +79,24 @@
|
|||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9FAE4AC9293783A200772766 /* Tabs */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9F398AB229360A4C00A889F2 /* TimelineTab.swift */,
|
||||
9FAE4ACA293783B000772766 /* SettingsTab.swift */,
|
||||
);
|
||||
path = Tabs;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9FAE4ACF29379ACA00772766 /* AppAccounts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9FAE4AD029379AD600772766 /* AppAccount.swift */,
|
||||
9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */,
|
||||
);
|
||||
path = AppAccounts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9FBFE630292A715500C250E9 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -94,6 +122,7 @@
|
|||
9FBFE63B292A715500C250E9 /* IceCubesApp */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9FAE4AC8293774FF00772766 /* Info.plist */,
|
||||
9F398AB429360A5800A889F2 /* App */,
|
||||
9FBFE642292A715600C250E9 /* IceCubesApp.entitlements */,
|
||||
9F398AB529360A6100A889F2 /* Resources */,
|
||||
|
@ -130,6 +159,7 @@
|
|||
9F398AA82935FFDB00A889F2 /* Account */,
|
||||
9F398AAA2935FFDB00A889F2 /* Models */,
|
||||
9F24EEBA293619210042359D /* Routeur */,
|
||||
9FAE4ACD29379A5A00772766 /* KeychainSwift */,
|
||||
);
|
||||
productName = IceCubesApp;
|
||||
productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */;
|
||||
|
@ -159,6 +189,9 @@
|
|||
Base,
|
||||
);
|
||||
mainGroup = 9FBFE630292A715500C250E9;
|
||||
packageReferences = (
|
||||
9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */,
|
||||
);
|
||||
productRefGroup = 9FBFE63A292A715500C250E9 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
|
@ -185,9 +218,12 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9F398AB329360A4C00A889F2 /* TimelineTabView.swift in Sources */,
|
||||
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */,
|
||||
9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */,
|
||||
9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */,
|
||||
9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */,
|
||||
9FBFE63D292A715500C250E9 /* IceCubesAppApp.swift in Sources */,
|
||||
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,
|
||||
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -316,6 +352,7 @@
|
|||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = IceCubesApp/Info.plist;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
|
@ -354,6 +391,7 @@
|
|||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = IceCubesApp/Info.plist;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
|
@ -402,6 +440,17 @@
|
|||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/evgenyneu/keychain-swift";
|
||||
requirement = {
|
||||
branch = master;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
9F24EEBA293619210042359D /* Routeur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
|
@ -419,6 +468,11 @@
|
|||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Models;
|
||||
};
|
||||
9FAE4ACD29379A5A00772766 /* KeychainSwift */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */;
|
||||
productName = KeychainSwift;
|
||||
};
|
||||
9FBFE64D292A72BD00C250E9 /* Network */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Network;
|
||||
|
|
|
@ -8,6 +8,15 @@
|
|||
"revision" : "00d7a9744bbd1e7762c587bbd248775e16345a65",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "keychain-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/evgenyneu/keychain-swift",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "32a99b537d1c6f3529a08257c28a5feb70c0c5af"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import SwiftUI
|
||||
import Timeline
|
||||
import Network
|
||||
import KeychainSwift
|
||||
import Models
|
||||
|
||||
struct AppAccount: Codable {
|
||||
let server: String
|
||||
let oauthToken: OauthToken?
|
||||
|
||||
var key: String {
|
||||
if let oauthToken {
|
||||
return "\(server):\(oauthToken.createdAt)"
|
||||
} else {
|
||||
return "\(server):anonymous:\(Date().timeIntervalSince1970)"
|
||||
}
|
||||
}
|
||||
|
||||
func save() throws {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(self)
|
||||
let keychain = KeychainSwift()
|
||||
keychain.set(data, forKey: key)
|
||||
}
|
||||
|
||||
func delete() {
|
||||
KeychainSwift().delete(key)
|
||||
}
|
||||
|
||||
static func retrieveAll() throws -> [AppAccount] {
|
||||
let keychain = KeychainSwift()
|
||||
let decoder = JSONDecoder()
|
||||
let keys = keychain.allKeys
|
||||
var accounts: [AppAccount] = []
|
||||
for key in keys {
|
||||
if let data = keychain.getData(key) {
|
||||
let account = try decoder.decode(AppAccount.self, from: data)
|
||||
accounts.append(account)
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
static func deleteAll() {
|
||||
let keychain = KeychainSwift()
|
||||
let keys = keychain.allKeys
|
||||
for key in keys {
|
||||
keychain.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import SwiftUI
|
||||
import Network
|
||||
|
||||
class AppAccountsManager: ObservableObject {
|
||||
@Published var currentAccount: AppAccount {
|
||||
didSet {
|
||||
currentClient = .init(server: currentAccount.server,
|
||||
oauthToken: currentAccount.oauthToken)
|
||||
}
|
||||
}
|
||||
@Published var currentClient: Client
|
||||
|
||||
init() {
|
||||
var defaultAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil)
|
||||
do {
|
||||
let keychainAccounts = try AppAccount.retrieveAll()
|
||||
defaultAccount = keychainAccounts.first ?? defaultAccount
|
||||
} catch {}
|
||||
currentAccount = defaultAccount
|
||||
currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken)
|
||||
}
|
||||
|
||||
func add(account: AppAccount) {
|
||||
do {
|
||||
try account.save()
|
||||
currentAccount = account
|
||||
} catch { }
|
||||
}
|
||||
|
||||
func delete(account: AppAccount) {
|
||||
account.delete()
|
||||
currentAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import SwiftUI
|
||||
import Timeline
|
||||
import Network
|
||||
import KeychainSwift
|
||||
|
||||
@main
|
||||
struct IceCubesApp: App {
|
||||
public static let defaultServer = "mastodon.world"
|
||||
|
||||
@StateObject private var appAccountsManager = AppAccountsManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
TabView {
|
||||
TimelineTab()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "globe")
|
||||
}
|
||||
SettingsTabs()
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
.environmentObject(appAccountsManager)
|
||||
.environmentObject(appAccountsManager.currentClient)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import SwiftUI
|
||||
import Timeline
|
||||
import Network
|
||||
|
||||
@main
|
||||
struct IceCubesAppApp: App {
|
||||
@State private var tabs: [String] = ["mastodon.social"]
|
||||
@State private var isServerSelectDisplayed: Bool = false
|
||||
@State private var newServerURL: String = ""
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
TabView {
|
||||
ForEach(tabs, id: \.self) { tab in
|
||||
TimelineTabView(tab: tab)
|
||||
.tabItem {
|
||||
Label(tab, systemImage: "globe")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
isServerSelectDisplayed.toggle()
|
||||
} label: {
|
||||
Image(systemName: "globe")
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Connect to another server", isPresented: $isServerSelectDisplayed) {
|
||||
TextField(tab, text: $newServerURL)
|
||||
Button("Connect", action: {
|
||||
tabs.append(newServerURL)
|
||||
newServerURL = ""
|
||||
})
|
||||
Button("Cancel", role: .cancel, action: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
import SwiftUI
|
||||
import Timeline
|
||||
import Routeur
|
||||
import Network
|
||||
import Account
|
||||
import Models
|
||||
|
||||
struct SettingsTabs: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
@EnvironmentObject private var client: Client
|
||||
@EnvironmentObject private var appAccountsManager: AppAccountsManager
|
||||
|
||||
@State private var signInInProgress = false
|
||||
@State private var accountData: Account?
|
||||
@State private var signInServer = IceCubesApp.defaultServer
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Account") {
|
||||
if let accountData {
|
||||
VStack(alignment: .leading) {
|
||||
Text(appAccountsManager.currentAccount.server)
|
||||
.font(.headline)
|
||||
Text(accountData.displayName)
|
||||
Text(accountData.username)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
signOutButton
|
||||
} else {
|
||||
TextField("Mastodon server", text: $signInServer)
|
||||
signInButton
|
||||
}
|
||||
}
|
||||
}
|
||||
.onOpenURL(perform: { url in
|
||||
Task {
|
||||
await continueSignIn(url: url)
|
||||
}
|
||||
})
|
||||
.navigationTitle(Text("Settings"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.task {
|
||||
if appAccountsManager.currentAccount.oauthToken != nil {
|
||||
signInInProgress = true
|
||||
await refreshAccountInfo()
|
||||
signInInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var signInButton: some View {
|
||||
Button {
|
||||
signInInProgress = true
|
||||
Task {
|
||||
await signIn()
|
||||
}
|
||||
} label: {
|
||||
if signInInProgress {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Sign in")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var signOutButton: some View {
|
||||
Button {
|
||||
accountData = nil
|
||||
appAccountsManager.delete(account: appAccountsManager.currentAccount)
|
||||
} 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 refreshAccountInfo()
|
||||
signInInProgress = false
|
||||
} catch {
|
||||
signInInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAccountInfo() async {
|
||||
accountData = try? await client.get(endpoint: Accounts.verifyCredentials)
|
||||
}
|
||||
}
|
|
@ -3,23 +3,14 @@ import Timeline
|
|||
import Routeur
|
||||
import Network
|
||||
|
||||
struct TimelineTabView: View {
|
||||
let tab: String
|
||||
|
||||
private let client: Client
|
||||
struct TimelineTab: View {
|
||||
@StateObject private var routeurPath = RouterPath()
|
||||
|
||||
init(tab: String) {
|
||||
self.tab = tab
|
||||
self.client = .init(server: tab)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $routeurPath.path) {
|
||||
TimelineView()
|
||||
.withAppRouteur()
|
||||
}
|
||||
.environmentObject(routeurPath)
|
||||
.environmentObject(client)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>icecubesapp</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>icecubesapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
|
@ -8,7 +8,7 @@ class AccountDetailViewModel: ObservableObject {
|
|||
var client: Client = .init(server: "")
|
||||
|
||||
enum State {
|
||||
case loading, data(account: Models.Account), error(error: Error)
|
||||
case loading, data(account: Account), error(error: Error)
|
||||
}
|
||||
|
||||
@Published var state: State = .loading
|
||||
|
@ -19,7 +19,7 @@ class AccountDetailViewModel: ObservableObject {
|
|||
|
||||
func fetchAccount() async {
|
||||
do {
|
||||
state = .data(account: try await client.fetch(endpoint: Network.Account.accounts(id: accountId)))
|
||||
state = .data(account: try await client.get(endpoint: Accounts.accounts(id: accountId)))
|
||||
} catch {
|
||||
state = .error(error: error)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import Foundation
|
||||
|
||||
public struct App: Codable, Identifiable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let website: URL?
|
||||
public let redirectUri: String
|
||||
public let clientId: String
|
||||
public let clientSecret: String
|
||||
public let vapidKey: String
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import Foundation
|
||||
|
||||
public struct OauthToken: Codable {
|
||||
public let accessToken: String
|
||||
public let tokenType: String
|
||||
public let scope: String
|
||||
public let createdAt: Double
|
||||
}
|
|
@ -13,11 +13,15 @@ let package = Package(
|
|||
name: "Network",
|
||||
targets: ["Network"]),
|
||||
],
|
||||
dependencies: [],
|
||||
dependencies: [
|
||||
.package(name: "Models", path: "../Models"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Network",
|
||||
dependencies: []),
|
||||
dependencies: [
|
||||
.product(name: "Models", package: "Models")
|
||||
]),
|
||||
.testTarget(
|
||||
name: "NetworkTests",
|
||||
dependencies: ["Network"]),
|
||||
|
|
|
@ -1,34 +1,102 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import Models
|
||||
|
||||
public class Client: ObservableObject {
|
||||
public enum Version: String {
|
||||
case v1
|
||||
}
|
||||
|
||||
public let server: String
|
||||
public enum OauthError: Error {
|
||||
case missingApp
|
||||
case invalidRedirectURL
|
||||
}
|
||||
|
||||
public var server: String
|
||||
public let version: Version
|
||||
private let urlSession: URLSession
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
public init(server: String, version: Version = .v1) {
|
||||
/// Only used as a transitionary app while in the oauth flow.
|
||||
private var oauthApp: Models.App?
|
||||
|
||||
private var oauthToken: OauthToken?
|
||||
|
||||
public var isAuth: Bool {
|
||||
oauthToken != nil
|
||||
}
|
||||
|
||||
public init(server: String, version: Version = .v1, oauthToken: OauthToken? = nil) {
|
||||
self.server = server
|
||||
self.version = version
|
||||
self.urlSession = URLSession.shared
|
||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
self.oauthToken = oauthToken
|
||||
}
|
||||
|
||||
private func makeURL(endpoint: Endpoint) -> URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = "https"
|
||||
components.host = server
|
||||
components.path += "/api/\(version.rawValue)/\(endpoint.path())"
|
||||
if type(of: endpoint) == Oauth.self {
|
||||
components.path += "/\(endpoint.path())"
|
||||
} else {
|
||||
components.path += "/api/\(version.rawValue)/\(endpoint.path())"
|
||||
}
|
||||
components.queryItems = endpoint.queryItems()
|
||||
return components.url!
|
||||
}
|
||||
|
||||
private func makeURLRequest(url: URL, httpMethod: String) -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = httpMethod
|
||||
if let oauthToken {
|
||||
request.setValue("Bearer \(oauthToken.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
public func fetch<Entity: Codable>(endpoint: Endpoint) async throws -> Entity {
|
||||
let (data, _) = try await urlSession.data(from: makeURL(endpoint: endpoint))
|
||||
public func get<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
|
||||
let url = makeURL(endpoint: endpoint)
|
||||
let request = makeURLRequest(url: url, httpMethod: "GET")
|
||||
let (data, httpResponse) = try await urlSession.data(for: request)
|
||||
logResponseOnError(httpResponse: httpResponse, data: data)
|
||||
return try decoder.decode(Entity.self, from: data)
|
||||
}
|
||||
|
||||
public func post<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
|
||||
let url = makeURL(endpoint: endpoint)
|
||||
let request = makeURLRequest(url: url, httpMethod: "POST")
|
||||
let (data, httpResponse) = try await urlSession.data(for: request)
|
||||
logResponseOnError(httpResponse: httpResponse, data: data)
|
||||
return try decoder.decode(Entity.self, from: data)
|
||||
}
|
||||
|
||||
public func oauthURL() async throws -> URL {
|
||||
let app: Models.App = try await post(endpoint: Apps.registerApp)
|
||||
self.oauthApp = app
|
||||
return makeURL(endpoint: Oauth.authorize(clientId: app.clientId))
|
||||
}
|
||||
|
||||
public func continueOauthFlow(url: URL) async throws -> OauthToken {
|
||||
guard let app = oauthApp else {
|
||||
throw OauthError.missingApp
|
||||
}
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let code = components.queryItems?.first(where: { $0.name == "code"})?.value else {
|
||||
throw OauthError.invalidRedirectURL
|
||||
}
|
||||
let token: OauthToken = try await post(endpoint: Oauth.token(code: code,
|
||||
clientId: app.clientId,
|
||||
clientSecret: app.clientSecret))
|
||||
self.oauthToken = token
|
||||
return token
|
||||
}
|
||||
|
||||
private func logResponseOnError(httpResponse: URLResponse, data: Data) {
|
||||
if let httpResponse = httpResponse as? HTTPURLResponse, httpResponse.statusCode > 299 {
|
||||
print(httpResponse)
|
||||
print(String(data: data, encoding: .utf8) ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
public enum Account: Endpoint {
|
||||
public enum Accounts: Endpoint {
|
||||
case accounts(id: String)
|
||||
case verifyCredentials
|
||||
|
||||
public func path() -> String {
|
||||
switch self {
|
||||
case .accounts(let id):
|
||||
return "accounts/\(id)"
|
||||
case .verifyCredentials:
|
||||
return "accounts/verify_credentials"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import Foundation
|
||||
|
||||
public enum Apps: Endpoint {
|
||||
case registerApp
|
||||
|
||||
public func path() -> String {
|
||||
switch self {
|
||||
case .registerApp:
|
||||
return "apps"
|
||||
}
|
||||
}
|
||||
|
||||
public func queryItems() -> [URLQueryItem]? {
|
||||
switch self {
|
||||
case .registerApp:
|
||||
return [
|
||||
.init(name: "client_name", value: "IceCubesApp"),
|
||||
.init(name: "redirect_uris", value: "icecubesapp://"),
|
||||
.init(name: "scopes", value: "read write follow push"),
|
||||
.init(name: "website", value: "https://github.com/Dimillian/IceCubesApp")
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import Foundation
|
||||
|
||||
public enum Oauth: Endpoint {
|
||||
case authorize(clientId: String)
|
||||
case token(code: String, clientId: String, clientSecret: String)
|
||||
|
||||
public func path() -> String {
|
||||
switch self {
|
||||
case .authorize:
|
||||
return "oauth/authorize"
|
||||
case .token:
|
||||
return "oauth/token"
|
||||
}
|
||||
}
|
||||
|
||||
public func queryItems() -> [URLQueryItem]? {
|
||||
switch self {
|
||||
case let .authorize(clientId):
|
||||
return [
|
||||
.init(name: "response_type", value: "code"),
|
||||
.init(name: "client_id", value: clientId),
|
||||
.init(name: "redirect_uri", value: "icecubesapp://"),
|
||||
.init(name: "scope", value: "read write follow push")
|
||||
]
|
||||
case let .token(code, clientId, clientSecret):
|
||||
return [
|
||||
.init(name: "grant_type", value: "authorization_code"),
|
||||
.init(name: "client_id", value: clientId),
|
||||
.init(name: "client_secret", value: clientSecret),
|
||||
.init(name: "redirect_uri", value: "icecubesapp://"),
|
||||
.init(name: "code", value: code),
|
||||
.init(name: "scope", value: "read write follow push")
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,12 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
public enum Timeline: Endpoint {
|
||||
public enum Timelines: Endpoint {
|
||||
case pub(sinceId: String?)
|
||||
case home(sinceId: String?)
|
||||
|
||||
public func path() -> String {
|
||||
switch self {
|
||||
case .pub:
|
||||
return "timelines/public"
|
||||
case .home:
|
||||
return "timelines/home"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,6 +17,8 @@ public enum Timeline: Endpoint {
|
|||
switch self {
|
||||
case .pub(let sinceId):
|
||||
return [.init(name: "max_id", value: sinceId)]
|
||||
case .home(let sinceId):
|
||||
return [.init(name: "max_id", value: sinceId)]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,8 +33,13 @@ public struct TimelineView: View {
|
|||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Public Timeline: \(viewModel.serverName)")
|
||||
.navigationTitle("\(viewModel.serverName)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
timelineFilterButton
|
||||
}
|
||||
}
|
||||
.task {
|
||||
viewModel.client = client
|
||||
if !didAppear {
|
||||
|
@ -54,4 +59,19 @@ public struct TimelineView: View {
|
|||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var timelineFilterButton: some View {
|
||||
Menu {
|
||||
ForEach(TimelineViewModel.TimelineFilter.allCases, id: \.self) { filter in
|
||||
Button {
|
||||
viewModel.timeline = filter
|
||||
} label: {
|
||||
Text(filter.rawValue)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,10 +13,36 @@ class TimelineViewModel: ObservableObject {
|
|||
case error(error: Error)
|
||||
}
|
||||
|
||||
var client: Client = .init(server: "")
|
||||
enum TimelineFilter: String, CaseIterable {
|
||||
case pub = "Public"
|
||||
case home = "Home"
|
||||
|
||||
func endpoint(sinceId: String?) -> Timelines {
|
||||
switch self {
|
||||
case .pub: return .pub(sinceId: sinceId)
|
||||
case .home: return .home(sinceId: sinceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var client: Client = .init(server: "") {
|
||||
didSet {
|
||||
timeline = client.isAuth ? .home : .pub
|
||||
}
|
||||
}
|
||||
|
||||
private var statuses: [Status] = []
|
||||
|
||||
@Published var state: State = .loading
|
||||
@Published var timeline: TimelineFilter = .pub {
|
||||
didSet {
|
||||
if oldValue != timeline {
|
||||
Task {
|
||||
await refreshTimeline()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var serverName: String {
|
||||
client.server
|
||||
|
@ -24,7 +50,8 @@ class TimelineViewModel: ObservableObject {
|
|||
|
||||
func refreshTimeline() async {
|
||||
do {
|
||||
statuses = try await client.fetch(endpoint: Timeline.pub(sinceId: nil))
|
||||
state = .loading
|
||||
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil))
|
||||
state = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||
} catch {
|
||||
state = .error(error: error)
|
||||
|
@ -35,7 +62,7 @@ class TimelineViewModel: ObservableObject {
|
|||
do {
|
||||
guard let lastId = statuses.last?.id else { return }
|
||||
state = .display(statuses: statuses, nextPageState: .loadingNextPage)
|
||||
let newStatuses: [Status] = try await client.fetch(endpoint: Timeline.pub(sinceId: lastId))
|
||||
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: lastId))
|
||||
statuses.append(contentsOf: newStatuses)
|
||||
state = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||
} catch {
|
||||
|
|
Loading…
Reference in New Issue