First of all, this project is the Reddit client project written by SwiftUI. The project address here takes Model and Network as a separate Pacakge, which can be referenced in the project and divided into different packages for different functions or modules. In this case, we add static methods to the Model, return AnyPublisher, and let the Store process the results. For example:

extension Comment {
    public enum Sort: String.CaseIterable {
        case best = "confidence"
        case top, new, controversial, old, qa
    }
    
    static public func fetch(subreddit: String.id: String.sort: Sort = .top) -> AnyPublisher"[ListingResponse<Comment>].Never> {
        let params: [String: String] = ["sort": sort.rawValue]
        return API.shared.request(endpoint: .comments(name: subreddit, id: id), params: params)
            .subscribe(on: DispatchQueue.global())
            .replaceError(with: [])
            .eraseToAnyPublisher()
    }
    
    public mutating func vote(vote: Vote) -> AnyPublisher<NetworkResponse.Never> {
        switch vote {
        case .upvote:
            likes = true
        case .downvote:
            likes = false
        case .neutral:
            likes = nil
        }
        return API.shared.POST(endpoint: .vote,
                               params: ["id": name, "dir": "\(vote.rawValue)"])}public mutating func save(a) -> AnyPublisher<NetworkResponse.Never> {
        saved = true
        return API.shared.POST(endpoint: .save, params: ["id": name])
    }
    
    public mutating func unsave(a) -> AnyPublisher<NetworkResponse.Never> {
        saved = false
        return API.shared.POST(endpoint: .unsave, params: ["id": name])
    }
}
Copy the code

The interface Endpoint is used as an enum, so the structure is clear

public enum Endpoint {
    case subreddit(name: String, sort: String?).case subredditAbout(name: String)
    case subscribe
    case searchSubreddit
    case search
    case searchPosts(name: String)
    case comments(name: String, id: String)
    case accessToken
    case me, mineSubscriptions, mineMulti
    case vote, visits, save, unsave
    case userAbout(username: String)
    case userOverview(usernmame: String)
    case userSaved(username: String)
    case userSubmitted(username: String)
    case userComments(username: String)
    case trendingSubreddits
    
    func path(a) -> String {
        switch self {
        case let .subreddit(name, sort):
            if name = = "top" || name = = "best" || name = = "new" || name = = "rising" || name = = "hot" {
                return name
            } else if let sort = sort {
                return "r/\(name)/\(sort)"
            } else {
                return "r/\(name)"
            }
        case .searchSubreddit:
            return "api/search_subreddits"
        case .subscribe:
            return "api/subscribe"
        case let .comments(name, id):
            return "r/\(name)/comments/\(id)"
        case .accessToken:
            return "api/v1/access_token"
        case .me:
            return "api/v1/me"
        case .mineSubscriptions:
            return "subreddits/mine/subscriber"
        case .mineMulti:
            return "api/multi/mine"
        case let .subredditAbout(name):
            return "r/\(name)/about"
        case .vote:
            return "api/vote"
        case .visits:
            return "api/store_visits"
        case .save:
            return "api/save"
        case .unsave:
            return "api/unsave"
        case let .userAbout(username):
            return "user/\(username)/about"
        case let .userOverview(username):
            return "user/\(username)/overview"
        case let .userSaved(username):
            return "user/\(username)/saved"
        case let .userSubmitted(username):
            return "user/\(username)/submitted"
        case let .userComments(username):
            return "user/\(username)/comments"
        case .trendingSubreddits:
            return "api/trending_subreddits"
        case .search:
            return "search"
        case let .searchPosts(name):
            return "r/\(name)/search"}}}Copy the code

Error handling is also treated as an enum, where errors are handled:

public enum NetworkError: Error {
    case unknown(data: Data)
    case message(reason: String, data: Data)
    case parseError(reason: Error)
    case redditAPIError(error: RedditError, data: Data)
    
