diff --git a/Leomard/Services/RequestHandler.swift b/Leomard/Services/RequestHandler.swift index d926845..6d78803 100644 --- a/Leomard/Services/RequestHandler.swift +++ b/Leomard/Services/RequestHandler.swift @@ -27,88 +27,91 @@ final class RequestHandler { public final let VERSION = "v3" public func makeApiRequest(host: String, request: String, method: HTTPMethod, headers: [String:String]? = nil, body: Codable? = nil, completion: @escaping (Result) -> Void) { - var urlString = host.containsAny("github.com", "imgur.com") ? "\(host)\(request)" : "\(host)/api/\(self.VERSION)\(request)" - - if !urlString.starts(with: "http") { - urlString = "https://\(urlString)" - } - - if SessionStorage.getInstance.isSessionActive() && method == .get { - let jwt = SessionStorage.getInstance.getCurrentSession()?.loginResponse.jwt - if !urlString.contains("?") { - urlString += "?auth=\(jwt!)" - } else { - urlString += "&auth=\(jwt!)" + Task(priority: .userInitiated) { + var urlString = host.containsAny("github.com", "imgur.com") ? "\(host)\(request)" : "\(host)/api/\(self.VERSION)\(request)" + + if !urlString.starts(with: "http") { + urlString = "https://\(urlString)" } - } - - guard let url = URL(string: urlString) else { - completion(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: nil))) - return - } - - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - - // Body is set? Include the Auth in body - if let body = body { - do { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - var jsonData = try encoder.encode(body) - - // Add the authentication to body, as it's needed for POSTs. - // To do that, we first must decode back the object, add "auth" key, and re-encode it. - guard var jsonDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + + if SessionStorage.getInstance.isSessionActive() && method == .get { + let jwt = SessionStorage.getInstance.getCurrentSession()?.loginResponse.jwt + if !urlString.contains("?") { + urlString += "?auth=\(jwt!)" + } else { + urlString += "&auth=\(jwt!)" + } + } + + guard let url = URL(string: urlString) else { + completion(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: nil))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + // Body is set? Include the Auth in body + if let body = body { + do { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + var jsonData = try encoder.encode(body) + + // Add the authentication to body, as it's needed for POSTs. + // To do that, we first must decode back the object, add "auth" key, and re-encode it. + guard var jsonDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to decode JSON data."]) completion(.failure(error)) return } - - if SessionStorage.getInstance.isSessionActive() { - jsonDictionary["auth"] = SessionStorage.getInstance.getCurrentSession()?.loginResponse.jwt - jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + + if SessionStorage.getInstance.isSessionActive() { + jsonDictionary["auth"] = SessionStorage.getInstance.getCurrentSession()?.loginResponse.jwt + jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + } + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } catch { + completion(.failure(error)) + return + } + } else if method != .get && SessionStorage.getInstance.getCurrentSession()?.loginResponse.jwt != nil { + // If no body is set, but the method is **NOT** GET, then add the "NoBodyPost" object with auth key. + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + do { + let jsonData = try encoder.encode(NoBodyPost(auth: SessionStorage.getInstance.getCurrentSession()!.loginResponse.jwt!)) + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } catch { + completion(.failure(error)) + return } - request.httpBody = jsonData - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - } catch { - completion(.failure(error)) - return - } - } else if method != .get && SessionStorage.getInstance.getCurrentSession()?.loginResponse.jwt != nil { - // If no body is set, but the method is **NOT** GET, then add the "NoBodyPost" object with auth key. - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - do { - let jsonData = try encoder.encode(NoBodyPost(auth: SessionStorage.getInstance.getCurrentSession()!.loginResponse.jwt!)) - request.httpBody = jsonData - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - } catch { - completion(.failure(error)) - return - } - } - - if let _headers = headers { - for header in _headers { - request.setValue(header.value, forHTTPHeaderField: header.key) - } - } - - URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in - if let error = error { - completion(.failure(error)) - return } - guard let httpResponse = response as? HTTPURLResponse else { - completion(.failure(NSError(domain: "Invalid response", code: 0, userInfo: nil))) - return + // If headers are set, add them too. + if let _headers = headers { + for header in _headers { + request.setValue(header.value, forHTTPHeaderField: header.key) + } } - let statusCode = httpResponse.statusCode - let apiResponse = APIResponse(statusCode: statusCode, data: data) - completion(.success(apiResponse)) - }).resume() + URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in + if let error = error { + completion(.failure(error)) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + completion(.failure(NSError(domain: "Invalid response", code: 0, userInfo: nil))) + return + } + + let statusCode = httpResponse.statusCode + let apiResponse = APIResponse(statusCode: statusCode, data: data) + completion(.success(apiResponse)) + }).resume() + } } } diff --git a/Leomard/Views/Components/PostUIView.swift b/Leomard/Views/Components/PostUIView.swift index cc726d2..cf43e4a 100644 --- a/Leomard/Views/Components/PostUIView.swift +++ b/Leomard/Views/Components/PostUIView.swift @@ -75,44 +75,74 @@ struct PostUIView: View { ) .task { - self.postBody = self.postView.post.body - if self.postBody != nil { - self.postBody = postBody!.replacingOccurrences(of: "\r", with: "
") + postBody = await postBodyTask() + url = await postUrlTask() + updatedTimeAsString = await updatedTimeAsStringTask() + } + .onTapGesture { + self.contentView.openPost(postView: self.postView) + } + .contextMenu { + PostContextMenu(postView: self.postView) + } + } + } + + nonisolated + private func postBodyTask() async -> String? { + return await withCheckedContinuation { continuation in + Task(priority: .background) { + var newPostBody = await self.postView.post.body + if newPostBody != nil { + newPostBody = newPostBody!.replacingOccurrences(of: "\r", with: "
") let regex = try! NSRegularExpression(pattern: #"!\[\]\((.*?)\)"#, options: .caseInsensitive) - let range = NSRange(location: 0, length: postBody!.utf16.count) + let range = NSRange(location: 0, length: newPostBody!.utf16.count) - let matches = regex.matches(in: postBody!, options: [], range: range) + let matches = regex.matches(in: newPostBody!, options: [], range: range) var imageUrls: [String] = [] for match in matches { - if let urlRange = Range(match.range(at: 1), in: postBody!) { - let imageUrl = String(postBody![urlRange]) + if let urlRange = Range(match.range(at: 1), in: newPostBody!) { + let imageUrl = String(newPostBody![urlRange]) imageUrls.append(imageUrl) } } } - if shortBody && postBody != nil && postBody!.count > PostUIView.maxPostLength { - postBody = String(postBody!.prefix(PostUIView.maxPostLength)) - postBody = postBody!.trimmingCharacters(in: .whitespacesAndNewlines) - postBody = postBody! + "... **Read More**" - } - - if postView.post.url != nil { - url = URL(string: postView.post.url!) - } - - if postView.post.updated != nil { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" - updatedTimeAsString = dateFormatter.string(from: postView.post.updated!) + if shortBody && newPostBody != nil && newPostBody!.count > PostUIView.maxPostLength { + newPostBody = String(newPostBody!.prefix(PostUIView.maxPostLength)) + newPostBody = newPostBody!.trimmingCharacters(in: .whitespacesAndNewlines) + newPostBody = newPostBody! + "... **Read More**" } + return continuation.resume(returning: newPostBody) } - .onTapGesture { - self.contentView.openPost(postView: self.postView) + } + } + + nonisolated + private func postUrlTask() async -> URL? { + return await withCheckedContinuation { continuation in + Task(priority: .background) { + guard let postUrl = await self.postView.post.url else { + return continuation.resume(returning: nil) + } + return continuation.resume(returning: URL(string: postUrl)) } - .contextMenu { - PostContextMenu(postView: self.postView) + } + } + + nonisolated + private func updatedTimeAsStringTask() async -> String { + return await withCheckedContinuation { continuation in + Task(priority: .background) { + guard let updatedDate = await self.postView.post.updated else { + return continuation.resume(returning: "") + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" + let dateString = dateFormatter.string(from: updatedDate) + return continuation.resume(returning: dateString) } } } diff --git a/Leomard/Views/FeedView.swift b/Leomard/Views/FeedView.swift index 7734467..2efcba9 100644 --- a/Leomard/Views/FeedView.swift +++ b/Leomard/Views/FeedView.swift @@ -9,20 +9,60 @@ import Foundation import SwiftUI import MarkdownUI +@MainActor +final class FeedViewModel: ObservableObject { + + @Published var selectedListing: ListingType = UserPreferences.getInstance.listType + @Published var selectedSort: SortType = UserPreferences.getInstance.postSortMethod + + @Published private(set) var isLoadingPosts = false + + let postService: PostService = .init(requestHandler: RequestHandler()) + @Published private(set) var page: Int = 1 + @Published var postsResponse: GetPostsResponse = .init() + + func loadPosts() { + if isLoadingPosts { + return + } + + isLoadingPosts = true + if postsResponse.posts == [] { + page = 1 + } else { + page += 1 + } + + postService.getAllPosts( + page: page, + sortType: selectedSort, + listingType: selectedListing) { result in + Task { @MainActor in + switch result { + case .success(let postsResponse) : + self.postsResponse.posts += postsResponse.posts + self.isLoadingPosts = false + case .failure(let error): + print(error) + self.isLoadingPosts = false + } + } + } + } + + func reload() { + postsResponse.posts.removeAll() + loadPosts() + } +} + struct FeedView: View { let contentView: ContentView @Binding var myself: MyUserInfo? let sortTypes: [SortType] = [ .topHour, .topDay, .topMonth, .topYear, .hot, .active, .new, .mostComments ] - @State var selectedListing: ListingType = UserPreferences.getInstance.listType - @State var selectedSort: SortType = UserPreferences.getInstance.postSortMethod - @State var postsResponse: GetPostsResponse = GetPostsResponse() - - @State var page: Int = 1 - @State var postService: PostService? = nil - - @State var isLoadingPosts: Bool = false + @StateObject private var viewModel: FeedViewModel = .init() @Binding var siteView: SiteView? @@ -35,11 +75,10 @@ struct FeedView: View { feedContent .cornerRadius(4) .task { - self.postService = PostService(requestHandler: RequestHandler()) - loadPosts() + viewModel.loadPosts() } .onDisappear { - self.postsResponse = GetPostsResponse() + viewModel.postsResponse = GetPostsResponse() } Spacer() } @@ -49,36 +88,34 @@ struct FeedView: View { private var feedToolbar: some View { HStack { HStack { - Image(systemName: selectedListing.image) + Image(systemName: viewModel.selectedListing.image) .padding(.trailing, 0) - Picker("", selection: $selectedListing) { + Picker("", selection: $viewModel.selectedListing) { ForEach(ListingType.allCases, id: \.self) { method in Text(String(describing: method)) } } .frame(maxWidth: 80) .padding(.leading, -10) - .onChange(of: selectedListing) { value in - self.reload() - self.loadPosts() + .onChange(of: viewModel.selectedListing) { value in + viewModel.reload() } } HStack { - Image(systemName: selectedSort.image) + Image(systemName: viewModel.selectedSort.image) .padding(.trailing, 0) - Picker("", selection: $selectedSort) { + Picker("", selection: $viewModel.selectedSort) { ForEach(sortTypes, id: \.self) { method in Text(String(describing: method)) } } .frame(maxWidth: 80) .padding(.leading, -10) - .onChange(of: selectedSort) { value in - self.reload() - self.loadPosts() + .onChange(of: viewModel.selectedSort) { value in + viewModel.reload() } } - Button(action: reload) { + Button(action: { viewModel.reload() }) { Image(systemName: "arrow.clockwise") } } @@ -110,22 +147,30 @@ struct FeedView: View { /// Infinite scrolling view for this feed's content @ViewBuilder private var feedPostsList: some View { - List(postsResponse.posts, id: \.post.id) { postView in - PostUIView(postView: postView, shortBody: true, postService: self.postService!, myself: $myself, contentView: contentView) + // - TODO: `scrollProxy` is expensive and isn't being used, remove? + ScrollViewReader { scrollProxy in + List(viewModel.postsResponse.posts, id: \.post.id) { postView in + PostUIView( + postView: postView, + shortBody: true, + postService: viewModel.postService, + myself: $myself, + contentView: contentView) .onAppear { - if postView == self.postsResponse.posts.last { - self.loadPosts() + if postView == viewModel.postsResponse.posts.last { + viewModel.loadPosts() } } .frame(maxWidth: .infinity, maxHeight: .infinity) - Spacer() + Spacer() + } + .frame( + minWidth: 0, + maxWidth: 600, + maxHeight: .infinity, + alignment: .center + ) } - .frame( - minWidth: 0, - maxWidth: 600, - maxHeight: .infinity, - alignment: .center - ) } @ViewBuilder @@ -145,33 +190,4 @@ struct FeedView: View { } } } - - func loadPosts() { - if self.isLoadingPosts { - return - } - - self.isLoadingPosts = true - if self.postsResponse.posts == [] { - self.page = 1 - } else { - self.page += 1 - } - - postService!.getAllPosts(page: self.page, sortType: self.selectedSort, listingType: self.selectedListing) { result in - switch result { - case .success(let postsResponse) : - self.postsResponse.posts += postsResponse.posts - self.isLoadingPosts = false - case .failure(let error): - print(error) - self.isLoadingPosts = false - } - } - } - - func reload() { - self.postsResponse.posts.removeAll() - loadPosts() - } }