-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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. * refactor: update ContentView to use HomeView and remove unused code - Replaced the static "Home Feed" text with the HomeView component for better functionality. - Removed unnecessary commented-out code and debug-related properties to clean up the ContentView structure. * refactor: update Status model and TimelineService for improved data handling - Changed Status from a struct to a class, adding new properties for enhanced functionality, including uri, editedAt, and various flags (favourited, reblogged, etc.). - Updated the TimelineService to modify the timeline fetching method and improve error logging with detailed messages for better debugging. - Enhanced HomeViewModel to handle loading states and detailed error reporting, improving user experience during data fetch operations. - Added support for displaying status statistics (replies, reblogs, favourites) in the HomeView.
- Loading branch information
Showing
4 changed files
with
512 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
import Foundation | ||
|
||
class Status: Codable, Identifiable { | ||
let id: String | ||
let uri: String | ||
let createdAt: Date | ||
let editedAt: Date? | ||
let content: String | ||
let text: String | ||
let visibility: Visibility | ||
let sensitive: Bool | ||
let spoilerText: String | ||
let url: String | ||
let account: Account | ||
let mediaAttachments: [MediaAttachment] | ||
let mentions: [Mention] | ||
let tags: [Tag] | ||
let card: Card? | ||
let language: String? | ||
let favourited: Bool | ||
let reblogged: Bool | ||
let muted: Bool | ||
let bookmarked: Bool | ||
let pinned: Bool | ||
let reblog: Status? | ||
let favouritesCount: Int | ||
let reblogsCount: Int | ||
let repliesCount: Int | ||
|
||
enum Visibility: String, Codable { | ||
case `public` | ||
case unlisted | ||
case `private` | ||
case direct | ||
} | ||
|
||
enum CodingKeys: String, CodingKey { | ||
case id, uri, content, text, visibility, sensitive, url, account, mentions, tags, card, language | ||
case createdAt = "created_at" | ||
case editedAt = "edited_at" | ||
case spoilerText = "spoiler_text" | ||
case mediaAttachments = "media_attachments" | ||
case favourited, reblogged, muted, bookmarked, pinned, reblog | ||
case favouritesCount = "favourites_count" | ||
case reblogsCount = "reblogs_count" | ||
case repliesCount = "replies_count" | ||
} | ||
} | ||
|
||
struct Account: Codable, Identifiable { | ||
let id: String | ||
let username: String | ||
let acct: String | ||
let url: String | ||
let displayName: String | ||
let note: String | ||
let avatar: String | ||
let avatarStatic: String | ||
let header: String | ||
let headerStatic: String | ||
let locked: Bool | ||
let bot: Bool | ||
let group: Bool | ||
let discoverable: Bool | ||
let indexable: Bool | ||
let statusesCount: Int | ||
let followersCount: Int | ||
let followingCount: Int | ||
let createdAt: Date | ||
let lastStatusAt: String? | ||
let fields: [Field] | ||
let emojis: [Emoji] | ||
|
||
enum CodingKeys: String, CodingKey { | ||
case id, username, acct, url, note, avatar, locked, bot, group, fields, emojis | ||
case displayName = "display_name" | ||
case avatarStatic = "avatar_static" | ||
case header, headerStatic = "header_static" | ||
case discoverable, indexable | ||
case statusesCount = "statuses_count" | ||
case followersCount = "followers_count" | ||
case followingCount = "following_count" | ||
case createdAt = "created_at" | ||
case lastStatusAt = "last_status_at" | ||
} | ||
} | ||
|
||
struct Field: Codable { | ||
let name: String | ||
let value: String | ||
let verifiedAt: Date? | ||
|
||
enum CodingKeys: String, CodingKey { | ||
case name, value | ||
case verifiedAt = "verified_at" | ||
} | ||
} | ||
|
||
struct Emoji: Codable { | ||
let shortcode: String | ||
let url: String | ||
let staticUrl: String | ||
let visibleInPicker: Bool | ||
|
||
enum CodingKeys: String, CodingKey { | ||
case shortcode, url | ||
case staticUrl = "static_url" | ||
case visibleInPicker = "visible_in_picker" | ||
} | ||
} | ||
|
||
struct MediaAttachment: Codable, Identifiable { | ||
let id: String | ||
let type: MediaType | ||
let url: String | ||
let previewUrl: String? | ||
let description: String? | ||
|
||
enum MediaType: String, Codable { | ||
case image | ||
case video | ||
case gifv | ||
case audio | ||
case unknown | ||
} | ||
|
||
enum CodingKeys: String, CodingKey { | ||
case id, type, url, description | ||
case previewUrl = "preview_url" | ||
} | ||
} | ||
|
||
struct Mention: Codable { | ||
let username: String | ||
let url: String | ||
let acct: String | ||
} | ||
|
||
struct Tag: Codable { | ||
let name: String | ||
let url: String | ||
} | ||
|
||
struct Card: Codable { | ||
let url: String | ||
let title: String | ||
let description: String | ||
let image: String? | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
import Foundation | ||
import OSLog | ||
|
||
@MainActor | ||
class TimelineService { | ||
private let authService: AuthService | ||
private let decoder: JSONDecoder | ||
private let logger = Logger(subsystem: "app.neodb", category: "TimelineService") | ||
|
||
init(authService: AuthService) { | ||
self.authService = authService | ||
|
||
self.decoder = JSONDecoder() | ||
let formatter = ISO8601DateFormatter() | ||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] | ||
self.decoder.dateDecodingStrategy = .custom { decoder in | ||
let container = try decoder.singleValueContainer() | ||
let dateString = try container.decode(String.self) | ||
|
||
if let date = formatter.date(from: dateString) { | ||
return date | ||
} | ||
|
||
// Fallback to basic ISO8601 without fractional seconds | ||
formatter.formatOptions = [.withInternetDateTime] | ||
if let date = formatter.date(from: dateString) { | ||
return date | ||
} | ||
|
||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Expected date string to be ISO8601-formatted.") | ||
} | ||
} | ||
|
||
func getTimeline(maxId: String? = nil, sinceId: String? = nil, minId: String? = nil, limit: Int = 20) async throws -> [Status] { | ||
guard let accessToken = authService.accessToken else { | ||
logger.error("No access token available") | ||
throw AuthError.unauthorized | ||
} | ||
|
||
let baseURL = "https://\(authService.currentInstance)" | ||
var components = URLComponents(string: "\(baseURL)/api/v1/timelines/public")! | ||
|
||
var queryItems = [URLQueryItem]() | ||
if let maxId = maxId { | ||
queryItems.append(URLQueryItem(name: "max_id", value: maxId)) | ||
} | ||
if let sinceId = sinceId { | ||
queryItems.append(URLQueryItem(name: "since_id", value: sinceId)) | ||
} | ||
if let minId = minId { | ||
queryItems.append(URLQueryItem(name: "min_id", value: minId)) | ||
} | ||
queryItems.append(URLQueryItem(name: "limit", value: String(min(limit, 40)))) | ||
components.queryItems = queryItems | ||
|
||
var request = URLRequest(url: components.url!) | ||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") | ||
|
||
logger.debug("Fetching timeline from: \(components.url?.absoluteString ?? "")") | ||
|
||
let (data, response) = try await URLSession.shared.data(for: request) | ||
|
||
guard let httpResponse = response as? HTTPURLResponse else { | ||
logger.error("Invalid response type") | ||
throw AuthError.invalidResponse | ||
} | ||
|
||
logger.debug("Response status code: \(httpResponse.statusCode)") | ||
|
||
guard httpResponse.statusCode == 200 else { | ||
if httpResponse.statusCode == 401 { | ||
logger.error("Unauthorized access") | ||
throw AuthError.unauthorized | ||
} | ||
logger.error("Invalid response status: \(httpResponse.statusCode)") | ||
|
||
if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | ||
let errorMessage = errorJson["error"] as? String { | ||
logger.error("Server error: \(errorMessage)") | ||
} | ||
|
||
if let responseString = String(data: data, encoding: .utf8) { | ||
logger.error("Response body: \(responseString)") | ||
} | ||
|
||
throw AuthError.invalidResponse | ||
} | ||
|
||
do { | ||
let statuses = try decoder.decode([Status].self, from: data) | ||
logger.debug("Successfully decoded \(statuses.count) statuses") | ||
return statuses | ||
} catch { | ||
logger.error("Decoding error: \(error.localizedDescription)") | ||
if let decodingError = error as? DecodingError { | ||
switch decodingError { | ||
case .dataCorrupted(let context): | ||
logger.error("Data corrupted: \(context.debugDescription)") | ||
case .keyNotFound(let key, let context): | ||
logger.error("Key not found: \(key.stringValue) in \(context.debugDescription)") | ||
case .typeMismatch(let type, let context): | ||
logger.error("Type mismatch: expected \(type) at \(context.debugDescription)") | ||
case .valueNotFound(let type, let context): | ||
logger.error("Value not found: expected \(type) at \(context.debugDescription)") | ||
@unknown default: | ||
logger.error("Unknown decoding error: \(decodingError)") | ||
} | ||
} | ||
if let responseString = String(data: data, encoding: .utf8) { | ||
logger.error("Raw response: \(responseString)") | ||
} | ||
throw error | ||
} | ||
} | ||
} |
Oops, something went wrong.