    static private let decoder = JSONDecoder(a)static func processResponse(data: Data.response: URLResponse) throws -> Data {
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.unknown(data: data)
        }
        if (httpResponse.statusCode = = 404) {
            throw NetworkError.message(reason: "Resource not found", data: data)
        }
        if 200 . 299 ~ = httpResponse.statusCode {
            return data
        } else {
            do {
                let redditError = try decoder.decode(RedditError.self, from: data)
                throw NetworkError.redditAPIError(error: redditError, data: data)
            } catch _ {
                throw NetworkError.unknown(data: data)
            }
        }
    }
}

Copy the code

TryMap: tryMap: tryMap: tryMap: tryMap: tryMap: tryMap: tryMap

.tryMap{ data, response in
            return try NetworkError.processResponse(data: data, response: response)
        }
      
Copy the code

User authentication is handled in a separate class, including logging in, logging out, and refreshing tokens:

public class OauthClient: ObservableObject {
    public enum State: Equatable {
        case signedOut
        case refreshing, signinInProgress
        case authenthicated(authToken: String)}struct AuthTokenResponse: Decodable {
        let accessToken: String
        let tokenType: String
        let refreshToken: String?
    }
    
    static public let shared = OauthClient(a)@Published public var authState = State.refreshing
    
    // Oauth URL
    private let baseURL = "https://www.reddit.com/api/v1/authorize"
    private let secrets: [String: AnyObject]?
    private let scopes = ["mysubreddits"."identity"."edit"."save"."vote"."subscribe"."read"."submit"."history"."privatemessages"]
    private let state = UUID().uuidString
    private let redirectURI = "redditos://auth"
    private let duration = "permanent"
    private let type = "code"
    
    // Keychain
    private let keychainService = "com.thomasricouard.RedditOs-reddit-token"
    private let keychainAuthTokenKey = "auth_token"
    private let keychainAuthTokenRefreshToken = "refresh_auth_token"
    
    // Request
    private var requestCancellable: AnyCancellable?
    private var refreshCancellable: AnyCancellable?
    
    private var refreshTimer: Timer?
    
    init(a) {
        if let path = Bundle.module.path(forResource: "secrets", ofType: "plist"),
           let secrets = NSDictionary(contentsOfFile: path) as? [String: AnyObject] {
            self.secrets = secrets
        } else {
            self.secrets = nil
            print("Error: No secrets file found, you won't be able to login on Reddit")}let keychain = Keychain(service: keychainService)
        if let refreshToken = keychain[keychainAuthTokenRefreshToken] {
            authState = .refreshing
            DispatchQueue.main.async {
                self.refreshToken(refreshToken: refreshToken)
            }
        } else {
            authState = .signedOut
        }
        
        // Refresh every 30 minutes
        refreshTimer = Timer.scheduledTimer(withTimeInterval: 60.0 * 30, repeats: true) { _ in
            switch self.authState {
            case .authenthicated(_) :let keychain = Keychain(service: self.keychainService)
                if let refresh = keychain[self.keychainAuthTokenRefreshToken] {
                    self.refreshToken(refreshToken: refresh)
                }
            default:
                break}}}public func startOauthFlow(a) -> URL? {
        guard let clientId = secrets?["client_id"] as? String else {
            return nil
        }
        
        authState = .signinInProgress
        
        return URL(string: baseURL)!
            .appending("client_id", value: clientId)
            .appending("response_type", value: type)
            .appending("state", value: state)
            .appending("redirect_uri", value: redirectURI)
            .appending("duration", value: duration)
            .appending("scope", value: scopes.joined(separator: ""))}public func handleNextURL(url: URL) {
        if url.absoluteString.hasPrefix(redirectURI),
           url.queryParameters?.first(where: { $0.value = = state }) ! = nil.let code = url.queryParameters?.first(where: { $0.key = = type }){
            authState = .signinInProgress
            requestCancellable = makeOauthPublisher(code: code.value)?
                .receive(on: DispatchQueue.main)
                .sink(receiveCompletion: { _ in },
                receiveValue: { response in
                    let keychain = Keychain(service: self.keychainService)
                    keychain[self.keychainAuthTokenKey] = response.accessToken
                    keychain[self.keychainAuthTokenRefreshToken] = response.refreshToken
                    self.authState = .authenthicated(authToken: response.accessToken)
                })
        }
    }
    
