From 726b3884608de32fb4d4c40d9665e82c5c17d3ef Mon Sep 17 00:00:00 2001 From: cirtron <45784494+lcandy2@users.noreply.github.com> Date: Mon, 6 Jan 2025 01:13:02 +0800 Subject: [PATCH] feat: re Feat/login (#4) * feat: basic login * feat: successful login * feat: save credentials with keychain-swift * feat: adjust log * feat: switch instances * feat: lowercase server address * fix: non-neodb.social instance cannot login * feat: user api * feat: make llm knows keychain-swift is already added * feat: custom ContentUnavailableView * feat: profile view * feat: feat inject * feat: new profile view design * feat: injection * feat: new profile design * feat: injection next * feat: redesigned profile * refactor: replace @StateObject with @EnvironmentObject for AuthService in ContentView, LoginView, and NeoDBApp * feat: implement user caching and refresh functionality in UserService and ProfileViewModel - Added caching mechanism for user data in UserService to improve performance. - Updated getCurrentUser method to accept a forceRefresh parameter for optional cache bypass. - Implemented clearCache method to allow cache clearing on logout. - Modified loadUserProfile method in ProfileViewModel to support force refresh. - Enhanced ProfileView to show loading indicators and support pull-to-refresh for user profile loading. * refactor: enhance ProfileView layout and loading state handling - Introduced a new private variable for avatar size to standardize avatar dimensions. - Refactored profile content display logic to improve readability and maintainability. - Added a placeholder for the avatar while loading user data. - Updated loading indicators and error handling for a better user experience. - Ensured the logout button is disabled when no user is present. * chore: remove outdated Project Structure documentation and update logout button in ProfileView - Deleted the Project Structure.md file as it was no longer relevant. - Updated the logout button in ProfileView to use a text label instead of an icon, enhancing clarity for users. --- NeoDB/NeoDB.xcodeproj/project.pbxproj | 46 ++ .../xcshareddata/swiftpm/Package.resolved | 20 +- NeoDB/NeoDB/ContentView.swift | 47 +- NeoDB/NeoDB/NeoDBApp.swift | 94 +++- .../References/Cursor/auth_and_profile.md | 159 ++++++ NeoDB/NeoDB/References/Mastodon/timelines.md | 506 ++++++++++++++++++ .../NeoDB/References/NeoDB/openapi/user.yaml | 69 +++ .../References/Packages/keychain-swift.md | 2 + .../References/Standards/Architecture.md | 63 +++ .../Project Structure.md | 0 NeoDB/NeoDB/Services/Config/AppConfig.swift | 13 + NeoDB/NeoDB/Services/Models/User.swift | 17 + .../NeoDB/Services/Network/AuthService.swift | 384 +++++++++++++ .../NeoDB/Services/Network/UserService.swift | 66 +++ NeoDB/NeoDB/Views/Auth/LoginView.swift | 173 +++++- NeoDB/NeoDB/Views/Profile/ProfileView.swift | 185 +++++++ NeoDB/NeoDB/Views/Shared/EmptyStateView.swift | 40 ++ 17 files changed, 1870 insertions(+), 14 deletions(-) create mode 100644 NeoDB/NeoDB/References/Cursor/auth_and_profile.md create mode 100644 NeoDB/NeoDB/References/Mastodon/timelines.md create mode 100644 NeoDB/NeoDB/References/NeoDB/openapi/user.yaml create mode 100644 NeoDB/NeoDB/References/Standards/Architecture.md rename NeoDB/NeoDB/References/Standards/{Project Structure => }/Project Structure.md (100%) create mode 100644 NeoDB/NeoDB/Services/Config/AppConfig.swift create mode 100644 NeoDB/NeoDB/Services/Models/User.swift create mode 100644 NeoDB/NeoDB/Services/Network/AuthService.swift create mode 100644 NeoDB/NeoDB/Services/Network/UserService.swift create mode 100644 NeoDB/NeoDB/Views/Profile/ProfileView.swift create mode 100644 NeoDB/NeoDB/Views/Shared/EmptyStateView.swift diff --git a/NeoDB/NeoDB.xcodeproj/project.pbxproj b/NeoDB/NeoDB.xcodeproj/project.pbxproj index 61f3176..7fe1246 100644 --- a/NeoDB/NeoDB.xcodeproj/project.pbxproj +++ b/NeoDB/NeoDB.xcodeproj/project.pbxproj @@ -7,7 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 242688402D2A9E0D00DFAAC1 /* InjectionNext in Frameworks */ = {isa = PBXBuildFile; productRef = 2426883F2D2A9E0D00DFAAC1 /* InjectionNext */; }; 245FD2132D2A817A005B55B3 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 245FD2122D2A817A005B55B3 /* KeychainSwift */; }; + 24D1BE412D2A9F340063B530 /* Inject in Frameworks */ = {isa = PBXBuildFile; productRef = 24D1BE402D2A9F340063B530 /* Inject */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -70,6 +72,8 @@ buildActionMask = 2147483647; files = ( 245FD2132D2A817A005B55B3 /* KeychainSwift in Frameworks */, + 242688402D2A9E0D00DFAAC1 /* InjectionNext in Frameworks */, + 24D1BE412D2A9F340063B530 /* Inject in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,12 +94,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 245FD27F2D2A9AF0005B55B3 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; 24CEACE62D0DA6200083D4BA = { isa = PBXGroup; children = ( 24CEACF12D0DA6200083D4BA /* NeoDB */, 24CEAD032D0DA6210083D4BA /* NeoDBTests */, 24CEAD0D2D0DA6210083D4BA /* NeoDBUITests */, + 245FD27F2D2A9AF0005B55B3 /* Frameworks */, 24CEACF02D0DA6200083D4BA /* Products */, ); sourceTree = ""; @@ -131,6 +143,8 @@ name = NeoDB; packageProductDependencies = ( 245FD2122D2A817A005B55B3 /* KeychainSwift */, + 2426883F2D2A9E0D00DFAAC1 /* InjectionNext */, + 24D1BE402D2A9F340063B530 /* Inject */, ); productName = NeoDB; productReference = 24CEACEF2D0DA6200083D4BA /* NeoDB.app */; @@ -216,6 +230,8 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 245FD2112D2A817A005B55B3 /* XCRemoteSwiftPackageReference "keychain-swift" */, + 2415DF2F2D2A9DCC00DAC07F /* XCRemoteSwiftPackageReference "InjectionNext" */, + 24AFFC8E2D2A9F040085A6D0 /* XCRemoteSwiftPackageReference "Inject" */, ); preferredProjectObjectVersion = 77; productRefGroup = 24CEACF02D0DA6200083D4BA /* Products */; @@ -432,6 +448,10 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 12; MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-Xlinker", + "-interposable", + ); PRODUCT_BUNDLE_IDENTIFIER = app.neodb; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -611,6 +631,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 2415DF2F2D2A9DCC00DAC07F /* XCRemoteSwiftPackageReference "InjectionNext" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/johnno1962/InjectionNext"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.6; + }; + }; 245FD2112D2A817A005B55B3 /* XCRemoteSwiftPackageReference "keychain-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/evgenyneu/keychain-swift.git"; @@ -619,14 +647,32 @@ minimumVersion = 24.0.0; }; }; + 24AFFC8E2D2A9F040085A6D0 /* XCRemoteSwiftPackageReference "Inject" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/krzysztofzablocki/Inject.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.2; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 2426883F2D2A9E0D00DFAAC1 /* InjectionNext */ = { + isa = XCSwiftPackageProductDependency; + package = 2415DF2F2D2A9DCC00DAC07F /* XCRemoteSwiftPackageReference "InjectionNext" */; + productName = InjectionNext; + }; 245FD2122D2A817A005B55B3 /* KeychainSwift */ = { isa = XCSwiftPackageProductDependency; package = 245FD2112D2A817A005B55B3 /* XCRemoteSwiftPackageReference "keychain-swift" */; productName = KeychainSwift; }; + 24D1BE402D2A9F340063B530 /* Inject */ = { + isa = XCSwiftPackageProductDependency; + package = 24AFFC8E2D2A9F040085A6D0 /* XCRemoteSwiftPackageReference "Inject" */; + productName = Inject; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 24CEACE72D0DA6200083D4BA /* Project object */; diff --git a/NeoDB/NeoDB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NeoDB/NeoDB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index df5a4e8..de23583 100644 --- a/NeoDB/NeoDB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NeoDB/NeoDB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "61d0f8bddcbf3b93ae7ce1d42c4f6929e95bfcb66319ddeb36d0dda55337047e", + "originHash" : "657466b00a4cf16eda52a23bd963af27f6da71d69047ed11c12b2a367c93a6ba", "pins" : [ + { + "identity" : "inject", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzysztofzablocki/Inject.git", + "state" : { + "revision" : "728c56639ecb3df441d51d5bc6747329afabcfc9", + "version" : "1.5.2" + } + }, + { + "identity" : "injectionnext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnno1962/InjectionNext", + "state" : { + "revision" : "dc3474dc65e0a8d6661932733950810bfca180a9", + "version" : "1.2.6" + } + }, { "identity" : "keychain-swift", "kind" : "remoteSourceControl", diff --git a/NeoDB/NeoDB/ContentView.swift b/NeoDB/NeoDB/ContentView.swift index 8dffa5d..25c07fb 100644 --- a/NeoDB/NeoDB/ContentView.swift +++ b/NeoDB/NeoDB/ContentView.swift @@ -8,17 +8,52 @@ import SwiftUI struct ContentView: View { + @EnvironmentObject var authService: AuthService + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + TabView { + NavigationStack { + Text("Home Feed") + .navigationTitle("Home") + } + .tabItem { + Label("Home", systemImage: "house.fill") + } + + NavigationStack { + Text("Search") + .navigationTitle("Search") + } + .tabItem { + Label("Search", systemImage: "magnifyingglass") + } + + NavigationStack { + Text("Library") + .navigationTitle("Library") + } + .tabItem { + Label("Library", systemImage: "books.vertical.fill") + } + + + NavigationStack { + ProfileView(authService: authService) + } + .tabItem { + Label("Profile", systemImage: "person.fill") + } } - .padding() + .tint(.accentColor) + .enableInjection() } + + #if DEBUG + @ObserveInjection var forceRedraw + #endif } #Preview { ContentView() + .environmentObject(AuthService()) } diff --git a/NeoDB/NeoDB/NeoDBApp.swift b/NeoDB/NeoDB/NeoDBApp.swift index ff57f36..6be666a 100644 --- a/NeoDB/NeoDB/NeoDBApp.swift +++ b/NeoDB/NeoDB/NeoDBApp.swift @@ -9,9 +9,101 @@ import SwiftUI @main struct NeoDBApp: App { + @StateObject private var authService = AuthService() + var body: some Scene { WindowGroup { - ContentView() + Group { + if authService.isAuthenticated { + ContentView() + .environmentObject(authService) + } else { + LoginView() + .environmentObject(authService) + } + } + .onOpenURL { url in + Task { + do { + try await authService.handleCallback(url: url) + } catch { + print("Authentication error: \(error)") + } + } + } } } } + +#if canImport(HotSwiftUI) +@_exported import HotSwiftUI +#elseif canImport(Inject) +@_exported import Inject +#else +// This code can be found in the Swift package: +// https://github.com/johnno1962/HotSwiftUI or +// https://github.com/krzysztofzablocki/Inject + +#if DEBUG +import Combine + +public class InjectionObserver: ObservableObject { + public static let shared = InjectionObserver() + @Published var injectionNumber = 0 + var cancellable: AnyCancellable? = nil + let publisher = PassthroughSubject() + init() { + cancellable = NotificationCenter.default.publisher(for: + Notification.Name("INJECTION_BUNDLE_NOTIFICATION")) + .sink { [weak self] change in + self?.injectionNumber += 1 + self?.publisher.send() + } + } +} + +extension SwiftUI.View { + public func eraseToAnyView() -> some SwiftUI.View { + return AnyView(self) + } + public func enableInjection() -> some SwiftUI.View { + return eraseToAnyView() + } + public func onInjection(bumpState: @escaping () -> ()) -> some SwiftUI.View { + return self + .onReceive(InjectionObserver.shared.publisher, perform: bumpState) + .eraseToAnyView() + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +@propertyWrapper +public struct ObserveInjection: DynamicProperty { + @ObservedObject private var iO = InjectionObserver.shared + public init() {} + public private(set) var wrappedValue: Int { + get {0} set {} + } +} +#else +extension SwiftUI.View { + @inline(__always) + public func eraseToAnyView() -> some SwiftUI.View { return self } + @inline(__always) + public func enableInjection() -> some SwiftUI.View { return self } + @inline(__always) + public func onInjection(bumpState: @escaping () -> ()) -> some SwiftUI.View { + return self + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +@propertyWrapper +public struct ObserveInjection { + public init() {} + public private(set) var wrappedValue: Int { + get {0} set {} + } +} +#endif +#endif diff --git a/NeoDB/NeoDB/References/Cursor/auth_and_profile.md b/NeoDB/NeoDB/References/Cursor/auth_and_profile.md new file mode 100644 index 0000000..9d78863 --- /dev/null +++ b/NeoDB/NeoDB/References/Cursor/auth_and_profile.md @@ -0,0 +1,159 @@ +# Authentication & Profile Implementation + +## Architecture + +### Data Flow +```mermaid +graph TD + A[AuthService] --> B[UserService] + B --> C[ProfileViewModel] + C --> D[ProfileView] + A --> D +``` + +### State Management +```swift +// App-level auth state +@Published var isAuthenticated: Bool +@Published var accessToken: String? + +// Profile-level state +@Published var user: User? +@Published var isLoading: Bool +``` + +## Key Components + +### AuthService +- Handles OAuth flow with NeoDB instances +- Manages client registration per instance +- Stores credentials in Keychain +- Supports multiple instances + +```swift +class AuthService { + // Instance management + var currentInstance: String + func switchInstance(_ newInstance: String) throws + + // OAuth flow + func registerApp() async throws + func handleCallback(url: URL) async throws + private func exchangeCodeForToken(code: String) async throws +} +``` + +### UserService +- Handles user data fetching +- Implements caching strategy +- Instance-aware caching + +```swift +class UserService { + func getCurrentUser(forceRefresh: Bool = false) async throws -> User + func clearCache() +} +``` + +### Cache Strategy +```swift +// Cache key format +"\(instance)_cached_user" + +// Cache invalidation +- On logout +- On instance switch +- On force refresh +- On 401 errors +``` + +## UI Implementation + +### Loading States +1. Initial load + - Show skeleton UI + - Placeholder avatar + - Redacted text + - Background loading indicator + +2. Refresh + - Keep existing content + - Show loading overlay + - Support pull-to-refresh + +### Avatar Handling +```swift +private let avatarSize: CGFloat = 60 + +// Size ratios +avatar: 1.0 +placeholder icon: 0.5 +error icon: 0.8 +``` + +## Error Handling + +### Error Types +```swift +enum AuthError { + case invalidURL + case networkError(Error) + case invalidResponse + case unauthorized + case registrationFailed(String) + case tokenExchangeFailed(String) + case invalidInstance + case noClientCredentials +} +``` + +### Error UI +- EmptyStateView for errors +- Placeholder content during loading +- Graceful degradation + +## Security Considerations + +### Credential Storage +- Use Keychain for sensitive data +- Instance-specific storage +- Clear on logout + +### OAuth Implementation +- State parameter for CSRF protection +- Instance validation +- Token refresh handling (TODO) + +## Future Improvements + +### TODO +- [ ] Token refresh mechanism +- [ ] Offline support +- [ ] Background sync +- [ ] Rate limiting +- [ ] Error retry mechanism + +### Performance Optimizations +- Preload avatar images +- Cache size limits +- Background data prefetch + +## API Endpoints + +### OAuth +``` +POST /api/v1/apps +POST /oauth/token +``` + +### User Data +``` +GET /api/me +Response: { + url: string + external_acct: string? + display_name: string + avatar: string + username: string +} +``` \ No newline at end of file diff --git a/NeoDB/NeoDB/References/Mastodon/timelines.md b/NeoDB/NeoDB/References/Mastodon/timelines.md new file mode 100644 index 0000000..9cbc08b --- /dev/null +++ b/NeoDB/NeoDB/References/Mastodon/timelines.md @@ -0,0 +1,506 @@ +--- +title: timelines API methods +description: Read and view timelines of statuses. +menu: + docs: + weight: 40 + name: timelines + parent: methods + identifier: methods-timelines +aliases: [ + "/methods/timelines", + "/api/methods/timelines", +] +--- + + + +## View public timeline {#public} + +```http +GET /api/v1/timelines/public HTTP/1.1 +``` + +View public statuses. + +**Returns:** Array of [Status]({{}})\ +**OAuth:** Public. Requires app token + `read:statuses` if the instance has disabled public preview.\ +**Version history:**\ +0.0.0 - added\ +2.3.0 - added `only_media`\ +2.6.0 - add `min_id`\ +3.0.0 - auth is required if public preview is disabled\ +3.1.4 - added `remote`\ +3.3.0 - both `min_id` and `max_id` can be used at the same time now + +#### Request + +##### Headers + +Authorization +: Provide this header with `Bearer ` to gain authorized access to this API method. + +##### Query parameters + +local +: Boolean. Show only local statuses? Defaults to false. + +remote +: Boolean. Show only remote statuses? Defaults to false. + +only_media +: Boolean. Show only statuses with media attached? Defaults to false. + +max_id +: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. + +since_id +: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. + +min_id +: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. + +limit +: Integer. Maximum number of results to return. Defaults to 20 statuses. Max 40 statuses. + +#### Response +##### 200: OK + +Sample API call with limit=2 + +```json +[ + { + "id": "103206804533200177", + "created_at": "2019-11-26T23:27:31.000Z", + // ... + "visibility": "public", + // ... + }, + { + "id": "103206804086086361", + "created_at": "2019-11-26T23:27:32.000Z", + // ... + "visibility": "public", + // ... + } +] +``` + +--- + +## View hashtag timeline {#tag} + +```http +GET /api/v1/timelines/tag/:hashtag HTTP/1.1 +``` + +View public statuses containing the given hashtag. + +**Returns:** Array of [Status]({{}})\ +**OAuth:** Public. Requires app token + `read:statuses` if the instance has disabled public preview.\ +**Version history:**\ +0.0.0 - added\ +2.3.0 - added `only_media`\ +2.6.0 - add `min_id`\ +2.7.0 - add `any[]`, `all[]`, `none[]` for additional tags\ +3.0.0 - auth is required if public preview is disabled\ +3.3.0 - both `min_id` and `max_id` can be used at the same time now. add `remote` + +#### Request + +##### Path parameters + +:hashtag +: {{}} String. The name of the hashtag (not including the # symbol). + +##### Headers + +Authorization +: Provide this header with `Bearer ` to gain authorized access to this API method. + +##### Query parameters + +any[] +: Array of String. Return statuses that contain any of these additional tags. + +all[] +: Array of String. Return statuses that contain all of these additional tags. + +none[] +: Array of String. Return statuses that contain none of these additional tags. + +local +: Boolean. Return only local statuses? Defaults to false. + +remote +: Boolean. Return only remote statuses? Defaults to false. + +only_media +: Boolean. Return only statuses with media attachments? Defaults to false. + +max_id +: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. + +since_id +: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. + +min_id +: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. + +limit +: Integer. Maximum number of results to return. Defaults to 20 statuses. Max 40 statuses. + +#### Response +##### 200: OK + +Sample timeline for the hashtag #cats and limit=2 + +```json +[ + { + "id": "103206185588894565", + "created_at": "2019-11-26T20:50:15.866Z", + // ... + "visibility": "public", + // ... + "content": "

#cats

", + // ... + "tags": [ + { + "name": "cats", + "url": "https://mastodon.social/tags/cats" + } + ], + // ... + }, + { + "id": "103203659567597966", + "created_at": "2019-11-26T10:07:49.000Z", + // ... + "visibility": "public", + // ... + "content": "

Caught on the hop. đŸ˜ș

#QualitÀtskatzen #cats #mastocats #catsofmastodon #Greece #Agistri
(photo: @kernpanik | license: CC BY-NC-SA 4.0)

", + // ... + "tags": [ + { + "name": "qualitÀtskatzen", + "url": "https://mastodon.social/tags/qualit%C3%A4tskatzen" + }, + { + "name": "cats", + "url": "https://mastodon.social/tags/cats" + }, + { + "name": "mastocats", + "url": "https://mastodon.social/tags/mastocats" + }, + { + "name": "catsofmastodon", + "url": "https://mastodon.social/tags/catsofmastodon" + }, + { + "name": "greece", + "url": "https://mastodon.social/tags/greece" + }, + { + "name": "agistri", + "url": "https://mastodon.social/tags/agistri" + } + ], + // ... + } +] +``` + +##### 404: Not found + +Hashtag does not exist + +```json +{ + "error": "Record not found" +} +``` + +--- + +## View home timeline {#home} + +```http +GET /api/v1/timelines/home HTTP/1.1 +``` + +View statuses from followed users and hashtags. + +**Returns:** Array of [Status]({{}})\ +**OAuth:** User + `read:statuses`\ +**Version history:**\ +0.0.0 - added\ +2.6.0 - add `min_id`\ +3.3.0 - both `min_id` and `max_id` can be used at the same time now +4.0.0 - as users can now follow hashtags, statuses from non-followed users may appear in the timeline + +#### Request + +##### Headers + +Authorization +: {{}} Provide this header with `Bearer ` to gain authorized access to this API method. + +##### Query parameters + +max_id +: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. + +since_id +: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. + +min_id +: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. + +limit +: Integer. Maximum number of results to return. Defaults to 20 statuses. Max 40 statuses. + +#### Response +##### 200: OK + +Statuses in your home timeline will be returned + +```json +[ + { + "id": "103206791453397862", + "created_at": "2019-11-26T23:24:13.113Z", + // ... + }, + // ... +] +``` + +##### 206: Partial content + +Home feed is regenerating + +```text +``` + +##### 401: Unauthorized + +Invalid or missing Authorization header. + +```json +{ + "error": "The access token is invalid" +} +``` + +--- + +## View link timeline {#link} + +```http +GET /api/v1/timelines/link?url=:url HTTP/1.1 +``` + +View public statuses containing a link to the specified currently-trending article. This only lists statuses from people who have opted in to discoverability features. + +**Returns:** Array of [Status]({{}})\ +**OAuth:** Public. Requires app token + `read:statuses` if the instance has disabled public preview.\ +**Version history:**\ +4.3.0 - added + +#### Request + +##### Headers + +Authorization +: Provide this header with `Bearer ` to gain authorized access to this API method. + +##### Query parameters + +url +: {{}} String. The URL of the trending article. + +max_id +: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. + +since_id +: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. + +min_id +: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. + +limit +: Integer. Maximum number of results to return. Defaults to 20 statuses. Max 40 statuses. + +#### Response +##### 200: OK + +##### 404: Not found + +The provided URL is not currently trending. + +```json +{ + "error": "Record not found" +} +``` + +--- + +## View list timeline {#list} + +```http +GET /api/v1/timelines/list/:list_id HTTP/1.1 +``` + +View statuses in the given list timeline. + +**Returns:** Array of [Status]({{}})\ +**OAuth:** User token + `read:lists`\ +**Version history:**\ +2.1.0 - added\ +2.6.0 - add `min_id`\ +3.3.0 - both `min_id` and `max_id` can be used at the same time now + +#### Request + +##### Path parameters + +:list_id +: {{}} String. Local ID of the List in the database. + +##### Headers + +Authorization +: {{}} Provide this header with `Bearer ` to gain authorized access to this API method. + +##### Query parameters + +max_id +: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. + +since_id +: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. + +min_id +: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. + +limit +: Integer. Maximum number of results to return. Defaults to 20 statuses. Max 40 statuses. + +#### Response +##### 200: OK + +Statuses in this list will be returned. + +```json +[ + { + "id": "103206791453397862", + "created_at": "2019-11-26T23:24:13.113Z", + // ... + }, + // ... +] +``` + +##### 401: Unauthorized + +Invalid or missing Authorization header. + +```json +{ + "error": "The access token is invalid" +} +``` + +##### 404: Not found + +List is not owned by you or does not exist + +```json +{ + "error": "Record not found" +} +``` + +--- + +## View direct timeline {{%deprecated%}} {#direct} + +```http +GET /api/v1/timelines/direct HTTP/1.1 +``` + +View statuses with a "direct" privacy, from your account or in your notifications. + +**Returns:** Array of [Status]({{}})\ +**OAuth:** User token + `read:statuses`\ +**Version history:**\ +x.x.x - added\ +2.6.0 - add `min_id`. deprecated in favor of [Conversations API]({{}})\ +3.0.0 - removed + +#### Request +##### Headers + +Authorization +: {{}} Provide this header with `Bearer ` to gain authorized access to this API method. + +##### Query parameters + +max_id +: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. + +since_id +: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. + +min_id +: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. + +limit +: Integer. Maximum number of results to return. Defaults to 20 statuses. Max 40 statuses. + +#### Response +##### 200: OK + +Statuses with direct visibility, authored by you or mentioning you. Statuses are not grouped by conversation, but are returned in chronological order. + +```json +[ + { + "id": "103206185588894565", + "created_at": "2019-11-26T20:50:15.866Z", + // ... + "visibility": "direct", + // ... + }, + // ... +] +``` + +##### 401: Unauthorized + +Invalid or missing Authorization header. + +```json +{ + "error": "The access token is invalid" +} +``` + +--- + +## See also + +{{< caption-link url="https://github.com/mastodon/mastodon/blob/main/app/controllers/api/v1/timelines/home_controller.rb" caption="app/controllers/api/v1/timelines/home_controller.rb" >}} + +{{< caption-link url="https://github.com/mastodon/mastodon/blob/main/app/controllers/api/v1/timelines/list_controller.rb" caption="app/controllers/api/v1/timelines/list_controller.rb" >}} + +{{< caption-link url="https://github.com/mastodon/mastodon/blob/main/app/controllers/api/v1/timelines/public_controller.rb" caption="app/controllers/api/v1/timelines/public_controller.rb" >}} + +{{< caption-link url="https://github.com/mastodon/mastodon/blob/main/app/controllers/api/v1/timelines/tag_controller.rb" caption="app/controllers/api/v1/timelines/tag_controller.rb" >}} diff --git a/NeoDB/NeoDB/References/NeoDB/openapi/user.yaml b/NeoDB/NeoDB/References/NeoDB/openapi/user.yaml new file mode 100644 index 0000000..4c7baaf --- /dev/null +++ b/NeoDB/NeoDB/References/NeoDB/openapi/user.yaml @@ -0,0 +1,69 @@ +openapi: 3.1.0 +info: + title: NeoDB API + version: 1.0.0 + description: NeoDB API
Learn more +paths: + /api/me: + get: + operationId: users_api_me + summary: Get current user's basic info + parameters: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserSchema' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Result' + tags: + - user + security: + - OAuthAccessTokenAuth: [] +components: + schemas: + UserSchema: + properties: + url: + title: Url + type: string + external_acct: + anyOf: + - type: string + - type: 'null' + title: External Acct + display_name: + title: Display Name + type: string + avatar: + title: Avatar + type: string + username: + title: Username + type: string + required: + - url + - external_acct + - display_name + - avatar + - username + title: UserSchema + type: object + Result: + properties: + message: + anyOf: + - type: string + - type: 'null' + title: Message + required: + - message + title: Result + type: object +servers: [] diff --git a/NeoDB/NeoDB/References/Packages/keychain-swift.md b/NeoDB/NeoDB/References/Packages/keychain-swift.md index 2c04281..72f9a91 100644 --- a/NeoDB/NeoDB/References/Packages/keychain-swift.md +++ b/NeoDB/NeoDB/References/Packages/keychain-swift.md @@ -1,3 +1,5 @@ +Note: keychain-swift is already added to the project, this is just a reference for the documentation. + # Helper functions for storing text in Keychain for iOS, macOS, tvOS and WatchOS [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) diff --git a/NeoDB/NeoDB/References/Standards/Architecture.md b/NeoDB/NeoDB/References/Standards/Architecture.md new file mode 100644 index 0000000..11a5d0c --- /dev/null +++ b/NeoDB/NeoDB/References/Standards/Architecture.md @@ -0,0 +1,63 @@ +# Architecture + +## Model View Controller (MVC) + +### Keeping It Simple + +We feel that Apple got it right with this one. All of our projects follow [Apple's MVC architecture](https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html). We've found this beneficial for many reasons: + +* Keeps the barrier to entry for understanding the code low, minimizing the amount of ramp-up time required to onboard new devs. +* Is well-documented. +* Avoids the need to rely on third party frameworks for app architecture. Less dependencies is always better (see [Dependency Management](../Dependency%20Management/Dependency%20Management.md) for more information). + +### Avoiding Massive View Controller + +Massive view controllers only happen when proper separation of concerns isn't implemented. By simply following [SOLID design principles](https://en.wikipedia.org/wiki/SOLID), you can make even a 300 line view controller a rare occurrence. + +## Table and Collection Views + +### Built-In Table/Collection View Controllers + +We **rarely** use the Apple-provided view controller templates (`UITableViewController` & `UICollectionViewController`). While they can be useful for beginners just starting out in iOS development, they reduce your ability to properly apply separation of concerns and can be challenging to build upon whenever you need to do non-default behavior. + +### Data Sources and Delegates + +The data source and delegate methods for table and collection views should not be implemented by the view controller. Instead, separate "table/collection controller" objects should be created for the task. This prevents table/collection view details from polluting your view controller, which leads to better separation of concerns and greatly simplifies its responsibilities. + +## Model Objects + +### JSON Deserialization + +Data received from external APIs is almost always JSON. We prefer to to convert the raw JSON into native model objects **as soon as possible** rather than work with raw JSON dictionaries. With the release of Swift 4, we now make heavy use of the [`Decodable` protocol](https://developer.apple.com/documentation/swift/decodable) rather than relying on third party libraries like SwiftyJSON or Argo. + +### Classes vs Structs + +We generally prefer pure Swift structs due to their value-typed nature and ability of the Swift compiler to automatically generate the [memberwise initializer](https://docs.swift.org/swift-book/LanguageGuide/Initialization.html#ID214). + +## Protocols + +### Prefer Protocols Over Inheritance + +The [Protocol-Oriented Programming in Swift](https://developer.apple.com/videos/play/wwdc2015/408/) session from WWDC 2015 is a great introduction to the power of protocols. It's worth a watch if you've never seen it or a re-watch for a quick refresher. + +You should prefer using composition over inheritance when writing Swift code. Specifically, you should prefer __protocol based composition__ over inheritance. + +Protocol based composition allows a struct or class to fulfill the desired behaviors of an object without requiring that direct inheritance - which is not available for a struct in any case. + +Inheritance should be used as a last resort. There are two generally palatable situations where you may use inheritance: + +* Base view controllers +* Inheritance required by mandated third party libraries + +## UI Responsiveness + +It's imperative that the app's UI remain responsive during all aspects of the app's lifecycle. In general, any delay greater than 200 ms will be immediately noticeable by the user and could cause them to question whether or not the system registered their tap. + +* Reserve the main thread for UI work. Move as much work to background threads as possible. +* Avoid purposely ignoring user interactions. There should generally never be a need to globally ignore user interaction by calling [`beginIgnoringInteractionEvents()`](https://developer.apple.com/documentation/uikit/uiapplication/1623047-beginignoringinteractionevents) on `UIApplication`. + +## Architectural Guidelines + +When working on a new project it is imperative that all developers understand the scope and significance of any technical decision they make. In general, the more junior the developer, the more guidance and the less architectural decision making power the developer has. This is primarily to protect the customer as well as Bottle Rocket from “rogue” developers making a poor technology or other architecturally significant decision that would impact our ability to meet customer expectations. + +In general, if a developer has to make a decision between development technologies (e.g. languages, tools, 3rd party components, complex implementations, etc.) then this is an architecturally significant decision that requires director level approval and notification. The sole intent of this guideline is to protect the client and Bottle Rocket, not to create an artificial bottleneck. diff --git a/NeoDB/NeoDB/References/Standards/Project Structure/Project Structure.md b/NeoDB/NeoDB/References/Standards/Project Structure.md similarity index 100% rename from NeoDB/NeoDB/References/Standards/Project Structure/Project Structure.md rename to NeoDB/NeoDB/References/Standards/Project Structure.md diff --git a/NeoDB/NeoDB/Services/Config/AppConfig.swift b/NeoDB/NeoDB/Services/Config/AppConfig.swift new file mode 100644 index 0000000..0cd2978 --- /dev/null +++ b/NeoDB/NeoDB/Services/Config/AppConfig.swift @@ -0,0 +1,13 @@ +import Foundation + +enum AppConfig { + static let baseURL = "https://neodb.social" + + enum OAuth { + // These values need to be updated with the ones from NeoDB after registering the app + static let clientId = "YOUR_CLIENT_ID" + static let clientSecret = "YOUR_CLIENT_SECRET" + static let redirectUri = "neodb://oauth/callback" + static let scopes = "read write" + } +} diff --git a/NeoDB/NeoDB/Services/Models/User.swift b/NeoDB/NeoDB/Services/Models/User.swift new file mode 100644 index 0000000..27c10fb --- /dev/null +++ b/NeoDB/NeoDB/Services/Models/User.swift @@ -0,0 +1,17 @@ +import Foundation + +struct User: Codable { + let url: String + let externalAcct: String? + let displayName: String + let avatar: String + let username: String + + enum CodingKeys: String, CodingKey { + case url + case externalAcct = "external_acct" + case displayName = "display_name" + case avatar + case username + } +} \ No newline at end of file diff --git a/NeoDB/NeoDB/Services/Network/AuthService.swift b/NeoDB/NeoDB/Services/Network/AuthService.swift new file mode 100644 index 0000000..3984bdd --- /dev/null +++ b/NeoDB/NeoDB/Services/Network/AuthService.swift @@ -0,0 +1,384 @@ +import Foundation +import SwiftUI +import OSLog +import KeychainSwift + +enum AuthError: Error { + case invalidURL + case networkError(Error) + case invalidResponse + case unauthorized + case registrationFailed(String) + case tokenExchangeFailed(String) + case invalidInstance + case noClientCredentials +} + +struct AppRegistrationResponse: Codable { + let client_id: String + let client_secret: String + let name: String + let redirect_uri: String +} + +struct InstanceClient: Codable { + let clientId: String + let clientSecret: String + let instance: String +} + +@MainActor +class AuthService: ObservableObject { + private let logger = Logger(subsystem: "app.neodb", category: "Auth") + private let keychain: KeychainSwift + + @Published var isAuthenticated = false + @Published var accessToken: String? + @Published var isRegistering = false + @Published var currentInstance: String { + didSet { + let lowercaseInstance = self.currentInstance.lowercased() + if lowercaseInstance != self.currentInstance { + self.currentInstance = lowercaseInstance + return + } + // Save the current instance + UserDefaults.standard.set(self.currentInstance, forKey: "neodb.currentInstance") + logger.debug("Switched to instance: \(self.currentInstance)") + } + } + + private var baseURL: String { "https://\(self.currentInstance)" } + private let redirectUri = "neodb://oauth/callback" + private let scopes = "read write" + + init(instance: String? = nil) { + // Load last used instance or use default, ensure it's lowercase + self.currentInstance = (instance ?? UserDefaults.standard.string(forKey: "neodb.currentInstance") ?? "neodb.social").lowercased() + self.keychain = KeychainSwift(keyPrefix: "neodb_") + + logger.debug("Initialized with instance: \(self.currentInstance)") + + // Check if we have a saved access token for current instance + if let token = savedAccessToken { + accessToken = token + isAuthenticated = true + logger.debug("Found saved access token for instance: \(self.currentInstance)") + } + + // Log if we have client credentials + if let client = getInstanceClient(for: self.currentInstance) { + logger.debug("Found existing client credentials for instance: \(self.currentInstance), client_id: \(client.clientId)") + } + } + + private var clientId: String? { + get { + guard let clientData = getInstanceClient(for: self.currentInstance) else { + logger.debug("No client_id found for instance: \(self.currentInstance)") + return nil + } + return clientData.clientId + } + set { + if let value = newValue, let secretValue = clientSecret { + saveInstanceClient(InstanceClient( + clientId: value, + clientSecret: secretValue, + instance: self.currentInstance + )) + logger.debug("Saved client_id for instance: \(self.currentInstance)") + } + } + } + + private var clientSecret: String? { + get { + guard let clientData = getInstanceClient(for: self.currentInstance) else { + logger.debug("No client_secret found for instance: \(self.currentInstance)") + return nil + } + return clientData.clientSecret + } + set { + if let value = newValue, let idValue = clientId { + saveInstanceClient(InstanceClient( + clientId: idValue, + clientSecret: value, + instance: self.currentInstance + )) + logger.debug("Saved client_secret for instance: \(self.currentInstance)") + } + } + } + + private var savedAccessToken: String? { + get { keychain.get("access_token_\(self.currentInstance)") } + set { + if let value = newValue { + keychain.set(value, forKey: "access_token_\(self.currentInstance)") + accessToken = value + isAuthenticated = true + logger.debug("Saved access token for instance: \(self.currentInstance)") + } else { + keychain.delete("access_token_\(self.currentInstance)") + accessToken = nil + isAuthenticated = false + logger.debug("Removed access token for instance: \(self.currentInstance)") + } + } + } + + private func getInstanceClient(for instance: String) -> InstanceClient? { + guard let data = keychain.getData("client_\(instance)"), + let client = try? JSONDecoder().decode(InstanceClient.self, from: data) + else { + logger.debug("Failed to get client data for instance: \(instance)") + return nil + } + logger.debug("Retrieved client data for instance: \(instance)") + return client + } + + private func saveInstanceClient(_ client: InstanceClient) { + if let data = try? JSONEncoder().encode(client) { + keychain.set(data, forKey: "client_\(client.instance)") + logger.debug("Saved client data for instance: \(client.instance)") + } else { + logger.error("Failed to encode client data for instance: \(client.instance)") + } + } + + private func removeInstanceClient(for instance: String) { + keychain.delete("client_\(instance)") + logger.debug("Removed client data for instance: \(instance)") + } + + func validateInstance(_ instance: String) -> Bool { + let lowercaseInstance = instance.lowercased() + // Basic validation: ensure it's a valid hostname + let hostnameRegex = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$" + let hostnamePredicate = NSPredicate(format: "SELF MATCHES %@", hostnameRegex) + return hostnamePredicate.evaluate(with: lowercaseInstance) + } + + func switchInstance(_ newInstance: String) throws { + let lowercaseInstance = newInstance.lowercased() + guard validateInstance(lowercaseInstance) else { + throw AuthError.invalidInstance + } + + logger.debug("Switching from instance \(self.currentInstance) to \(lowercaseInstance)") + + // Logout from current instance but keep the client credentials + logout() + + // Switch to new instance + currentInstance = lowercaseInstance + + // Clear current session but keep client credentials + savedAccessToken = nil + isAuthenticated = false + } + + var authorizationURL: URL? { + guard let clientId = clientId else { + logger.error("Cannot create authorization URL: no client_id available") + return nil + } + var components = URLComponents(string: "\(baseURL)/oauth/authorize") + components?.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "redirect_uri", value: redirectUri), + URLQueryItem(name: "scope", value: scopes), + // Add current instance as state to restore it after callback + URLQueryItem(name: "state", value: self.currentInstance) + ] + logger.debug("Created authorization URL for instance: \(self.currentInstance)") + return components?.url + } + + func registerApp() async throws { + // Check if we already have valid credentials for this instance + if let client = getInstanceClient(for: self.currentInstance) { + logger.debug("Using existing client credentials for instance: \(self.currentInstance), client_id: \(client.clientId)") + return + } + + isRegistering = true + defer { isRegistering = false } + + guard let url = URL(string: "\(baseURL)/api/v1/apps") else { + throw AuthError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let parameters = [ + "client_name": "NeoDB iOS App", + "redirect_uris": redirectUri, + "website": "https://github.com/citron/neodb-app" + ] + + let body = parameters + .map { "\($0.key)=\($0.value)" } + .joined(separator: "&") + + request.httpBody = body.data(using: String.Encoding.utf8) + + logger.debug("Registering app with instance: \(self.currentInstance)") + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AuthError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if let errorMessage = String(data: data, encoding: .utf8) { + logger.error("Registration failed for instance \(self.currentInstance): \(errorMessage)") + throw AuthError.registrationFailed(errorMessage) + } + throw AuthError.registrationFailed("Registration failed with status code: \(httpResponse.statusCode)") + } + + let registrationResponse = try JSONDecoder().decode(AppRegistrationResponse.self, from: data) + + // Save the client credentials for this instance + let client = InstanceClient( + clientId: registrationResponse.client_id, + clientSecret: registrationResponse.client_secret, + instance: currentInstance + ) + saveInstanceClient(client) + + logger.debug("App registered successfully with client_id: \(registrationResponse.client_id) for instance: \(self.currentInstance)") + } + + func handleCallback(url: URL) async throws { + logger.debug("Handling callback URL: \(url)") + + // Extract instance from saved state if available + if let state = URLComponents(url: url, resolvingAgainstBaseURL: true)? + .queryItems? + .first(where: { $0.name == "state" })? + .value, + !state.isEmpty && state != "None" { + // If state contains instance information, use it + currentInstance = state.lowercased() + logger.debug("Restored instance from state: \(self.currentInstance)") + } + + // Verify we have client credentials before proceeding + guard let client = getInstanceClient(for: self.currentInstance) else { + logger.error("No client credentials found for instance: \(self.currentInstance)") + throw AuthError.noClientCredentials + } + logger.debug("Found client credentials for callback, client_id: \(client.clientId)") + + guard let code = URLComponents(url: url, resolvingAgainstBaseURL: true)? + .queryItems? + .first(where: { $0.name == "code" })? + .value + else { + logger.error("No authorization code found in callback URL") + throw AuthError.invalidResponse + } + + logger.debug("Authorization code received: \(code) for instance: \(self.currentInstance)") + try await exchangeCodeForToken(code: code) + } + + private func exchangeCodeForToken(code: String) async throws { + // Double check we're using the correct instance + logger.debug("Exchanging token for instance: \(self.currentInstance)") + + guard let client = getInstanceClient(for: self.currentInstance) else { + logger.error("No client credentials found for token exchange on instance: \(self.currentInstance)") + throw AuthError.noClientCredentials + } + + logger.debug("Using client_id: \(client.clientId) for token exchange on instance: \(self.currentInstance)") + + guard let url = URL(string: "\(baseURL)/oauth/token") else { + throw AuthError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let parameters = [ + "client_id": client.clientId, + "client_secret": client.clientSecret, + "code": code, + "redirect_uri": redirectUri, + "grant_type": "authorization_code" + ] + + let body = parameters + .map { "\($0.key)=\($0.value)" } + .joined(separator: "&") + + request.httpBody = body.data(using: String.Encoding.utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AuthError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if let errorMessage = String(data: data, encoding: .utf8) { + logger.error("Token exchange failed for instance \(self.currentInstance): \(errorMessage), status code: \(httpResponse.statusCode)") + // If unauthorized, clear stored credentials for this instance + if httpResponse.statusCode == 401 { + removeInstanceClient(for: self.currentInstance) + savedAccessToken = nil + } + throw AuthError.tokenExchangeFailed(errorMessage) + } + logger.error("Token exchange failed for instance \(self.currentInstance) with status code: \(httpResponse.statusCode)") + throw AuthError.tokenExchangeFailed("Failed with status code: \(httpResponse.statusCode)") + } + + struct TokenResponse: Codable { + let access_token: String + let token_type: String + let scope: String + } + + do { + let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) + self.savedAccessToken = tokenResponse.access_token + logger.debug("Successfully obtained access token for instance: \(self.currentInstance)") + } catch { + logger.error("Failed to decode token response for instance \(self.currentInstance): \(error)") + if let responseString = String(data: data, encoding: .utf8) { + logger.error("Raw response: \(responseString)") + } + throw error + } + } + + func logout() { + savedAccessToken = nil + isAuthenticated = false + logger.debug("Logged out from instance: \(self.currentInstance)") + } + + func clearAllData() { + // Clear all keychain data + keychain.clear() + // Clear current instance + UserDefaults.standard.removeObject(forKey: "neodb.currentInstance") + // Reset state + currentInstance = "neodb.social" + isAuthenticated = false + accessToken = nil + logger.debug("Cleared all data") + } +} + diff --git a/NeoDB/NeoDB/Services/Network/UserService.swift b/NeoDB/NeoDB/Services/Network/UserService.swift new file mode 100644 index 0000000..c463f42 --- /dev/null +++ b/NeoDB/NeoDB/Services/Network/UserService.swift @@ -0,0 +1,66 @@ +import Foundation + +@MainActor +class UserService { + private let authService: AuthService + private let cache = NSCache() + private let cacheKey = "cached_user" + + init(authService: AuthService) { + self.authService = authService + } + + func getCurrentUser(forceRefresh: Bool = false) async throws -> User { + let cacheKey = "\(authService.currentInstance)_\(cacheKey)" as NSString + + // Return cached user if available and not forcing refresh + if !forceRefresh, let cachedUser = cache.object(forKey: cacheKey) { + return cachedUser.user + } + + guard let accessToken = authService.accessToken else { + throw AuthError.unauthorized + } + + let baseURL = "https://\(authService.currentInstance)" + guard let url = URL(string: "\(baseURL)/api/me") else { + throw AuthError.invalidURL + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AuthError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 401 { + throw AuthError.unauthorized + } + throw AuthError.invalidResponse + } + + let user = try JSONDecoder().decode(User.self, from: data) + + // Cache the user + cache.setObject(CachedUser(user: user), forKey: cacheKey) + + return user + } + + func clearCache() { + cache.removeAllObjects() + } +} + +// Helper class to make User cacheable +final class CachedUser { + let user: User + + init(user: User) { + self.user = user + } +} \ No newline at end of file diff --git a/NeoDB/NeoDB/Views/Auth/LoginView.swift b/NeoDB/NeoDB/Views/Auth/LoginView.swift index d20a4c9..0812a91 100644 --- a/NeoDB/NeoDB/Views/Auth/LoginView.swift +++ b/NeoDB/NeoDB/Views/Auth/LoginView.swift @@ -2,8 +2,12 @@ import SwiftUI import AuthenticationServices struct LoginView: View { - @StateObject private var authService = AuthService() + @EnvironmentObject var authService: AuthService @Environment(\.openURL) private var openURL + @State private var errorMessage: String? + @State private var showError = false + @State private var instanceUrl: String = "neodb.social" + @State private var showInstanceInput = false var body: some View { VStack(spacing: 20) { @@ -22,14 +26,56 @@ struct LoginView: View { .multilineTextAlignment(.center) .padding(.horizontal) + VStack(alignment: .leading, spacing: 8) { + Text("Instance") + .font(.headline) + + HStack { + Text(authService.currentInstance) + .foregroundColor(.secondary) + + Spacer() + + Button("Change") { + showInstanceInput = true + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + } + .padding(.horizontal) + Button(action: { - if let url = authService.authorizationURL { - openURL(url) + Task { + do { + try await authService.registerApp() + if let url = authService.authorizationURL { + openURL(url) + } else { + errorMessage = "Failed to create authorization URL" + showError = true + } + } catch AuthError.registrationFailed(let message) { + errorMessage = "Registration failed: \(message)" + showError = true + } catch AuthError.tokenExchangeFailed(let message) { + errorMessage = "Authentication failed: \(message)" + showError = true + } catch { + errorMessage = error.localizedDescription + showError = true + } } }) { HStack { - Image(systemName: "person.fill") - Text("Sign in with NeoDB") + if authService.isRegistering { + ProgressView() + .tint(.white) + } else { + Image(systemName: "person.fill") + Text("Sign in with NeoDB") + } } .frame(maxWidth: .infinity) .padding() @@ -37,8 +83,123 @@ struct LoginView: View { .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 10)) } + .disabled(authService.isRegistering) .padding(.horizontal) } .padding() + .alert("Error", isPresented: $showError, actions: { + Button("OK", role: .cancel) {} + }, message: { + Text(errorMessage ?? "An unknown error occurred") + }) + .sheet(isPresented: $showInstanceInput) { + NavigationStack { + InstanceInputView(instanceUrl: instanceUrl) { newInstance in + do { + try authService.switchInstance(newInstance) + instanceUrl = newInstance + showInstanceInput = false + } catch { + errorMessage = "Invalid instance URL" + showError = true + } + } + .navigationTitle("Change Instance") + .navigationBarItems( + leading: Button("Cancel") { + showInstanceInput = false + } + ) + } + .presentationDetents([.height(200)]) + } + .enableInjection() + } + + #if DEBUG + @ObserveInjection var forceRedraw + #endif +} + +struct InstanceInputView: View { + @State private var instanceUrl: String + @State private var isValidating = false + @State private var localError: String? + @FocusState private var isUrlFieldFocused: Bool + + let onSubmit: (String) -> Void + + init(instanceUrl: String, onSubmit: @escaping (String) -> Void) { + _instanceUrl = State(initialValue: instanceUrl) + self.onSubmit = onSubmit + } + + var isValidUrl: Bool { + let urlPattern = "^[a-zA-Z0-9][a-zA-Z0-9-_.]+\\.[a-zA-Z]{2,}$" + let urlPredicate = NSPredicate(format: "SELF MATCHES %@", urlPattern) + return urlPredicate.evaluate(with: instanceUrl) + } + + var body: some View { + VStack(spacing: 16) { + Text("Enter the URL of your NeoDB instance") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + TextField("Instance URL", text: $instanceUrl) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .submitLabel(.done) + .focused($isUrlFieldFocused) + .onChange(of: instanceUrl) { _ in + localError = nil + } + .onSubmit { + submitInstance() + } + .accessibilityHint("Enter your NeoDB instance URL, for example: neodb.social") + + if let error = localError { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + + Button(action: submitInstance) { + HStack { + if isValidating { + ProgressView() + .tint(.white) + } else { + Text("Connect") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isValidating || instanceUrl.isEmpty || !isValidUrl) + } + .padding() + .onAppear { + isUrlFieldFocused = true + } + .enableInjection() + } + + #if DEBUG + @ObserveInjection var forceRedraw + #endif + + private func submitInstance() { + guard isValidUrl else { + localError = "Please enter a valid instance URL" + return + } + + isValidating = true + onSubmit(instanceUrl) + isValidating = false } -} \ No newline at end of file +} diff --git a/NeoDB/NeoDB/Views/Profile/ProfileView.swift b/NeoDB/NeoDB/Views/Profile/ProfileView.swift new file mode 100644 index 0000000..be22336 --- /dev/null +++ b/NeoDB/NeoDB/Views/Profile/ProfileView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +@MainActor +class ProfileViewModel: ObservableObject { + private let userService: UserService + private let authService: AuthService + + @Published var user: User? + @Published var isLoading = false + @Published var error: String? + + init(userService: UserService, authService: AuthService) { + self.userService = userService + self.authService = authService + } + + func loadUserProfile(forceRefresh: Bool = false) async { + if forceRefresh { + isLoading = true + } + error = nil + + do { + user = try await userService.getCurrentUser(forceRefresh: forceRefresh) + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + func logout() { + userService.clearCache() + authService.logout() + } +} + +struct ProfileView: View { + @StateObject private var viewModel: ProfileViewModel + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + @Environment(\.refresh) private var refresh + + private let avatarSize: CGFloat = 60 + + init(authService: AuthService) { + let userService = UserService(authService: authService) + _viewModel = StateObject(wrappedValue: ProfileViewModel(userService: userService, authService: authService)) + } + + var body: some View { + NavigationStack { + Group { + if let error = viewModel.error { + EmptyStateView( + "Couldn't Load Profile", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else { + profileContent + } + } + .navigationTitle("Profile") + .navigationBarTitleDisplayMode(.large) + } + .task { + await viewModel.loadUserProfile() + } + } + + private var profileContent: some View { + List { + // Profile Header Section + Section { + HStack(spacing: 16) { + if let user = viewModel.user { + AsyncImage(url: URL(string: user.avatar)) { phase in + switch phase { + case .empty: + placeholderAvatar + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + .transition(.scale.combined(with: .opacity)) + case .failure(_): + Image(systemName: "person.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .font(.system(size: avatarSize * 0.8)) + .frame(width: avatarSize, height: avatarSize) + @unknown default: + EmptyView() + } + } + } else { + placeholderAvatar + } + + VStack(alignment: .leading, spacing: 4) { + if let user = viewModel.user { + Text(user.displayName) + .font(.headline) + Text("@\(user.username)") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + Text("Loading Name") + .font(.headline) + Text("@username") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .redacted(reason: viewModel.user == nil || viewModel.isLoading ? .placeholder : []) + } + } + + // External Account Section + if let user = viewModel.user, let externalAcct = user.externalAcct { + Section("Account Information") { + LabeledContent("External Account", value: externalAcct) + .redacted(reason: viewModel.isLoading ? .placeholder : []) + } + } else if viewModel.user == nil { + Section("Account Information") { + LabeledContent("External Account", value: "loading...") + .redacted(reason: .placeholder) + } + } + + // Logout Button + Section { + Button(role: .destructive, action: { + withAnimation { + viewModel.logout() + dismiss() + } + }) { + Text("Sign Out") + .frame(maxWidth: .infinity) + } + .disabled(viewModel.user == nil) + } + } + .listStyle(.insetGrouped) + .refreshable { + await viewModel.loadUserProfile(forceRefresh: true) + } + .overlay { + if viewModel.isLoading && viewModel.user == nil { + Color.clear + .background(.ultraThinMaterial) + .overlay { + ProgressView() + } + .allowsHitTesting(false) + } + } + .enableInjection() + } + + #if DEBUG + @ObserveInjection var forceRedraw + #endif + + private var placeholderAvatar: some View { + Circle() + .fill(Color.gray.opacity(0.2)) + .frame(width: avatarSize, height: avatarSize) + .overlay { + if viewModel.isLoading { + ProgressView() + .scaleEffect(0.5) + } else { + Image(systemName: "person.fill") + .font(.system(size: avatarSize * 0.5)) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/NeoDB/NeoDB/Views/Shared/EmptyStateView.swift b/NeoDB/NeoDB/Views/Shared/EmptyStateView.swift new file mode 100644 index 0000000..17ccef4 --- /dev/null +++ b/NeoDB/NeoDB/Views/Shared/EmptyStateView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct EmptyStateView: View { + let title: String + let systemImage: String + let description: Text + + init(_ title: String, systemImage: String, description: Text) { + self.title = title + self.systemImage = systemImage + self.description = description + } + + var body: some View { + if #available(iOS 17.0, *) { + ContentUnavailableView( + title, + systemImage: systemImage, + description: description + ) + } else { + VStack(spacing: 16) { + Image(systemName: systemImage) + .font(.system(size: 56)) + .foregroundStyle(.secondary) + + Text(title) + .font(.title2) + .fontWeight(.bold) + + description + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + } +} \ No newline at end of file