From c5304a4902d5cca422200c663826e7bd6a702b6c Mon Sep 17 00:00:00 2001 From: Bosco Ho Date: Wed, 19 Jul 2023 14:15:34 -0700 Subject: [PATCH 1/5] - Refactor model logic into view model. --- Leomard/Views/FeedView.swift | 129 +++++++++++++++++++---------------- 1 file changed, 71 insertions(+), 58 deletions(-) diff --git a/Leomard/Views/FeedView.swift b/Leomard/Views/FeedView.swift index ab4f259..364f2b5 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") } } @@ -113,14 +150,19 @@ struct FeedView: View { // - TODO: `scrollProxy` is expensive and isn't being used, remove? ScrollViewReader { scrollProxy in List { - ForEach(postsResponse.posts, id: \.self) { postView in - PostUIView(postView: postView, shortBody: true, postService: self.postService!, myself: $myself, contentView: contentView) - .onAppear { - if postView == self.postsResponse.posts.last { - self.loadPosts() - } + ForEach(viewModel.postsResponse.posts, id: \.self) { postView in + PostUIView( + postView: postView, + shortBody: true, + postService: viewModel.postService, + myself: $myself, + contentView: contentView) + .onAppear { + if postView == viewModel.postsResponse.posts.last { + viewModel.loadPosts() } - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) Spacer() } } @@ -148,33 +190,4 @@ struct FeedView: View { .cornerRadius(4) } } - - 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() - } } From 35396367ff0185d269489b27144eb0454765cd71 Mon Sep 17 00:00:00 2001 From: Bosco Ho Date: Wed, 19 Jul 2023 14:15:55 -0700 Subject: [PATCH 2/5] - Wrap API request call in .userInitiated task. --- Leomard/Services/RequestHandler.swift | 140 +++++++++++++------------- 1 file changed, 71 insertions(+), 69 deletions(-) diff --git a/Leomard/Services/RequestHandler.swift b/Leomard/Services/RequestHandler.swift index f86e33d..495ce5a 100644 --- a/Leomard/Services/RequestHandler.swift +++ b/Leomard/Services/RequestHandler.swift @@ -28,82 +28,84 @@ final class RequestHandler { public func makeApiRequest(host: String, request: String, method: HTTPMethod, body: Codable? = nil, completion: @escaping (Result) -> Void) { - var urlString = "\(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)/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 { - 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() && method == .get { + let jwt = SessionStorage.getInstance.getCurrentSession()?.loginResponse.jwt + if !urlString.contains("?") { + urlString += "?auth=\(jwt!)" + } else { + urlString += "&auth=\(jwt!)" } - 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 } - } - - URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in - if let error = error { - completion(.failure(error)) + + guard let url = URL(string: urlString) else { + completion(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: nil))) return } - guard let httpResponse = response as? HTTPURLResponse else { - completion(.failure(NSError(domain: "Invalid response", 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: []) + } + 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 + } } - 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() + } } } From 35ac2be15cd9367e04215b2e6fd56c5b120e4a59 Mon Sep 17 00:00:00 2001 From: Bosco Ho Date: Thu, 20 Jul 2023 20:37:23 -0700 Subject: [PATCH 3/5] - *WIP* Move task in PostUIView off main thread. --- Leomard/Views/Components/PostUIView.swift | 77 ++++++++++++++++------- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/Leomard/Views/Components/PostUIView.swift b/Leomard/Views/Components/PostUIView.swift index ce9c3c6..3155a14 100644 --- a/Leomard/Views/Components/PostUIView.swift +++ b/Leomard/Views/Components/PostUIView.swift @@ -76,27 +76,28 @@ struct PostUIView: View { .task { self.postBody = self.postView.post.body - if self.postBody != nil { - self.postBody = postBody!.replacingOccurrences(of: "\r", with: "
") - let regex = try! NSRegularExpression(pattern: #"!\[\]\((.*?)\)"#, options: .caseInsensitive) - let range = NSRange(location: 0, length: postBody!.utf16.count) - - let matches = regex.matches(in: postBody!, 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]) - 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**" - } + self.postBody = await postBodyTask() +// if self.postBody != nil { +// self.postBody = postBody!.replacingOccurrences(of: "\r", with: "
") +// let regex = try! NSRegularExpression(pattern: #"!\[\]\((.*?)\)"#, options: .caseInsensitive) +// let range = NSRange(location: 0, length: postBody!.utf16.count) +// +// let matches = regex.matches(in: postBody!, 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]) +// 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!) @@ -117,6 +118,40 @@ struct PostUIView: View { } } + nonisolated + private func postBodyTask() async -> String? { + return await withCheckedContinuation { continuation in + Task(priority: .background) { + if Thread.isMainThread { + print("WARNING: running on main thread") + } + var newPostBody = await self.postBody + if newPostBody != nil { + newPostBody = newPostBody!.replacingOccurrences(of: "\r", with: "
") + let regex = try! NSRegularExpression(pattern: #"!\[\]\((.*?)\)"#, options: .caseInsensitive) + let range = NSRange(location: 0, length: newPostBody!.utf16.count) + + 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: newPostBody!) { + let imageUrl = String(newPostBody![urlRange]) + imageUrls.append(imageUrl) + } + } + } + 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) + } + } + } + // MARK: - @ViewBuilder From d909b48c024a1ee3d427eb08684f5b721e25b3eb Mon Sep 17 00:00:00 2001 From: Bosco Ho Date: Fri, 21 Jul 2023 16:14:24 -0700 Subject: [PATCH 4/5] - Move more logic into background tasks (K.I.S.S. for now). --- Leomard/Views/Components/PostUIView.swift | 69 +++++++++++------------ 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/Leomard/Views/Components/PostUIView.swift b/Leomard/Views/Components/PostUIView.swift index 3155a14..1aca076 100644 --- a/Leomard/Views/Components/PostUIView.swift +++ b/Leomard/Views/Components/PostUIView.swift @@ -75,39 +75,9 @@ struct PostUIView: View { ) .task { - self.postBody = self.postView.post.body - self.postBody = await postBodyTask() -// if self.postBody != nil { -// self.postBody = postBody!.replacingOccurrences(of: "\r", with: "
") -// let regex = try! NSRegularExpression(pattern: #"!\[\]\((.*?)\)"#, options: .caseInsensitive) -// let range = NSRange(location: 0, length: postBody!.utf16.count) -// -// let matches = regex.matches(in: postBody!, 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]) -// 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!) - } + postBody = await postBodyTask() + url = await postUrlTask() + updatedTimeAsString = await updatedTimeAsStringTask() } .onTapGesture { self.contentView.openPost(postView: self.postView) @@ -122,10 +92,7 @@ struct PostUIView: View { private func postBodyTask() async -> String? { return await withCheckedContinuation { continuation in Task(priority: .background) { - if Thread.isMainThread { - print("WARNING: running on main thread") - } - var newPostBody = await self.postBody + var newPostBody = await self.postView.post.body if newPostBody != nil { newPostBody = newPostBody!.replacingOccurrences(of: "\r", with: "
") let regex = try! NSRegularExpression(pattern: #"!\[\]\((.*?)\)"#, options: .caseInsensitive) @@ -152,6 +119,34 @@ struct PostUIView: View { } } + 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)) + } + } + } + + 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) + } + } + } + // MARK: - @ViewBuilder From 9bb25880a72165443f7651cb5313eed4bb871997 Mon Sep 17 00:00:00 2001 From: Athlon007 Date: Sat, 22 Jul 2023 12:46:17 +0200 Subject: [PATCH 5/5] Fix merge conflict --- Leomard/Services/RequestHandler.swift | 36 +++++++++------------------ Leomard/Views/FeedView.swift | 1 + 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/Leomard/Services/RequestHandler.swift b/Leomard/Services/RequestHandler.swift index 7590386..6d78803 100644 --- a/Leomard/Services/RequestHandler.swift +++ b/Leomard/Services/RequestHandler.swift @@ -27,19 +27,11 @@ 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) { - 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)" - } - - 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)" } if SessionStorage.getInstance.isSessionActive() && method == .get { @@ -53,17 +45,6 @@ final class RequestHandler { guard let url = URL(string: urlString) else { completion(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: nil))) - } - - 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 } @@ -109,6 +90,13 @@ final class RequestHandler { } } + // If headers are set, add them too. + 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)) diff --git a/Leomard/Views/FeedView.swift b/Leomard/Views/FeedView.swift index 2bd8363..2efcba9 100644 --- a/Leomard/Views/FeedView.swift +++ b/Leomard/Views/FeedView.swift @@ -170,6 +170,7 @@ struct FeedView: View { maxHeight: .infinity, alignment: .center ) + } } @ViewBuilder