    public func logout(a) {
        authState = .signedOut
        let keychain = Keychain(service: keychainService)
        keychain[keychainAuthTokenKey] = nil
        keychain[keychainAuthTokenRefreshToken] = nil
    }
    
    private func refreshToken(refreshToken: String) {
        refreshCancellable = makeRefreshOauthPublisher(refreshToken: refreshToken)?
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { _ in },
            receiveValue: { response in
                self.authState = .authenthicated(authToken: response.accessToken)
                let keychain = Keychain(service: self.keychainService)
                keychain[self.keychainAuthTokenKey] = response.accessToken
            })
    }
    
    private func makeOauthPublisher(code: String) -> AnyPublisher<AuthTokenResponse.NetworkError>? {
        let params: [String: String] = ["code": code,
                                        "grant_type": "authorization_code"."redirect_uri": redirectURI]
        return API.shared.request(endpoint: .accessToken,
                                  basicAuthUser: secrets?["client_id"] as? String,
                                  httpMethod: "POST",
                                  isJSONEndpoint: false,
                                  queryParamsAsBody: true,
                                  params: params).eraseToAnyPublisher()
    }
    
    private func makeRefreshOauthPublisher(refreshToken: String) -> AnyPublisher<AuthTokenResponse.NetworkError>? {
        let params: [String: String] = ["grant_type": "refresh_token"."refresh_token": refreshToken]
        return API.shared.request(endpoint: .accessToken,
                                  basicAuthUser: secrets?["client_id"] as? String,
                                  httpMethod: "POST",
                                  isJSONEndpoint: false,
                                  queryParamsAsBody: true,
                                  params: params).eraseToAnyPublisher()
    }
}

Copy the code

For Data persistence, the Data is stored directly:

import Foundation

fileprivate let decoder = JSONDecoder(a)fileprivate let encoder = JSONEncoder(a)fileprivate let saving_queue = DispatchQueue(label: "redditOS.savingqueue", qos: .background)
// The protocol to save the data
protocol PersistentDataStore {
    // Need the data type, save the file name. Then there are the ways to save and get
    associatedtype DataType: Codable
    var persistedDataFilename: String { get }
    func persistData(data: DataType)
    func restorePersistedData(a) -> DataType?
}

extension PersistentDataStore {
    func persistData(data: DataType) {
        saving_queue.async {
            do {
                let filePath = try FileManager.default.url(for: .documentDirectory,
                                                       in: .userDomainMask,
                                                       appropriateFor: nil,
                                                       create: false)
                    .appendingPathComponent(persistedDataFilename)
                let archive = try encoder.encode(data)
                try archive.write(to: filePath, options: .atomicWrite)
            } catch let error {
                print("Error while saving: \(error.localizedDescription)")}}}func restorePersistedData(a) -> DataType? {
        do {
            let filePath = try FileManager.default.url(for: .documentDirectory,
                                                   in: .userDomainMask,
                                                   appropriateFor: nil,
                                                   create: false)
                .appendingPathComponent(persistedDataFilename)
            if let data = try? Data(contentsOf: filePath) {
                return try decoder.decode(DataType.self, from: data)
            }
        } catch let error {
            print("Error while loading: \(error.localizedDescription)")}return nil}}Copy the code

Places that implement this protocol can be persisted, for example:

import Foundation
import SwiftUI
import Combine

public class CurrentUserStore: ObservableObject.PersistentDataStore {
    
    public static let shared = CurrentUserStore(a)@Published public private(set) var user: User? {
        didSet {
            saveUser()
        }
    }
    
    @Published public private(set) var subscriptions: [Subreddit] = [] {
        didSet {
            saveUser()
        }
    }
    
    @Published public private(set) var multi: [Multi] = [] {
        didSet {
            saveUser()
        }
    }
    
    @Published public private(set) var isRefreshingSubscriptions = false
    
    @Published public private(set) var overview: [GenericListingContent]?
    @Published public private(set) var savedPosts: [SubredditPost]?
    @Published public private(set) var submittedPosts: [SubredditPost]?
    
