Skip to content

Commit

Permalink
feat: re Feat/timeline (#5)
Browse files Browse the repository at this point in the history
* 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
lcandy2 authored Jan 5, 2025
1 parent 726b388 commit 04280af
Show file tree
Hide file tree
Showing 4 changed files with 512 additions and 4 deletions.
5 changes: 1 addition & 4 deletions NeoDB/NeoDB/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ struct ContentView: View {
var body: some View {
TabView {
NavigationStack {
Text("Home Feed")
.navigationTitle("Home")
HomeView(authService: authService)
}
.tabItem {
Label("Home", systemImage: "house.fill")
Expand All @@ -36,7 +35,6 @@ struct ContentView: View {
Label("Library", systemImage: "books.vertical.fill")
}


NavigationStack {
ProfileView(authService: authService)
}
Expand All @@ -45,7 +43,6 @@ struct ContentView: View {
}
}
.tint(.accentColor)
.enableInjection()
}

#if DEBUG
Expand Down
149 changes: 149 additions & 0 deletions NeoDB/NeoDB/Models/Timeline/Status.swift
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?
}
115 changes: 115 additions & 0 deletions NeoDB/NeoDB/Services/Network/TimelineService.swift
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
}
}
}
Loading

0 comments on commit 04280af

Please sign in to comment.