    private var subscriptionFetched = false
    private var fetchingSubscriptions: [Subreddit] = [] {
        didSet {
            isRefreshingSubscriptions = !fetchingSubscriptions.isEmpty
        }
    }
    
    private var disposables: [AnyCancellable? ]= []
    private var authStateCancellable: AnyCancellable?
    private var afterOverview: String?
    
    let persistedDataFilename = "CurrentUserData"
    typealias DataType = SaveData
    struct SaveData: Codable {
        let user: User?
        let subscriptions: [Subreddit]
        let multi: [Multi]}public init(a) {
        if let data = restorePersistedData() {
            subscriptions = data.subscriptions
            user = data.user
        }
        authStateCancellable = OauthClient.shared.$authState.sink(receiveValue: { state in
            switch state {
            case .signedOut:
                self.user = nil
            case .authenthicated:
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    self.refreshUser()
                    if !self.subscriptionFetched {
                        self.subscriptionFetched = true
                        self.fetchSubscription(after: nil)
                        self.fetchMulti()
                    }
                }
            default:
                break}})}private func saveUser(a) {
        persistData(data: .init(user: user,
                                subscriptions: subscriptions,
                                multi: multi))
    }
    
    private func refreshUser(a) {
        let cancellable = User.fetchMe()?
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { error in
                print(error)
            }, receiveValue: { user in
                self.user = user
            })
        disposables.append(cancellable)
    }
    
    private func fetchSubscription(after: String?). {
        let cancellable = Subreddit.fetchMine(after: after)
            .receive(on: DispatchQueue.main)
            .sink { subs in
                if let subscriptions = subs.data?.children {
                    let news = subscriptions.map{ $0.data }
                    self.fetchingSubscriptions.append(contentsOf: news)
                }
                if let after = subs.data?.after {
                    self.fetchSubscription(after: after)
                } else {
                    self.fetchingSubscriptions.sort{ $0.displayName.lowercased() < The $1.displayName.lowercased() }
                    self.subscriptions = self.fetchingSubscriptions
                    self.fetchingSubscriptions = []
                }
            }
        disposables.append(cancellable)
    }
    
    private func fetchMulti(a) {
        let cancellable = user?.fetchMulti()
            .receive(on: DispatchQueue.main)
            .sink{ listings in
                self.multi = listings.map{ $0.data }
            }
        disposables.append(cancellable)
    }
    
    public func fetchSaved(after: SubredditPost?). {
        let cancellable = user?.fetchSaved(after: after)
            .receive(on: DispatchQueue.main)
            .map{ $0.data?.children.map{ $0.data }}
            .sink{ listings in
                if self.savedPosts?.last ! = nil.let listings = listings {
                    self.savedPosts?.append(contentsOf: listings)
                } else if self.savedPosts = = nil {
                    self.savedPosts = listings
                }
            }
        disposables.append(cancellable)
    }
    
    public func fetchSubmitted(after: SubredditPost?). {
        let cancellable = user?.fetchSubmitted(after: after)
            .receive(on: DispatchQueue.main)
            .map{ $0.data?.children.map{ $0.data }}
            .sink{ listings in
                if self.submittedPosts?.last ! = nil.let listings = listings {
                    self.submittedPosts?.append(contentsOf: listings)
                } else if self.submittedPosts = = nil {
                    self.submittedPosts = listings
                }
            }
        disposables.append(cancellable)
    }
    
    public func fetchOverview(a) {
        let cancellable = user?.fetchOverview(after: afterOverview)
            .receive(on: DispatchQueue.main)
            .sink{ content in
                self.afterOverview = content.data?.after
                let listings = content.data?.children.map{ $0.data }
                if self.overview?.last ! = nil.let listings = listings {
                    self.overview?.append(contentsOf: listings)
                } else if self.overview = = nil {
                    self.overview = listings
                }
            }
        disposables.append(cancellable)
    }
    
}

Copy the code

Instead of having a single unified Store like objc’s SwiftUI and Combine programs, there are multiple:

WindowGroup {
            NavigationView {
                SidebarView(a)ProgressView(a)PostNoSelectionPlaceholder()
                .toolbar {
                    PostDetailToolbar(shareURL: nil)
                }
            }
            .frame(minWidth: 1300, minHeight: 600)
            .environmentObject(localData)
            .environmentObject(OauthClient.shared)
            .environmentObject(CurrentUserStore.shared)
            .environmentObject(uiState)
            .environmentObject(searchText)
            .onOpenURL { url in
                OauthClient.shared.handleNextURL(url: url)
            }
            .sheet(item: $uiState.presentedSheetRoute, content: { $0.makeView() })
        }
Copy the code

You can see that there are multiple environmentObject, with a clear division of labor. Here for openURL processing is to refresh the token, re-authentication:

.onOpenURL { url in
                OauthClient.shared.handleNextURL(url: url)
            }
Copy the code

The unified management of pop-up boxes is in the UIState presentedSheetRoute, which pops different boxes according to the Route:

.sheet(item: $uiState.presentedSheetRoute, content: { $0.makeView() })
Copy the code

Route is implemented as follows:

import Foundation
import SwiftUI
import Combine
import Backend

enum Route: Identifiable.Hashable {
    static func = = (lhs: Route.rhs: Route) -> Bool {
        lhs.id = = rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    case user(user: User)
    case subreddit(subreddit: String)
    case defaultChannel(chanel: UIState.DefaultChannels)
    case searchPostsResult
    
    var id: String {
        switch self {
        case let .user(user):
            return user.id
        case let .subreddit(subreddit):
            return subreddit
        case let .defaultChannel(chanel):
            return chanel.rawValue
        case .searchPostsResult:
            return "searchPostsResult"}}@ViewBuilder
    func makeView(a) -> some View {
        switch self {
        case let .user(user):
            UserSheetView(user: user)
        case let .subreddit(subreddit):
            SubredditPostsListView(name: subreddit)
                .equatable()
        case let .defaultChannel(chanel):
            SubredditPostsListView(name: chanel.rawValue)
                .equatable()
        case .searchPostsResult:
            QuickSearchPostsResultView()}}}Copy the code

When using, just assign the value directly:

uiState.presentedSheetRoute = .user(user: user)
Copy the code

Particularly important here is the presentation of comments, which has a clever recursive call:

RecursiveView(data: viewModel.comments ?? placeholderComments,
                      children: \.repliesComments) { comment in
            CommentRow(comment: comment,
                       isRoot: comment.parentId = = "t3_" + viewModel.post.id || viewModel.comments = = nil)
                .redacted(reason: viewModel.comments = = nil ? .placeholder : [])
        }
Copy the code

The recursive invocation here is implemented as follows:

import SwiftUI

public struct RecursiveView<Data.RowContent> :View where Data: RandomAccessCollection.Data.Element: Identifiable.RowContent: View {
    let data: Data
    let children: KeyPath<Data.Element.Data? >let rowContent: (Data.Element) - >RowContent
    
    public init(data: Data.children: KeyPath<Data.Element.Data? >,rowContent: @escaping (Data.Element) - >RowContent) {
        self.data = data
        self.children = children
        self.rowContent = rowContent
    }
    
    public var body: some View {
        ForEach(data) { child in
            if self.containsSub(child)  {
                CustomDisclosureGroup(content: {
                    RecursiveView(data: child[keyPath: children]!,
                                  children: children,
                                  rowContent: rowContent)
                        .padding(.leading, 8)
                }, label: {
                    rowContent(child)
                })
            } else {
                rowContent(child)
            }
        }
    }
    
    func containsSub(_ element: Data.Element) -> Bool {
        element[keyPath: children] ! = nil}}struct CustomDisclosureGroup<Label.Content> :View where Label: View.Content: View {
    @State var isExpanded: Bool = true
    var content: () -> Content
    var label: () -> Label
    
    var body: some View {
        HStack(alignment: .top, spacing: 8) {
            Image(systemName: "chevron.right")
                .rotationEffect(isExpanded ? .degrees(90) : .degrees(0))
                .padding(.top, 4)
                .onTapGesture {
                    isExpanded.toggle()
                }
            label()
        }
        if isExpanded {
            content()
        }
    }
}

Copy the code

It calls RecursiveView again