├── .github └── FUNDING.yml ├── .gitignore ├── Images ├── image1.png └── old.png ├── LICENSE ├── Packages ├── Backend │ ├── .gitignore │ ├── Package.swift │ ├── README.md │ ├── Sources │ │ └── Backend │ │ │ ├── Extensions │ │ │ ├── Int.swift │ │ │ ├── URL+StaticString.swift │ │ │ └── URL.swift │ │ │ ├── Models │ │ │ ├── Award.swift │ │ │ ├── Comment.swift │ │ │ ├── FlairRichText.swift │ │ │ ├── Listing.swift │ │ │ ├── Media.swift │ │ │ ├── Multi.swift │ │ │ ├── NetworkResponse.swift │ │ │ ├── RedditError.swift │ │ │ ├── Subreddit.swift │ │ │ ├── SubredditPost.swift │ │ │ ├── SubredditSmall.swift │ │ │ ├── TrendingSubreddits.swift │ │ │ ├── User.swift │ │ │ └── Vote.swift │ │ │ ├── Network │ │ │ ├── API.swift │ │ │ ├── Endpoint.swift │ │ │ ├── Models │ │ │ │ ├── Comment+Networking.swift │ │ │ │ ├── Subreddit+Networking.swift │ │ │ │ ├── SubredditPost+Networking.swift │ │ │ │ ├── TrendingSubreddits+Network.swift │ │ │ │ └── User+Networking.swift │ │ │ ├── NetworkError.swift │ │ │ └── OauthClient.swift │ │ │ ├── Protocols │ │ │ └── PersistentDataStore.swift │ │ │ ├── Resources │ │ │ └── Readme.md │ │ │ └── User │ │ │ ├── CurrentUserStore.swift │ │ │ └── LocalDataStore.swift │ └── Tests │ │ └── BackendTests │ │ ├── BackendTests.swift │ │ ├── Models │ │ └── AwardTests.swift │ │ └── XCTestManifests.swift └── UI │ ├── .gitignore │ ├── Package.swift │ ├── README.md │ ├── Sources │ └── UI │ │ ├── Hovered.swift │ │ └── RecursiveView.swift │ └── Tests │ └── UITests │ ├── UITests.swift │ └── XCTestManifests.swift ├── README.md ├── RedditOs.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── RedditOs.xcscheme ├── RedditOs ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── 1024.png │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 512.png │ │ ├── 64.png │ │ └── Contents.json │ ├── Contents.json │ ├── InvertedTextColor.colorset │ │ └── Contents.json │ ├── RedditBlue.colorset │ │ └── Contents.json │ ├── RedditGold.colorset │ │ └── Contents.json │ ├── RedditGray.colorset │ │ └── Contents.json │ └── TextColor.colorset │ │ └── Contents.json ├── Environements │ ├── Route.swift │ ├── SettingsKey.swift │ └── UIState.swift ├── Extensions │ ├── Array.swift │ ├── Color.swift │ └── NSTextField.swift ├── Features │ ├── Comments │ │ ├── CommentActionsView.swift │ │ ├── CommentRow.swift │ │ ├── CommentViewModel.swift │ │ └── CommentVoteView.swift │ ├── Post │ │ ├── PostDetail.swift │ │ ├── PostDetailActions.swift │ │ ├── PostDetailActionsView.swift │ │ ├── PostDetailCommentsSection.swift │ │ ├── PostDetailContent.swift │ │ ├── PostDetailHeader.swift │ │ ├── PostDetailToolbar.swift │ │ ├── PostDetailView.swift │ │ ├── PostDetailViewModel.swift │ │ └── PostViewModel.swift │ ├── Profile │ │ ├── ProfileView.swift │ │ ├── SavedPostListView.swift │ │ ├── SavedPostsListView.swift │ │ └── SubmittedPostsListView.swift │ ├── Search │ │ ├── Quick │ │ │ ├── QuickSearchBar.swift │ │ │ ├── QuickSearchFullResultsView.swift │ │ │ ├── QuickSearchPostsResultView.swift │ │ │ ├── QuickSearchResultRow.swift │ │ │ ├── QuickSearchResultsView.swift │ │ │ └── QuickSearchState.swift │ │ ├── SearchSheet.swift │ │ ├── SearchSubredditsPopover.swift │ │ ├── Shared │ │ │ └── SearchBarView.swift │ │ └── Subreddit Search Popover │ │ │ ├── PopoverSearchSubredditRow.swift │ │ │ └── PopoverSearchSubredditView.swift │ ├── Settings │ │ ├── GeneralTabView.swift │ │ ├── SettingsView.swift │ │ └── SidebarTabView.swift │ ├── Sidebar │ │ ├── Sidebar.swift │ │ ├── SidebarItem.swift │ │ ├── SidebarMultiView.swift │ │ ├── SidebarSubredditRow.swift │ │ └── SidebarView.swift │ ├── Subreddit │ │ ├── SubredditAboutPopoverView.swift │ │ ├── SubredditPostRow.swift │ │ ├── SubredditPostThubnailView.swift │ │ ├── SubredditPostThumbnailView.swift │ │ ├── SubredditPostsListView.swift │ │ └── SubredditViewModel.swift │ └── Users │ │ ├── popover │ │ └── UserPopoverView.swift │ │ ├── shared │ │ ├── UserHeaderView.swift │ │ └── UserViewModel.swift │ │ └── sheet │ │ ├── UserSheetCommentsView.swift │ │ ├── UserSheetOverviewView.swift │ │ └── UserSheetView.swift ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── RedditOs.entitlements ├── RedditOsApp.swift ├── Representables │ ├── LinkPresentationRepresentable.swift │ └── SharingPicker.swift └── Shared │ ├── AwardView.swift │ ├── FlairView.swift │ ├── LoadingRow.swift │ ├── PostInfoView.swift │ ├── PostNoSelectionPlaceholder.swift │ ├── PostVoteView.swift │ └── PostsListView.swift └── Sketches └── AppIcon.sketch /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [dimillian] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | Packages/Backend/Sources/Backend/Resources/secrets.plist 92 | .DS_Store -------------------------------------------------------------------------------- /Images/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/Images/image1.png -------------------------------------------------------------------------------- /Images/old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/Images/old.png -------------------------------------------------------------------------------- /Packages/Backend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /Packages/Backend/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Backend", 8 | platforms: [ 9 | .macOS("11"), .iOS("14"), .tvOS("14"), .watchOS("7") 10 | ], 11 | products: [ 12 | .library( 13 | name: "Backend", 14 | targets: ["Backend"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.0"), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "Backend", 22 | dependencies: ["KeychainAccess"], 23 | resources: [.process("Resources")]), 24 | .testTarget( 25 | name: "BackendTests", 26 | dependencies: ["Backend"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /Packages/Backend/README.md: -------------------------------------------------------------------------------- 1 | # Backend 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Extensions/Int.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Int { 11 | public func toRoundedSuffixAsString() -> String { 12 | var number = Double(self) 13 | number = fabs(number); 14 | if (number < 1000.0){ 15 | return "\(Int(number))"; 16 | } 17 | let exp: Int = Int(log10(number) / 3.0 ); 18 | let units: [String] = ["K","M","G","T","P","E"]; 19 | let roundedNum: Double = round(10 * number / pow(1000.0, Double(exp))) / 10; 20 | return "\(roundedNum)\(units[exp-1])"; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Extensions/URL+StaticString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+StaticString.swift 3 | // 4 | // 5 | // Created by Dan Korkelia on 01/01/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URL { 11 | /// Use this init for static URL strings to avoid using force unwrap or doing redundant error handling 12 | /// - Parameter string: static url ie https://www.example.com/privacy/ 13 | init(staticString: StaticString) { 14 | guard let url = URL(string: "\(staticString)") else { 15 | fatalError("URL is illegal: \(staticString)") 16 | } 17 | self = url 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Extensions/URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URL { 11 | public func appending(_ queryItem: String, value: String?) -> URL { 12 | guard var urlComponents = URLComponents(string: absoluteString) else { return absoluteURL } 13 | var queryItems: [URLQueryItem] = urlComponents.queryItems ?? [] 14 | let queryItem = URLQueryItem(name: queryItem, value: value) 15 | queryItems.append(queryItem) 16 | urlComponents.queryItems = queryItems 17 | return urlComponents.url! 18 | } 19 | 20 | public var queryParameters: [String: String]? { 21 | guard 22 | let components = URLComponents(url: self, resolvingAgainstBaseURL: true), 23 | let queryItems = components.queryItems else { return nil } 24 | return queryItems.reduce(into: [String: String]()) { (result, item) in 25 | result[item.name] = item.value 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/Award.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 05/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Award: Decodable, Identifiable { 11 | public let id: String 12 | public let name: String 13 | public let staticIconUrl: URL 14 | public let description: String 15 | public let count: Int 16 | public let coinPrice: Int 17 | 18 | public static let `default` = Award(id: "award", 19 | name: "Awesome", 20 | staticIconUrl: URL(staticString: "https://i.redd.it/award_images/t5_22cerq/5smbysczm1w41_Hugz.png"), 21 | description: "Awesome reward", 22 | count: 5, 23 | coinPrice: 200) 24 | } 25 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/Comment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension ListingResponse where T == Comment { 11 | public var comments: [Comment] { 12 | data?.children.map{ $0.data } ?? [] 13 | } 14 | } 15 | 16 | public struct Comment: Decodable, Identifiable { 17 | public let id: String 18 | public let name: String 19 | public let body: String? 20 | public let isSubmitter: Bool? 21 | public let author: String? 22 | public let lindId: String? 23 | public let parentId: String? 24 | public let created: Date? 25 | public let createdUtc: Date? 26 | public let replies: Replies? 27 | public var score: Int? 28 | public var likes: Bool? 29 | public let allAwardings: [Award]? 30 | public var saved: Bool? 31 | 32 | public let permalink: String? 33 | public var permalinkURL: URL? { 34 | guard let permalink = permalink else { return nil } 35 | return URL(string: "https://reddit.com\(permalink)") 36 | } 37 | 38 | public let authorFlairRichtext: [FlairRichText]? 39 | public let authorFlairText: String? 40 | public let authorFlairTextColor: String? 41 | public let authorFlairBackgroundColor: String? 42 | 43 | public var repliesComments: [Comment]? { 44 | if let replies = replies { 45 | switch replies { 46 | case let .some(replies): 47 | return replies.data?.children.map{ $0.data } 48 | default: 49 | return nil 50 | } 51 | } 52 | return nil 53 | } 54 | } 55 | 56 | public enum Replies: Decodable { 57 | public init(from decoder: Decoder) throws { 58 | let container = try decoder.singleValueContainer() 59 | do { 60 | self = .some(try container.decode(ListingResponse.self)) 61 | } catch { 62 | self = .none(try container.decode(String.self)) 63 | } 64 | } 65 | 66 | case some(ListingResponse) 67 | case none(String) 68 | } 69 | 70 | public let static_comment = Comment(id: UUID().uuidString, 71 | name: "t1_id", 72 | body: "Comment text with a long line of text\nThis is another line.", 73 | isSubmitter: false, 74 | author: "TestUser", 75 | lindId: "", 76 | parentId: "", 77 | created: Date(), 78 | createdUtc: Date(), 79 | replies: .none(""), 80 | score: 2500, 81 | allAwardings: [], 82 | saved: false, 83 | permalink: "", 84 | authorFlairRichtext: nil, 85 | authorFlairText: nil, 86 | authorFlairTextColor: nil, 87 | authorFlairBackgroundColor: nil) 88 | public let static_comments = [static_comment, static_comment, static_comment] 89 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/FlairRichText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 10/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct FlairRichText: Decodable, Hashable { 11 | public func hash(into hasher: inout Hasher) { 12 | hasher.combine(e) 13 | hasher.combine(u) 14 | hasher.combine(t) 15 | } 16 | 17 | /// type 18 | public let e: String 19 | /// image URL 20 | public let u: URL? 21 | /// text 22 | public let t: String? 23 | } 24 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/Listing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ListingResponse: Decodable { 4 | public let kind: String? 5 | public let data: ListingData? 6 | public let errorMessage: String? 7 | 8 | public init(error: String) { 9 | self.errorMessage = error 10 | self.kind = nil 11 | self.data = nil 12 | } 13 | } 14 | 15 | public struct ListingData: Decodable { 16 | public let modhash: String? 17 | public let dist: Int? 18 | public let after: String? 19 | public let before: String? 20 | public let children: [ListingHolder] 21 | } 22 | 23 | public struct ListingHolder: Decodable { 24 | public let kind: String 25 | public let data: T 26 | 27 | enum CodingKeys : CodingKey { 28 | case kind, data 29 | } 30 | 31 | public init(from decoder: Decoder) throws { 32 | let container = try decoder.container(keyedBy: CodingKeys.self) 33 | kind = try container.decode(String.self, forKey: .kind) 34 | if T.self == GenericListingContent.self { 35 | data = try GenericListingContent(from: decoder) as! T 36 | } else { 37 | data = try container.decode(T.self, forKey: .data) 38 | } 39 | } 40 | } 41 | 42 | public enum GenericListingContent: Decodable, Identifiable { 43 | public var id: String { 44 | switch self { 45 | case let .post(post): 46 | return post.id 47 | case let .comment(comment): 48 | return comment.id 49 | default: 50 | return UUID().uuidString 51 | } 52 | } 53 | 54 | public var post: SubredditPost? { 55 | if case .post(let post) = self { 56 | return post 57 | } 58 | return nil 59 | } 60 | 61 | public var comment: Comment? { 62 | if case .comment(let comment) = self { 63 | return comment 64 | } 65 | return nil 66 | } 67 | 68 | public init(from decoder: Decoder) throws { 69 | let container = try decoder.container(keyedBy: ListingHolder.CodingKeys.self) 70 | let kind = try container.decode(String.self, forKey: .kind) 71 | switch kind { 72 | case "t1": 73 | self = .comment(try container.decode(Comment.self, forKey: .data)) 74 | case "t3": 75 | self = .post(try container.decode(SubredditPost.self, forKey: .data)) 76 | default: 77 | self = .notSupported 78 | } 79 | } 80 | 81 | case post(SubredditPost) 82 | case comment(Comment) 83 | case notSupported 84 | } 85 | 86 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/Media.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 05/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct SecureMedia: Decodable { 11 | public let redditVideo: RedditVideo? 12 | public let oembed: Oembed? 13 | 14 | public var video: Video? { 15 | if let video = redditVideo { 16 | return Video(url: video.fallbackUrl, width: video.width, height: video.height) 17 | } else if oembed?.type == "video", 18 | let oembed = oembed, 19 | let url = oembed.url, 20 | let width = oembed.width, 21 | let height = oembed.height { 22 | return Video(url: url, width: width, height: height) 23 | } 24 | return nil 25 | } 26 | } 27 | 28 | public struct RedditVideo: Decodable { 29 | public let fallbackUrl: URL 30 | public let height: Int 31 | public let width: Int 32 | } 33 | 34 | public struct Oembed: Decodable { 35 | public let providerUrl: URL? 36 | public let thumbnailUrl: String? 37 | public var thumbnailUrlAsURL: URL? { 38 | thumbnailUrl != nil ? URL(string: thumbnailUrl!) : nil 39 | } 40 | public let url: URL? 41 | public let width: Int? 42 | public let height: Int? 43 | public let thumbnailWidth: Int? 44 | public let thumbnailHeight: Int? 45 | public let type: String? 46 | } 47 | 48 | public struct Video { 49 | public let url: URL 50 | public let width: Int 51 | public let height: Int 52 | } 53 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/Multi.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Multi: Codable, Identifiable { 4 | public struct Subreddit: Codable, Identifiable { 5 | public var id: String { 6 | name 7 | } 8 | public let name: String 9 | } 10 | 11 | public var id: String { 12 | path 13 | } 14 | 15 | public let displayName: String 16 | public let iconURL: String? 17 | public let subreddits: [Subreddit] 18 | public var subredditsAsName: String { 19 | return subreddits.map{ $0.name }.joined(separator: "+") 20 | } 21 | public let path: String 22 | } 23 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/NetworkResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 22/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct NetworkResponse: Decodable { 11 | public let error: RedditError? 12 | } 13 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/RedditError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 22/07/2020. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | public struct RedditError: Decodable { 12 | public let message: String? 13 | public let error: Int? 14 | 15 | static public func processNetworkError(error: NetworkError) -> RedditError { 16 | switch error { 17 | case let .redditAPIError(error, _): 18 | return error 19 | default: 20 | return RedditError(message: "Unknown error", error: -999) 21 | } 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/Subreddit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Subreddit: Codable, Identifiable { 4 | public let id: String 5 | public let name: String 6 | public let displayName: String 7 | public let title: String 8 | public let publicDescription: String? 9 | public let primaryColor: String? 10 | public let keyColor: String? 11 | public let bannerBackgroundColor: String? 12 | public let iconImg: String? 13 | public let bannerImg: String? 14 | public let subscribers: Int? 15 | public let accountsActive: Int? 16 | public let createdUtc: Date 17 | public let url: String 18 | public var redditURL: URL { 19 | URL(string: "https://reddit.com\(url)")! 20 | } 21 | public var userIsSubscriber: Bool? 22 | } 23 | 24 | public let static_subreddit_full = Subreddit(id: "games", 25 | name: "t3_fjfj", 26 | displayName: "games", 27 | title: "games", 28 | publicDescription: "a description", 29 | primaryColor: "#545452", 30 | keyColor: "#545452", 31 | bannerBackgroundColor: "#545452", 32 | iconImg: "https://a.thumbs.redditmedia.com/8hr1PTpJ9iWLNWP67vZN0w3IEP8uI3eAQ1kE4XLRg88.png", 33 | bannerImg: "https://a.thumbs.redditmedia.com/8hr1PTpJ9iWLNWP67vZN0w3IEP8uI3eAQ1kE4XLRg88.png", 34 | subscribers: 1000, 35 | accountsActive: 500, 36 | createdUtc: Date(), 37 | url: "/r/games", 38 | userIsSubscriber: false) 39 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/SubredditPost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct SubredditPost: Decodable, Identifiable, Hashable { 11 | public static func == (lhs: SubredditPost, rhs: SubredditPost) -> Bool { 12 | lhs.id == rhs.id && 13 | lhs.likes == rhs.likes && 14 | lhs.saved == rhs.saved 15 | } 16 | 17 | public func hash(into hasher: inout Hasher) { 18 | hasher.combine(id) 19 | } 20 | 21 | public let id: String 22 | public let name: String 23 | public let title: String 24 | public let numComments: Int 25 | public let subreddit: String 26 | public let thumbnail: String 27 | public let created: Date 28 | public let createdUtc: Date 29 | public var thumbnailURL: URL? { 30 | guard thumbnail.hasPrefix("http"), 31 | let url = URL(string: thumbnail) else { 32 | return nil 33 | } 34 | return url 35 | } 36 | public let author: String 37 | public let selftext: String? 38 | public let description: String? 39 | public var ups: Int 40 | public let downs: Int 41 | public let secureMedia: SecureMedia? 42 | public let url: String? 43 | public let permalink: String? 44 | 45 | public let linkFlairText: String? 46 | public let linkFlairRichtext: [FlairRichText]? 47 | public let linkFlairBackgroundColor: String? 48 | public let linkFlairTextColor: String? 49 | 50 | public let authorFlairRichtext: [FlairRichText]? 51 | public let authorFlairText: String? 52 | public let authorFlairTextColor: String? 53 | public let authorFlairBackgroundColor: String? 54 | 55 | public let allAwardings: [Award] 56 | public var visited: Bool 57 | public var saved: Bool 58 | public var redditURL: URL? { 59 | if let permalink = permalink, let url = URL(string: "https://reddit.com\(permalink)") { 60 | return url 61 | } 62 | return nil 63 | } 64 | public var likes: Bool? 65 | } 66 | 67 | public let static_listing = SubredditPost(id: "0", 68 | name: "t3_0", 69 | title: "A very long title to be able to debug the UI correctly as it should be displayed on mutliple lines.", 70 | numComments: 3400, 71 | subreddit: "preview", 72 | thumbnail: "", 73 | created: Date(), 74 | createdUtc: Date(), 75 | author: "TestUser", 76 | selftext: "A text", 77 | description: "A description", 78 | ups: 150 * 1000, 79 | downs: 30, 80 | secureMedia: nil, 81 | url: "https://test.com", 82 | permalink: nil, 83 | linkFlairText: nil, 84 | linkFlairRichtext: nil, 85 | linkFlairBackgroundColor: nil, 86 | linkFlairTextColor: nil, 87 | authorFlairRichtext: nil, 88 | authorFlairText: nil, 89 | authorFlairTextColor: nil, 90 | authorFlairBackgroundColor: nil, 91 | allAwardings: [], 92 | visited: false, 93 | saved: false, 94 | likes: nil) 95 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/SubredditSmall.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct SubredditResponse: Decodable { 4 | public let subreddits: [SubredditSmall] 5 | 6 | public init() { 7 | self.subreddits = [] 8 | } 9 | } 10 | 11 | public struct SubredditSmall: Codable, Identifiable, Equatable, Hashable { 12 | public static func == (lhs: SubredditSmall, rhs: SubredditSmall) -> Bool { 13 | lhs.id == rhs.id 14 | } 15 | 16 | public var id: String { name } 17 | public let name: String 18 | public let subscriberCount: Int 19 | public let activeUserCount: Int 20 | public let iconImg: String? 21 | public let keyColor: String? 22 | 23 | public static func makeSubredditSmall(with subreddit: Subreddit) -> SubredditSmall { 24 | SubredditSmall(name: subreddit.displayName, 25 | subscriberCount: subreddit.subscribers ?? 0, 26 | activeUserCount: subreddit.subscribers ?? 0, 27 | iconImg: subreddit.iconImg, 28 | keyColor: subreddit.keyColor) 29 | } 30 | } 31 | 32 | public let static_subreddit = SubredditSmall(name: "Games", 33 | subscriberCount: 10000, 34 | activeUserCount: 500, 35 | iconImg: "https://a.thumbs.redditmedia.com/8hr1PTpJ9iWLNWP67vZN0w3IEP8uI3eAQ1kE4XLRg88.png", 36 | keyColor: "#545452") 37 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/TrendingSubreddits.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct TrendingSubreddits: Decodable { 4 | public let subredditNames: [String] 5 | } 6 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/User.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct User: Codable, Identifiable { 4 | public let id: String 5 | public let name: String 6 | public let createdUtc: Date? 7 | public let iconImg: String? 8 | public var avatarURL: URL? { 9 | if let stringUrl = iconImg, 10 | let index = stringUrl.firstIndex(of: "?"), 11 | let url = URL(string: String(stringUrl.prefix(upTo: index))) { 12 | return url 13 | } 14 | return nil 15 | } 16 | public let linkKarma: Int 17 | public let commentKarma: Int 18 | } 19 | 20 | public let static_user = User(id: "0", name: "Test user", createdUtc: Date(), iconImg: nil, linkKarma: 12000, commentKarma: 200) 21 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Models/Vote.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 12/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum Vote: Int { 11 | case upvote = 1, downvote = -1, neutral = 0 12 | } 13 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Network/Endpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Endpoint { 4 | case subreddit(name: String, sort: String?) 5 | case subredditAbout(name: String) 6 | case subscribe 7 | case searchSubreddit 8 | case search 9 | case searchPosts(name: String) 10 | case comments(name: String, id: String) 11 | case accessToken 12 | case me, mineSubscriptions, mineMulti 13 | case vote, visits, save, unsave 14 | case userAbout(username: String) 15 | case userOverview(usernmame: String) 16 | case userSaved(username: String) 17 | case userSubmitted(username: String) 18 | case userComments(username: String) 19 | case trendingSubreddits 20 | 21 | func path() -> String { 22 | switch self { 23 | case let .subreddit(name, sort): 24 | if name == "top" || name == "best" || name == "new" || name == "rising" || name == "hot" { 25 | return name 26 | } else if let sort = sort { 27 | return "r/\(name)/\(sort)" 28 | } else { 29 | return "r/\(name)" 30 | } 31 | case .searchSubreddit: 32 | return "api/search_subreddits" 33 | case .subscribe: 34 | return "api/subscribe" 35 | case let .comments(name, id): 36 | return "r/\(name)/comments/\(id)" 37 | case .accessToken: 38 | return "api/v1/access_token" 39 | case .me: 40 | return "api/v1/me" 41 | case .mineSubscriptions: 42 | return "subreddits/mine/subscriber" 43 | case .mineMulti: 44 | return "api/multi/mine" 45 | case let .subredditAbout(name): 46 | return "r/\(name)/about" 47 | case .vote: 48 | return "api/vote" 49 | case .visits: 50 | return "api/store_visits" 51 | case .save: 52 | return "api/save" 53 | case .unsave: 54 | return "api/unsave" 55 | case let .userAbout(username): 56 | return "user/\(username)/about" 57 | case let .userOverview(username): 58 | return "user/\(username)/overview" 59 | case let .userSaved(username): 60 | return "user/\(username)/saved" 61 | case let .userSubmitted(username): 62 | return "user/\(username)/submitted" 63 | case let .userComments(username): 64 | return "user/\(username)/comments" 65 | case .trendingSubreddits: 66 | return "api/trending_subreddits" 67 | case .search: 68 | return "search" 69 | case let .searchPosts(name): 70 | return "r/\(name)/search" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Network/Models/Comment+Networking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 13/07/2020. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | extension Comment { 12 | public enum Sort: String, CaseIterable { 13 | case best = "confidence" 14 | case top, new, controversial, old, qa 15 | } 16 | 17 | static public func fetch(subreddit: String, id: String, sort: Sort = .top) -> AnyPublisher<[ListingResponse], Never> { 18 | let params: [String: String] = ["sort": sort.rawValue] 19 | return API.shared.request(endpoint: .comments(name: subreddit, id: id), params: params) 20 | .subscribe(on: DispatchQueue.global()) 21 | .replaceError(with: []) 22 | .eraseToAnyPublisher() 23 | } 24 | 25 | public mutating func vote(vote: Vote) -> AnyPublisher { 26 | switch vote { 27 | case .upvote: 28 | likes = true 29 | case .downvote: 30 | likes = false 31 | case .neutral: 32 | likes = nil 33 | } 34 | return API.shared.POST(endpoint: .vote, 35 | params: ["id": name, "dir": "\(vote.rawValue)"]) 36 | } 37 | 38 | public mutating func save() -> AnyPublisher { 39 | saved = true 40 | return API.shared.POST(endpoint: .save, params: ["id": name]) 41 | } 42 | 43 | public mutating func unsave() -> AnyPublisher { 44 | saved = false 45 | return API.shared.POST(endpoint: .unsave, params: ["id": name]) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Network/Models/Subreddit+Networking.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | extension Subreddit { 5 | static public func fetchMine(after: String?) -> AnyPublisher, Never> { 6 | var params: [String: String] = [:] 7 | if let after = after { 8 | params["after"] = after 9 | } 10 | params["limit"] = "100" 11 | return API.shared.request(endpoint: .mineSubscriptions, 12 | params: params) 13 | .subscribe(on: DispatchQueue.global()) 14 | .replaceError(with: ListingResponse(error: "error")) 15 | .eraseToAnyPublisher() 16 | } 17 | 18 | static public func fetchAbout(name: String) -> AnyPublisher?, Never> { 19 | API.shared.request(endpoint: .subredditAbout(name: name)) 20 | .subscribe(on: DispatchQueue.global()) 21 | .replaceError(with: nil) 22 | .eraseToAnyPublisher() 23 | } 24 | 25 | public mutating func subscribe() -> AnyPublisher { 26 | userIsSubscriber = true 27 | return API.shared.POST(endpoint: .subscribe, params: ["action": "sub", "sr": name]) 28 | } 29 | 30 | public mutating func unSubscribe() -> AnyPublisher { 31 | userIsSubscriber = false 32 | return API.shared.POST(endpoint: .subscribe, params: ["action": "unsub", "sr": name]) 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Network/Models/SubredditPost+Networking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 21/07/2020. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | extension SubredditPost { 12 | static public func fetch(subreddit: String, 13 | sort: String, 14 | after: String?) -> AnyPublisher, Never> { 15 | var params: [String: String] = [:] 16 | if let listing = after { 17 | params["after"] = "t3_\(listing)" 18 | } 19 | return API.shared.request(endpoint: .subreddit(name: subreddit, sort: sort), 20 | params: params) 21 | .subscribe(on: DispatchQueue.global()) 22 | .replaceError(with: ListingResponse(error: "error")) 23 | .eraseToAnyPublisher() 24 | } 25 | 26 | public mutating func vote(vote: Vote) -> AnyPublisher { 27 | switch vote { 28 | case .upvote: 29 | likes = true 30 | ups += 1 31 | case .downvote: 32 | likes = false 33 | ups -= 1 34 | case .neutral: 35 | if likes == true { 36 | ups -= 1 37 | } else if likes == false { 38 | ups += 1 39 | } 40 | likes = nil 41 | } 42 | return API.shared.POST(endpoint: .vote, 43 | params: ["id": name, "dir": "\(vote.rawValue)"]) 44 | } 45 | 46 | public mutating func visit() -> AnyPublisher { 47 | visited = true 48 | return API.shared.POST(endpoint: .visits, params: ["links": name]) 49 | } 50 | 51 | public mutating func save() -> AnyPublisher { 52 | saved = true 53 | return API.shared.POST(endpoint: .save, params: ["id": name]) 54 | } 55 | 56 | public mutating func unsave() -> AnyPublisher { 57 | saved = false 58 | return API.shared.POST(endpoint: .unsave, params: ["id": name]) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Network/Models/TrendingSubreddits+Network.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | extension TrendingSubreddits { 5 | public static func fetch() -> AnyPublisher { 6 | API.shared.request(endpoint: .trendingSubreddits, forceSignedOutURL: true) 7 | .subscribe(on: DispatchQueue.global()) 8 | .catch { _ in Empty(completeImmediately: false) } 9 | .eraseToAnyPublisher() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Network/Models/User+Networking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 24/07/2020. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | extension User { 12 | static public func fetchMe() -> AnyPublisher? { 13 | API.shared.request(endpoint: .me).eraseToAnyPublisher() 14 | } 15 | 16 | static public func fetchUserAbout(username: String) -> AnyPublisher, NetworkError>? { 17 | API.shared.request(endpoint: .userAbout(username: username)).eraseToAnyPublisher() 18 | } 19 | 20 | public func fetchOverview(after: String?) -> AnyPublisher, Never> { 21 | var params: [String: String] = ["limit" : "100"] 22 | if let after = after { 23 | params["after"] = after 24 | } 25 | return API.shared.request(endpoint: .userOverview(usernmame: name), 26 | params: params) 27 | .subscribe(on: DispatchQueue.global()) 28 | .replaceError(with: ListingResponse(error: "error")) 29 | .eraseToAnyPublisher() 30 | } 31 | 32 | public func fetchSaved(after: SubredditPost?) -> AnyPublisher, Never> { 33 | var params: [String: String] = ["type": "links"] 34 | if let listing = after { 35 | params["after"] = listing.name 36 | } 37 | return API.shared.request(endpoint: .userSaved(username: name), 38 | params: params) 39 | .subscribe(on: DispatchQueue.global()) 40 | .replaceError(with: ListingResponse(error: "error")) 41 | .eraseToAnyPublisher() 42 | } 43 | 44 | public func fetchSubmitted(after: SubredditPost?) -> AnyPublisher, Never> { 45 | var params: [String: String] = [:] 46 | if let listing = after { 47 | params["after"] = listing.name 48 | } 49 | return API.shared.request(endpoint: .userSubmitted(username: name), 50 | params: params) 51 | .subscribe(on: DispatchQueue.global()) 52 | .replaceError(with: ListingResponse(error: "error")) 53 | .eraseToAnyPublisher() 54 | } 55 | 56 | public func fetchComments(after: Comment?) -> AnyPublisher, Never> { 57 | var params: [String: String] = [:] 58 | if let listing = after { 59 | params["after"] = listing.name 60 | } 61 | return API.shared.request(endpoint: .userComments(username: name), 62 | params: params) 63 | .subscribe(on: DispatchQueue.global()) 64 | .replaceError(with: ListingResponse(error: "error")) 65 | .eraseToAnyPublisher() 66 | } 67 | 68 | public func fetchMulti() -> AnyPublisher<[ListingHolder], Never> { 69 | return API.shared.request(endpoint: .mineMulti) 70 | .subscribe(on: DispatchQueue.global()) 71 | .catch { _ in Empty(completeImmediately: false) } 72 | .eraseToAnyPublisher() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Network/NetworkError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum NetworkError: Error { 11 | case unknown(data: Data) 12 | case message(reason: String, data: Data) 13 | case parseError(reason: Error) 14 | case redditAPIError(error: RedditError, data: Data) 15 | 16 | static private let decoder = JSONDecoder() 17 | 18 | static func processResponse(data: Data, response: URLResponse) throws -> Data { 19 | guard let httpResponse = response as? HTTPURLResponse else { 20 | throw NetworkError.unknown(data: data) 21 | } 22 | if (httpResponse.statusCode == 404) { 23 | throw NetworkError.message(reason: "Resource not found", data: data) 24 | } 25 | if 200 ... 299 ~= httpResponse.statusCode { 26 | return data 27 | } else { 28 | do { 29 | let redditError = try decoder.decode(RedditError.self, from: data) 30 | throw NetworkError.redditAPIError(error: redditError, data: data) 31 | } catch _ { 32 | throw NetworkError.unknown(data: data) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Network/OauthClient.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import Foundation 4 | import KeychainAccess 5 | 6 | 7 | public class OauthClient: ObservableObject { 8 | public enum State: Equatable { 9 | case signedOut 10 | case refreshing, signinInProgress 11 | case authenthicated(authToken: String) 12 | } 13 | 14 | struct AuthTokenResponse: Decodable { 15 | let accessToken: String 16 | let tokenType: String 17 | let refreshToken: String? 18 | } 19 | 20 | static public let shared = OauthClient() 21 | 22 | @Published public var authState = State.refreshing 23 | 24 | // Oauth URL 25 | private let baseURL = "https://www.reddit.com/api/v1/authorize" 26 | private let secrets: [String: AnyObject]? 27 | private let scopes = ["mysubreddits", "identity", "edit", "save", 28 | "vote", "subscribe", "read", "submit", "history", 29 | "privatemessages"] 30 | private let state = UUID().uuidString 31 | private let redirectURI = "redditos://auth" 32 | private let duration = "permanent" 33 | private let type = "code" 34 | 35 | // Keychain 36 | private let keychainService = "com.thomasricouard.RedditOs-reddit-token" 37 | private let keychainAuthTokenKey = "auth_token" 38 | private let keychainAuthTokenRefreshToken = "refresh_auth_token" 39 | 40 | // Request 41 | private var requestCancellable: AnyCancellable? 42 | private var refreshCancellable: AnyCancellable? 43 | 44 | private var refreshTimer: Timer? 45 | 46 | init() { 47 | if let path = Bundle.module.path(forResource: "secrets", ofType: "plist"), 48 | let secrets = NSDictionary(contentsOfFile: path) as? [String: AnyObject] { 49 | self.secrets = secrets 50 | } else { 51 | self.secrets = nil 52 | print("Error: No secrets file found, you won't be able to login on Reddit") 53 | } 54 | 55 | let keychain = Keychain(service: keychainService) 56 | if let refreshToken = keychain[keychainAuthTokenRefreshToken] { 57 | authState = .refreshing 58 | DispatchQueue.main.async { 59 | self.refreshToken(refreshToken: refreshToken) 60 | } 61 | } else { 62 | authState = .signedOut 63 | } 64 | 65 | 66 | refreshTimer = Timer.scheduledTimer(withTimeInterval: 60.0 * 30, repeats: true) { _ in 67 | switch self.authState { 68 | case .authenthicated(_): 69 | let keychain = Keychain(service: self.keychainService) 70 | if let refresh = keychain[self.keychainAuthTokenRefreshToken] { 71 | self.refreshToken(refreshToken: refresh) 72 | } 73 | default: 74 | break 75 | } 76 | } 77 | } 78 | 79 | public func startOauthFlow() -> URL? { 80 | guard let clientId = secrets?["client_id"] as? String else { 81 | return nil 82 | } 83 | 84 | authState = .signinInProgress 85 | 86 | return URL(string: baseURL)! 87 | .appending("client_id", value: clientId) 88 | .appending("response_type", value: type) 89 | .appending("state", value: state) 90 | .appending("redirect_uri", value: redirectURI) 91 | .appending("duration", value: duration) 92 | .appending("scope", value: scopes.joined(separator: " ")) 93 | } 94 | 95 | public func handleNextURL(url: URL) { 96 | if url.absoluteString.hasPrefix(redirectURI), 97 | url.queryParameters?.first(where: { $0.value == state }) != nil, 98 | let code = url.queryParameters?.first(where: { $0.key == type }){ 99 | authState = .signinInProgress 100 | requestCancellable = makeOauthPublisher(code: code.value)? 101 | .receive(on: DispatchQueue.main) 102 | .sink(receiveCompletion: { _ in }, 103 | receiveValue: { response in 104 | let keychain = Keychain(service: self.keychainService) 105 | keychain[self.keychainAuthTokenKey] = response.accessToken 106 | keychain[self.keychainAuthTokenRefreshToken] = response.refreshToken 107 | self.authState = .authenthicated(authToken: response.accessToken) 108 | }) 109 | } 110 | } 111 | 112 | public func logout() { 113 | authState = .signedOut 114 | let keychain = Keychain(service: keychainService) 115 | keychain[keychainAuthTokenKey] = nil 116 | keychain[keychainAuthTokenRefreshToken] = nil 117 | } 118 | 119 | private func refreshToken(refreshToken: String) { 120 | refreshCancellable = makeRefreshOauthPublisher(refreshToken: refreshToken)? 121 | .receive(on: DispatchQueue.main) 122 | .sink(receiveCompletion: { _ in }, 123 | receiveValue: { response in 124 | self.authState = .authenthicated(authToken: response.accessToken) 125 | let keychain = Keychain(service: self.keychainService) 126 | keychain[self.keychainAuthTokenKey] = response.accessToken 127 | }) 128 | } 129 | 130 | private func makeOauthPublisher(code: String) -> AnyPublisher? { 131 | let params: [String: String] = ["code": code, 132 | "grant_type": "authorization_code", 133 | "redirect_uri": redirectURI] 134 | return API.shared.request(endpoint: .accessToken, 135 | basicAuthUser: secrets?["client_id"] as? String, 136 | httpMethod: "POST", 137 | isJSONEndpoint: false, 138 | queryParamsAsBody: true, 139 | params: params).eraseToAnyPublisher() 140 | } 141 | 142 | private func makeRefreshOauthPublisher(refreshToken: String) -> AnyPublisher? { 143 | let params: [String: String] = ["grant_type": "refresh_token", 144 | "refresh_token": refreshToken] 145 | return API.shared.request(endpoint: .accessToken, 146 | basicAuthUser: secrets?["client_id"] as? String, 147 | httpMethod: "POST", 148 | isJSONEndpoint: false, 149 | queryParamsAsBody: true, 150 | params: params).eraseToAnyPublisher() 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Protocols/PersistentDataStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 24/07/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | fileprivate let decoder = JSONDecoder() 11 | fileprivate let encoder = JSONEncoder() 12 | fileprivate let saving_queue = DispatchQueue(label: "redditOS.savingqueue", qos: .background) 13 | 14 | protocol PersistentDataStore { 15 | associatedtype DataType: Codable 16 | var persistedDataFilename: String { get } 17 | func persistData(data: DataType) 18 | func restorePersistedData() -> DataType? 19 | } 20 | 21 | extension PersistentDataStore { 22 | func persistData(data: DataType) { 23 | saving_queue.async { 24 | do { 25 | let filePath = try FileManager.default.url(for: .documentDirectory, 26 | in: .userDomainMask, 27 | appropriateFor: nil, 28 | create: false) 29 | .appendingPathComponent(persistedDataFilename) 30 | let archive = try encoder.encode(data) 31 | try archive.write(to: filePath, options: .atomicWrite) 32 | } catch let error { 33 | print("Error while saving: \(error.localizedDescription)") 34 | } 35 | } 36 | } 37 | 38 | func restorePersistedData() -> DataType? { 39 | do { 40 | let filePath = try FileManager.default.url(for: .documentDirectory, 41 | in: .userDomainMask, 42 | appropriateFor: nil, 43 | create: false) 44 | .appendingPathComponent(persistedDataFilename) 45 | if let data = try? Data(contentsOf: filePath) { 46 | return try decoder.decode(DataType.self, from: data) 47 | } 48 | } catch let error { 49 | print("Error while loading: \(error.localizedDescription)") 50 | } 51 | return nil 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/Resources/Readme.md: -------------------------------------------------------------------------------- 1 | This is compiled as part of the Backend package. In order to make the Oauth work from the open source clone of this project you would need to create a secrets.plist with a client_id key and your Reddit app client id as value in this folder. 2 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/User/CurrentUserStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import Combine 4 | 5 | public class CurrentUserStore: ObservableObject, PersistentDataStore { 6 | 7 | public static let shared = CurrentUserStore() 8 | 9 | @Published public private(set) var user: User? { 10 | didSet { 11 | saveUser() 12 | } 13 | } 14 | 15 | @Published public private(set) var subscriptions: [Subreddit] = [] { 16 | didSet { 17 | saveUser() 18 | } 19 | } 20 | 21 | @Published public private(set) var multi: [Multi] = [] { 22 | didSet { 23 | saveUser() 24 | } 25 | } 26 | 27 | @Published public private(set) var isRefreshingSubscriptions = false 28 | 29 | @Published public private(set) var overview: [GenericListingContent]? 30 | @Published public private(set) var savedPosts: [SubredditPost]? 31 | @Published public private(set) var submittedPosts: [SubredditPost]? 32 | 33 | private var subscriptionFetched = false 34 | private var fetchingSubscriptions: [Subreddit] = [] { 35 | didSet { 36 | isRefreshingSubscriptions = !fetchingSubscriptions.isEmpty 37 | } 38 | } 39 | 40 | private var disposables: [AnyCancellable?] = [] 41 | private var authStateCancellable: AnyCancellable? 42 | private var afterOverview: String? 43 | 44 | let persistedDataFilename = "CurrentUserData" 45 | typealias DataType = SaveData 46 | struct SaveData: Codable { 47 | let user: User? 48 | let subscriptions: [Subreddit] 49 | let multi: [Multi] 50 | } 51 | 52 | public init() { 53 | if let data = restorePersistedData() { 54 | subscriptions = data.subscriptions 55 | user = data.user 56 | } 57 | authStateCancellable = OauthClient.shared.$authState.sink(receiveValue: { state in 58 | switch state { 59 | case .signedOut: 60 | self.user = nil 61 | case .authenthicated: 62 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 63 | self.refreshUser() 64 | if !self.subscriptionFetched { 65 | self.subscriptionFetched = true 66 | self.fetchSubscription(after: nil) 67 | self.fetchMulti() 68 | } 69 | } 70 | default: 71 | break 72 | } 73 | }) 74 | } 75 | 76 | private func saveUser() { 77 | persistData(data: .init(user: user, 78 | subscriptions: subscriptions, 79 | multi: multi)) 80 | } 81 | 82 | private func refreshUser() { 83 | let cancellable = User.fetchMe()? 84 | .receive(on: DispatchQueue.main) 85 | .sink(receiveCompletion: { error in 86 | print(error) 87 | }, receiveValue: { user in 88 | self.user = user 89 | }) 90 | disposables.append(cancellable) 91 | } 92 | 93 | private func fetchSubscription(after: String?) { 94 | let cancellable = Subreddit.fetchMine(after: after) 95 | .receive(on: DispatchQueue.main) 96 | .sink { subs in 97 | if let subscriptions = subs.data?.children { 98 | let news = subscriptions.map{ $0.data } 99 | self.fetchingSubscriptions.append(contentsOf: news) 100 | } 101 | if let after = subs.data?.after { 102 | self.fetchSubscription(after: after) 103 | } else { 104 | self.fetchingSubscriptions.sort{ $0.displayName.lowercased() < $1.displayName.lowercased() } 105 | self.subscriptions = self.fetchingSubscriptions 106 | self.fetchingSubscriptions = [] 107 | } 108 | } 109 | disposables.append(cancellable) 110 | } 111 | 112 | private func fetchMulti() { 113 | let cancellable = user?.fetchMulti() 114 | .receive(on: DispatchQueue.main) 115 | .sink{ listings in 116 | self.multi = listings.map{ $0.data } 117 | } 118 | disposables.append(cancellable) 119 | } 120 | 121 | public func fetchSaved(after: SubredditPost?) { 122 | let cancellable = user?.fetchSaved(after: after) 123 | .receive(on: DispatchQueue.main) 124 | .map{ $0.data?.children.map{ $0.data }} 125 | .sink{ listings in 126 | if self.savedPosts?.last != nil, let listings = listings { 127 | self.savedPosts?.append(contentsOf: listings) 128 | } else if self.savedPosts == nil { 129 | self.savedPosts = listings 130 | } 131 | } 132 | disposables.append(cancellable) 133 | } 134 | 135 | public func fetchSubmitted(after: SubredditPost?) { 136 | let cancellable = user?.fetchSubmitted(after: after) 137 | .receive(on: DispatchQueue.main) 138 | .map{ $0.data?.children.map{ $0.data }} 139 | .sink{ listings in 140 | if self.submittedPosts?.last != nil, let listings = listings { 141 | self.submittedPosts?.append(contentsOf: listings) 142 | } else if self.submittedPosts == nil { 143 | self.submittedPosts = listings 144 | } 145 | } 146 | disposables.append(cancellable) 147 | } 148 | 149 | public func fetchOverview() { 150 | let cancellable = user?.fetchOverview(after: afterOverview) 151 | .receive(on: DispatchQueue.main) 152 | .sink{ content in 153 | self.afterOverview = content.data?.after 154 | let listings = content.data?.children.map{ $0.data } 155 | if self.overview?.last != nil, let listings = listings { 156 | self.overview?.append(contentsOf: listings) 157 | } else if self.overview == nil { 158 | self.overview = listings 159 | } 160 | } 161 | disposables.append(cancellable) 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /Packages/Backend/Sources/Backend/User/LocalDataStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | public class LocalDataStore: ObservableObject, PersistentDataStore { 5 | public static let shared = LocalDataStore() 6 | 7 | @Published public private(set) var favorites: [SubredditSmall] = [] { 8 | didSet { 9 | saveData() 10 | } 11 | } 12 | 13 | @Published public private(set) var recentlyVisited: [SubredditSmall] = [] { 14 | didSet { 15 | saveData() 16 | } 17 | } 18 | 19 | let persistedDataFilename = "redditOsData" 20 | typealias DataType = SavedData 21 | 22 | struct SavedData: Codable { 23 | let favorites: [SubredditSmall] 24 | let recentlyVisited: [SubredditSmall]? 25 | } 26 | 27 | public init() { 28 | let data = restorePersistedData() 29 | var favorites = data?.favorites ?? [] 30 | favorites.sort{ $0.name.lowercased() < $1.name.lowercased() } 31 | self.favorites = favorites 32 | self.recentlyVisited = data?.recentlyVisited ?? [] 33 | } 34 | 35 | // MARK: - Favorites management 36 | public func add(favorite: SubredditSmall) { 37 | if !favorites.contains(favorite) { 38 | favorites.append(favorite) 39 | } 40 | } 41 | 42 | public func add(recent: SubredditSmall) { 43 | guard !recentlyVisited.contains(recent) else { 44 | return 45 | } 46 | var edit = recentlyVisited 47 | edit.insert(recent, at: 0) 48 | if edit.count > 5 { 49 | edit.removeLast() 50 | } 51 | recentlyVisited = edit 52 | } 53 | 54 | public func remove(favorite: SubredditSmall) { 55 | favorites.removeAll(where: { $0 == favorite }) 56 | } 57 | 58 | public func remove(favoriteNamed: String) { 59 | favorites.removeAll(where: { $0.name == favoriteNamed }) 60 | } 61 | 62 | // MARK: - Private 63 | 64 | private func saveData() { 65 | persistData(data: SavedData(favorites: favorites, recentlyVisited: recentlyVisited)) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Packages/Backend/Tests/BackendTests/BackendTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Backend 3 | 4 | final class BackendTests: XCTestCase { 5 | 6 | } 7 | -------------------------------------------------------------------------------- /Packages/Backend/Tests/BackendTests/Models/AwardTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Backend 3 | 4 | final class AwardTests: XCTestCase { 5 | 6 | func test_defaultValueAreCorrect() { 7 | XCTAssertEqual(Award.default.id, "award") 8 | XCTAssertEqual(Award.default.name, "Awesome") 9 | XCTAssertEqual(Award.default.staticIconUrl, URL(staticString: "https://i.redd.it/award_images/t5_22cerq/5smbysczm1w41_Hugz.png")) 10 | XCTAssertEqual(Award.default.description, "Awesome reward") 11 | XCTAssertEqual(Award.default.count, 5) 12 | XCTAssertEqual(Award.default.coinPrice, 200) 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Packages/Backend/Tests/BackendTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(BackendTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Packages/UI/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /Packages/UI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "UI", 8 | platforms: [ 9 | .macOS("11"), .iOS("14"), .tvOS("14"), .watchOS("7") 10 | ], 11 | products: [ 12 | .library( 13 | name: "UI", 14 | targets: ["UI"]), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "UI", 19 | dependencies: []), 20 | .testTarget( 21 | name: "UITests", 22 | dependencies: ["UI"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /Packages/UI/README.md: -------------------------------------------------------------------------------- 1 | # UI 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Packages/UI/Sources/UI/Hovered.swift: -------------------------------------------------------------------------------- 1 | // Source: https://stackoverflow.com/questions/65841298/swiftui-onhover-doesnt-register-mouse-leaving-the-element-if-mouse-moves-too-fa 2 | 3 | import SwiftUI 4 | 5 | extension View { 6 | public func whenHovered(_ mouseIsInside: @escaping (Bool) -> Void) -> some View { 7 | modifier(MouseInsideModifier(mouseIsInside)) 8 | } 9 | } 10 | 11 | struct MouseInsideModifier: ViewModifier { 12 | let mouseIsInside: (Bool) -> Void 13 | 14 | init(_ mouseIsInside: @escaping (Bool) -> Void) { 15 | self.mouseIsInside = mouseIsInside 16 | } 17 | 18 | func body(content: Content) -> some View { 19 | content.background( 20 | GeometryReader { proxy in 21 | Representable(mouseIsInside: mouseIsInside, 22 | frame: proxy.frame(in: .global)) 23 | } 24 | ) 25 | } 26 | 27 | private struct Representable: NSViewRepresentable { 28 | let mouseIsInside: (Bool) -> Void 29 | let frame: NSRect 30 | 31 | func makeCoordinator() -> Coordinator { 32 | let coordinator = Coordinator() 33 | coordinator.mouseIsInside = mouseIsInside 34 | return coordinator 35 | } 36 | 37 | class Coordinator: NSResponder { 38 | var mouseIsInside: ((Bool) -> Void)? 39 | 40 | override func mouseEntered(with event: NSEvent) { 41 | mouseIsInside?(true) 42 | } 43 | 44 | override func mouseExited(with event: NSEvent) { 45 | mouseIsInside?(false) 46 | } 47 | } 48 | 49 | func makeNSView(context: Context) -> NSView { 50 | let view = NSView(frame: frame) 51 | 52 | let options: NSTrackingArea.Options = [ 53 | .mouseEnteredAndExited, 54 | .inVisibleRect, 55 | .activeInKeyWindow 56 | ] 57 | 58 | let trackingArea = NSTrackingArea(rect: frame, 59 | options: options, 60 | owner: context.coordinator, 61 | userInfo: nil) 62 | 63 | view.addTrackingArea(trackingArea) 64 | 65 | return view 66 | } 67 | 68 | func updateNSView(_ nsView: NSView, context: Context) {} 69 | 70 | static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { 71 | nsView.trackingAreas.forEach { nsView.removeTrackingArea($0) } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Packages/UI/Sources/UI/RecursiveView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Thomas Ricouard on 12/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct RecursiveView: View where Data: RandomAccessCollection, 11 | Data.Element: Identifiable, 12 | RowContent: View { 13 | let data: Data 14 | let children: KeyPath 15 | let rowContent: (Data.Element) -> RowContent 16 | 17 | public init(data: Data, children: KeyPath, rowContent: @escaping (Data.Element) -> RowContent) { 18 | self.data = data 19 | self.children = children 20 | self.rowContent = rowContent 21 | } 22 | 23 | public var body: some View { 24 | ForEach(data) { child in 25 | if self.containsSub(child) { 26 | CustomDisclosureGroup(content: { 27 | RecursiveView(data: child[keyPath: children]!, 28 | children: children, 29 | rowContent: rowContent) 30 | .padding(.leading, 8) 31 | }, label: { 32 | rowContent(child) 33 | }) 34 | } else { 35 | rowContent(child) 36 | } 37 | } 38 | } 39 | 40 | func containsSub(_ element: Data.Element) -> Bool { 41 | element[keyPath: children] != nil 42 | } 43 | } 44 | 45 | struct CustomDisclosureGroup: View where Label: View, Content: View { 46 | @State var isExpanded: Bool = true 47 | var content: () -> Content 48 | var label: () -> Label 49 | 50 | var body: some View { 51 | HStack(alignment: .top, spacing: 8) { 52 | Image(systemName: "chevron.right") 53 | .rotationEffect(isExpanded ? .degrees(90) : .degrees(0)) 54 | .padding(.top, 4) 55 | .onTapGesture { 56 | isExpanded.toggle() 57 | } 58 | label() 59 | } 60 | if isExpanded { 61 | content() 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Packages/UI/Tests/UITests/UITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import UI 3 | 4 | final class UITests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(UI().text, "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Packages/UI/Tests/UITests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(UITests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RedditOS 2 | A SwiftUI Reddit client for macOS 3 | 4 | ![Image](Images/image1.png?) 5 | 6 | ## About 7 | 8 | This project is about two things: 9 | 1. Building a good Reddit client. 10 | 2. Building a good macOS application using mostly SwiftUI. 11 | 12 | You'll need the latest version of Xcode 12 and macOS Big Sur to build it and enjoyt it. 13 | You can also download a pre built version in the release section if you don't want to build it youself. 14 | 15 | I'm planning to drop Big Sur in the near future to focus execlusively on SwiftUI 3 + macOS Monterey. 16 | SwiftUI 3 add a ton of features, polish and performance improvements that this application can't live without. 17 | 18 | ## Dev environment 19 | 20 | If you want to login with your Reddit account building the project from the source you'll need to create a file `secrets.plist` in `Packages/Backend/Sources/Backend/Resources` with your Reddit app id as `client_id` key/value. Create an reddit app [here](https://www.reddit.com/prefs/apps) and use `redditos://auth` as redirect url. 21 | 22 | -------------------------------------------------------------------------------- /RedditOs.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RedditOs.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /RedditOs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "AttributedText", 6 | "repositoryURL": "https://github.com/gonzalezreal/AttributedText", 7 | "state": { 8 | "branch": null, 9 | "revision": "bf076de48dbb2172525486936d512e1bba062642", 10 | "version": "0.3.0" 11 | } 12 | }, 13 | { 14 | "package": "combine-schedulers", 15 | "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", 16 | "state": { 17 | "branch": null, 18 | "revision": "c37e5ae8012fb654af776cc556ff8ae64398c841", 19 | "version": "0.5.0" 20 | } 21 | }, 22 | { 23 | "package": "KeychainAccess", 24 | "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess", 25 | "state": { 26 | "branch": null, 27 | "revision": "84e546727d66f1adc5439debad16270d0fdd04e7", 28 | "version": "4.2.2" 29 | } 30 | }, 31 | { 32 | "package": "Kingfisher", 33 | "repositoryURL": "https://github.com/onevcat/Kingfisher", 34 | "state": { 35 | "branch": null, 36 | "revision": "44450a8f564d7c0165f736ba2250649ff8d3e556", 37 | "version": "6.3.0" 38 | } 39 | }, 40 | { 41 | "package": "MarkdownUI", 42 | "repositoryURL": "https://github.com/gonzalezreal/MarkdownUI", 43 | "state": { 44 | "branch": null, 45 | "revision": "e8931e37dcf777b4c03ca76aa09c10cf246a2ced", 46 | "version": "0.5.1" 47 | } 48 | }, 49 | { 50 | "package": "NetworkImage", 51 | "repositoryURL": "https://github.com/gonzalezreal/NetworkImage", 52 | "state": { 53 | "branch": null, 54 | "revision": "15582b821cb097012b41b83d6219717926ec4ed6", 55 | "version": "2.1.0" 56 | } 57 | }, 58 | { 59 | "package": "cmark", 60 | "repositoryURL": "https://github.com/SwiftDocOrg/swift-cmark.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "9c8096a23f44794bde297452d87c455fc4f76d42", 64 | "version": "0.29.0+20210102.9c8096a" 65 | } 66 | }, 67 | { 68 | "package": "SwiftCommonMark", 69 | "repositoryURL": "https://github.com/gonzalezreal/SwiftCommonMark", 70 | "state": { 71 | "branch": null, 72 | "revision": "f1575c37110a386e50da3208a04266b398bcefaa", 73 | "version": "0.1.1" 74 | } 75 | }, 76 | { 77 | "package": "xctest-dynamic-overlay", 78 | "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", 79 | "state": { 80 | "branch": null, 81 | "revision": "603974e3909ad4b48ba04aad7e0ceee4f077a518", 82 | "version": "0.1.0" 83 | } 84 | } 85 | ] 86 | }, 87 | "version": 1 88 | } 89 | -------------------------------------------------------------------------------- /RedditOs.xcodeproj/xcshareddata/xcschemes/RedditOs.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x01", 9 | "green" : "0x57", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.271", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/RedditOs/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/RedditOs/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/RedditOs/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/RedditOs/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/RedditOs/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/RedditOs/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/RedditOs/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | {"images":[{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/InvertedTextColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/RedditBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xD2", 9 | "green" : "0x78", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xD2", 27 | "green" : "0x78", 28 | "red" : "0x00" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/RedditGold.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x37", 9 | "green" : "0xBD", 10 | "red" : "0xDD" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/RedditGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.876", 9 | "green" : "0.847", 10 | "red" : "0.845" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.276", 27 | "green" : "0.267", 28 | "red" : "0.266" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RedditOs/Assets.xcassets/TextColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RedditOs/Environements/Route.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Route.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 13/08/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | import Backend 12 | 13 | enum Route: Identifiable, Hashable { 14 | static func == (lhs: Route, rhs: Route) -> Bool { 15 | lhs.id == rhs.id 16 | } 17 | 18 | func hash(into hasher: inout Hasher) { 19 | hasher.combine(id) 20 | } 21 | 22 | case user(user: User) 23 | case subreddit(subreddit: String) 24 | case defaultChannel(chanel: UIState.DefaultChannels) 25 | case searchPostsResult 26 | 27 | var id: String { 28 | switch self { 29 | case let .user(user): 30 | return user.id 31 | case let .subreddit(subreddit): 32 | return subreddit 33 | case let .defaultChannel(chanel): 34 | return chanel.rawValue 35 | case .searchPostsResult: 36 | return "searchPostsResult" 37 | } 38 | } 39 | 40 | @ViewBuilder 41 | func makeView() -> some View { 42 | switch self { 43 | case let .user(user): 44 | UserSheetView(user: user) 45 | case let .subreddit(subreddit): 46 | SubredditPostsListView(name: subreddit) 47 | .equatable() 48 | case let .defaultChannel(chanel): 49 | SubredditPostsListView(name: chanel.rawValue) 50 | .equatable() 51 | case .searchPostsResult: 52 | QuickSearchPostsResultView() 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /RedditOs/Environements/SettingsKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsKey.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SettingsKey { 11 | static let subreddit_display_mode = "postDisplayMode" 12 | static let subreddit_defaut_sort_order = "defaultSortOrder" 13 | static let comments_default_sort_order = "defaultCommentsSortOrder" 14 | static let sidebar_enabled_section = "sidebarEnabledSections" 15 | } 16 | -------------------------------------------------------------------------------- /RedditOs/Environements/UIState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIState.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | import Backend 12 | 13 | class UIState: ObservableObject { 14 | enum DefaultChannels: String, CaseIterable { 15 | case hot, best, new, top, rising 16 | 17 | func icon() -> String { 18 | switch self { 19 | case .best: return "rosette" 20 | case .hot: return "flame" 21 | case .new: return "calendar.circle" 22 | case .top: return "chart.bar" 23 | case .rising: return "waveform.path.ecg" 24 | } 25 | } 26 | } 27 | 28 | enum Constants { 29 | static let searchTag = "search" 30 | } 31 | 32 | @Published var displayToolbarSearchBar = true 33 | 34 | @Published var selectedSubreddit: SubredditViewModel? 35 | @Published var selectedPost: PostViewModel? 36 | 37 | @Published var presentedSheetRoute: Route? 38 | @Published var searchRoute: Route? { 39 | didSet { 40 | if searchRoute != nil { 41 | sidebarSelection = Constants.searchTag 42 | displayToolbarSearchBar = false 43 | } 44 | } 45 | } 46 | 47 | lazy var isSearchActive: Binding = .init(get: { 48 | self.sidebarSelection == Constants.searchTag 49 | }, set: { _ in }) 50 | 51 | @Published var sidebarSelection: String? = DefaultChannels.hot.rawValue { 52 | didSet { 53 | if sidebarSelection != Constants.searchTag { 54 | searchRoute = nil 55 | displayToolbarSearchBar = true 56 | } else { 57 | displayToolbarSearchBar = false 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /RedditOs/Extensions/Array.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Array: RawRepresentable where Element: Codable { 4 | public init?(rawValue: String) { 5 | guard let data = rawValue.data(using: .utf8), 6 | let result = try? JSONDecoder().decode([Element].self, from: data) 7 | else { 8 | return nil 9 | } 10 | self = result 11 | } 12 | 13 | public var rawValue: String { 14 | guard let data = try? JSONEncoder().encode(self), 15 | let result = String(data: data, encoding: .utf8) 16 | else { 17 | return "[]" 18 | } 19 | return result 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /RedditOs/Extensions/Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 11/07/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension Color { 12 | static public var textColor: Color { 13 | Color("TextColor") 14 | } 15 | 16 | static public var invertedTextColor: Color { 17 | Color("InvertedTextColor") 18 | } 19 | 20 | static public var redditBlue: Color { 21 | Color("RedditBlue") 22 | } 23 | 24 | static public var redditGold: Color { 25 | Color("RedditGold") 26 | } 27 | 28 | static public var redditGray: Color { 29 | Color("RedditGray") 30 | } 31 | } 32 | 33 | extension Color { 34 | init(hex: String) { 35 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 36 | var int: UInt64 = 0 37 | Scanner(string: hex).scanHexInt64(&int) 38 | let a, r, g, b: UInt64 39 | switch hex.count { 40 | case 3: // RGB (12-bit) 41 | (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 42 | case 6: // RGB (24-bit) 43 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 44 | case 8: // ARGB (32-bit) 45 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 46 | default: 47 | (a, r, g, b) = (1, 1, 1, 0) 48 | } 49 | 50 | self.init( 51 | .sRGB, 52 | red: Double(r) / 255, 53 | green: Double(g) / 255, 54 | blue: Double(b) / 255, 55 | opacity: Double(a) / 255 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /RedditOs/Extensions/NSTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSTextField.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 28/07/2020. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | extension NSTextField { 12 | open override var focusRingType: NSFocusRingType { 13 | get { .none } 14 | set { } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /RedditOs/Features/Comments/CommentActionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentActionsView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 12/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct CommentActionsView: View { 12 | @ObservedObject var viewModel: CommentViewModel 13 | @State private var showPicker = false 14 | 15 | var body: some View { 16 | HStack(spacing: 16) { 17 | Button(action: { 18 | 19 | }, label: { 20 | Label("Reply", systemImage: "bubble.right") 21 | }).buttonStyle(BorderlessButtonStyle()) 22 | 23 | Button(action: { 24 | showPicker.toggle() 25 | }, label: { 26 | Label("Share", systemImage: "square.and.arrow.up") 27 | }) 28 | .buttonStyle(BorderlessButtonStyle()) 29 | .background(SharingsPicker(isPresented: $showPicker, 30 | sharingItems: [viewModel.comment.permalinkURL ?? ""])) 31 | 32 | Button(action: { 33 | viewModel.toggleSave() 34 | }, label: { 35 | Label("Save", 36 | systemImage: viewModel.comment.saved == true ? "bookmark.fill" : "bookmark") 37 | }).buttonStyle(BorderlessButtonStyle()) 38 | 39 | Button(action: { 40 | 41 | }, label: { 42 | Label("Report", systemImage: "flag") 43 | }).buttonStyle(BorderlessButtonStyle()) 44 | } 45 | } 46 | } 47 | 48 | struct CommentActionsView_Previews: PreviewProvider { 49 | static var previews: some View { 50 | CommentActionsView(viewModel: CommentViewModel(comment: static_comment)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /RedditOs/Features/Comments/CommentRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentRow.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import MarkdownUI 11 | 12 | struct CommentRow: View { 13 | @StateObject private var viewModel: CommentViewModel 14 | @State private var showUserPopover = false 15 | 16 | var isFake: Bool { 17 | viewModel.comment.name == "t1_id" 18 | } 19 | 20 | let isRoot: Bool 21 | 22 | init(comment: Comment, isRoot: Bool) { 23 | self.isRoot = isRoot 24 | _viewModel = StateObject(wrappedValue: CommentViewModel(comment: comment)) 25 | } 26 | 27 | var body: some View { 28 | HStack(alignment: .top) { 29 | if !isRoot { 30 | Rectangle() 31 | .frame(width: 1) 32 | .background(Color.white) 33 | .padding(.bottom, 8) 34 | } 35 | VStack(alignment: .leading, spacing: 8) { 36 | HStack(spacing: 0) { 37 | HStack(spacing: 6) { 38 | if let richText = viewModel.comment.authorFlairRichtext, !richText.isEmpty { 39 | FlairView(richText: richText, 40 | textColorHex: viewModel.comment.authorFlairTextColor, 41 | backgroundColorHex: viewModel.comment.authorFlairBackgroundColor, 42 | display: .small) 43 | } 44 | if let author = viewModel.comment.author { 45 | Button(action: { 46 | showUserPopover = true 47 | }, label: { 48 | HStack(spacing: 4) { 49 | if viewModel.comment.isSubmitter == true { 50 | Image(systemName: "music.mic") 51 | .foregroundColor(.redditBlue) 52 | } else { 53 | Image(systemName: "person") 54 | } 55 | Text(author) 56 | .font(.callout) 57 | .fontWeight(.bold) 58 | } 59 | }) 60 | .buttonStyle(BorderlessButtonStyle()) 61 | .popover(isPresented: $showUserPopover, content: { 62 | UserPopoverView(username: author) 63 | }) 64 | } else { 65 | Text("Deleted user") 66 | .font(.footnote) 67 | } 68 | } 69 | if let score = viewModel.comment.score { 70 | Text(" · \(score.toRoundedSuffixAsString()) points · ") 71 | .foregroundColor(.gray) 72 | .font(.caption) 73 | } 74 | if let date = viewModel.comment.createdUtc { 75 | Text(date, style: .relative) 76 | .foregroundColor(.gray) 77 | .font(.caption) 78 | } 79 | if let awards = viewModel.comment.allAwardings, !awards.isEmpty { 80 | AwardsView(awards: awards).padding(.leading, 8) 81 | } 82 | } 83 | if let body = viewModel.comment.body { 84 | if isFake { 85 | Text(body) 86 | .font(.body) 87 | .fixedSize(horizontal: false, vertical: true) 88 | } else { 89 | Markdown(Document(body)) 90 | .font(.body) 91 | .fixedSize(horizontal: false, vertical: true) 92 | } 93 | 94 | } else { 95 | Text("Deleted comment") 96 | .font(.footnote) 97 | .foregroundColor(.gray) 98 | } 99 | HStack(spacing: 16) { 100 | CommentVoteView(viewModel: viewModel) 101 | CommentActionsView(viewModel: viewModel) 102 | .foregroundColor(.gray) 103 | } 104 | Divider() 105 | }.padding(.vertical, 4) 106 | } 107 | } 108 | } 109 | 110 | struct CommentRow_Previews: PreviewProvider { 111 | static var previews: some View { 112 | List { 113 | CommentRow(comment: static_comment, isRoot: true) 114 | CommentRow(comment: static_comment, isRoot: false) 115 | CommentRow(comment: static_comment, isRoot: true) 116 | CommentRow(comment: static_comment, isRoot: false) 117 | CommentRow(comment: static_comment, isRoot: false) 118 | CommentRow(comment: static_comment, isRoot: false) 119 | } 120 | .frame(height: 800) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /RedditOs/Features/Comments/CommentViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentViewModel.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 12/08/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | import Backend 12 | 13 | class CommentViewModel: ObservableObject { 14 | @Published var comment: Comment 15 | 16 | private var cancellableStore: [AnyCancellable] = [] 17 | 18 | init(comment: Comment) { 19 | self.comment = comment 20 | } 21 | 22 | func postVote(vote: Vote) { 23 | let oldValue = comment.likes 24 | let cancellable = comment.vote(vote: vote) 25 | .receive(on: DispatchQueue.main) 26 | .sink{ [weak self] response in 27 | if response.error != nil { 28 | self?.comment.likes = oldValue 29 | } 30 | } 31 | cancellableStore.append(cancellable) 32 | } 33 | 34 | func toggleSave() { 35 | let oldValue = comment.saved 36 | let cancellable = (comment.saved == true ? comment.unsave() : comment.save()) 37 | .receive(on: DispatchQueue.main) 38 | .sink{ [weak self] response in 39 | if response.error != nil { 40 | self?.comment.saved = oldValue 41 | } 42 | } 43 | cancellableStore.append(cancellable) 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /RedditOs/Features/Comments/CommentVoteView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentVoteView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 12/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct CommentVoteView: View { 12 | @ObservedObject var viewModel: CommentViewModel 13 | 14 | var body: some View { 15 | HStack(spacing: 6) { 16 | Button(action: { 17 | viewModel.postVote(vote: viewModel.comment.likes == true ? .neutral : .upvote) 18 | }, 19 | label: { 20 | Image(systemName: "arrowtriangle.up.circle") 21 | .resizable() 22 | .frame(width: 12, height: 12) 23 | .foregroundColor(viewModel.comment.likes == true ? .accentColor : nil) 24 | }).buttonStyle(BorderlessButtonStyle()) 25 | 26 | Text(viewModel.comment.score?.toRoundedSuffixAsString() ?? "Vote") 27 | .font(.callout) 28 | .lineLimit(1) 29 | 30 | Button(action: { 31 | viewModel.postVote(vote: viewModel.comment.likes == false ? .neutral : .downvote) 32 | }, 33 | label: { 34 | Image(systemName: "arrowtriangle.down.circle") 35 | .resizable() 36 | .frame(width: 12, height: 12) 37 | .foregroundColor(viewModel.comment.likes == false ? .redditBlue : nil) 38 | }).buttonStyle(BorderlessButtonStyle()) 39 | } 40 | } 41 | } 42 | 43 | struct CommentVoteView_Previews: PreviewProvider { 44 | static var previews: some View { 45 | CommentVoteView(viewModel: CommentViewModel(comment: static_comment)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import AVKit 11 | 12 | struct PostDetailView: View { 13 | @ObservedObject var viewModel: PostViewModel 14 | @State private var redrawLink = false 15 | 16 | var body: some View { 17 | List { 18 | VStack(alignment: .leading, spacing: 8) { 19 | HStack { 20 | PostVoteView(viewModel: viewModel) 21 | VStack(alignment: .leading) { 22 | PostInfoView(post: viewModel.post) 23 | PostDetailHeader(listing: viewModel.post) 24 | } 25 | } 26 | PostDetailContent(listing: viewModel.post, redrawLink: $redrawLink) 27 | PostDetailActions(listing: viewModel.post) 28 | }.padding(.bottom, 16) 29 | PostDetailCommentsSection(comments: viewModel.comments) 30 | } 31 | .onAppear(perform: viewModel.fechComments) 32 | .onAppear(perform: viewModel.postVisit) 33 | .frame(minWidth: 500, 34 | maxWidth: .infinity, 35 | maxHeight: .infinity) 36 | } 37 | } 38 | 39 | struct PostDetail_Previews: PreviewProvider { 40 | static var previews: some View { 41 | PostDetailView(viewModel: PostViewModel(post: static_listing)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetailActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailActions.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct PostDetailActionsView: View { 12 | @ObservedObject var viewModel: PostViewModel 13 | 14 | var body: some View { 15 | HStack(spacing: 16) { 16 | HStack(spacing: 6) { 17 | Image(systemName: "bubble.middle.bottom.fill") 18 | .imageScale(.small) 19 | Text("\(viewModel.post.numComments) comments") 20 | } 21 | 22 | HStack(spacing: 6) { 23 | Image(systemName: "square.and.arrow.up") 24 | .imageScale(.small) 25 | Text("Share") 26 | } 27 | 28 | HStack(spacing: 6) { 29 | Button(action: { 30 | viewModel.toggleSave() 31 | }) { 32 | Image(systemName: viewModel.post.saved ? "bookmark.fill": "bookmark") 33 | .imageScale(.small) 34 | Text("Save") 35 | }.buttonStyle(BorderlessButtonStyle()) 36 | } 37 | 38 | HStack(spacing: 6) { 39 | Image(systemName: "flag") 40 | .imageScale(.small) 41 | Text("Report") 42 | } 43 | 44 | } 45 | } 46 | } 47 | 48 | struct PostDetailActions_Previews: PreviewProvider { 49 | static var previews: some View { 50 | PostDetailActionsView(viewModel: PostViewModel(post: static_listing)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetailActionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailActions.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct PostDetailActionsView: View { 12 | @ObservedObject var viewModel: PostViewModel 13 | @State private var showPicker = false 14 | 15 | var body: some View { 16 | HStack(spacing: 16) { 17 | Button(action: { 18 | 19 | }, label: { 20 | Label("\(viewModel.post.numComments) comments", systemImage: "bubble.middle.bottom.fill") 21 | .foregroundColor(.white) 22 | }).buttonStyle(BorderlessButtonStyle()) 23 | 24 | Button(action: { 25 | showPicker.toggle() 26 | }, label: { 27 | Label("Share", systemImage: "square.and.arrow.up") 28 | .foregroundColor(.white) 29 | }) 30 | .buttonStyle(BorderlessButtonStyle()) 31 | .background(SharingsPicker(isPresented: $showPicker, 32 | sharingItems: [viewModel.post.redditURL ?? ""])) 33 | 34 | 35 | Button(action: { 36 | viewModel.toggleSave() 37 | }, label: { 38 | Label(viewModel.post.saved ? "Saved" : "Save", 39 | systemImage: viewModel.post.saved ? "bookmark.fill": "bookmark") 40 | .foregroundColor(viewModel.post.saved ? .accentColor : .white) 41 | }).buttonStyle(BorderlessButtonStyle()) 42 | 43 | Button(action: { 44 | 45 | }, label: { 46 | Label("Report", systemImage: "flag") 47 | .foregroundColor(.white) 48 | }).buttonStyle(BorderlessButtonStyle()) 49 | } 50 | } 51 | } 52 | 53 | struct PostDetailActions_Previews: PreviewProvider { 54 | static var previews: some View { 55 | PostDetailActionsView(viewModel: PostViewModel(post: static_listing)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetailCommentsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailCommentsSection.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import UI 11 | 12 | struct PostDetailCommentsSection: View { 13 | @ObservedObject var viewModel: PostViewModel 14 | private let placeholderComments = Array(repeating: static_comment, count: 10) 15 | 16 | var body: some View { 17 | Divider() 18 | 19 | Picker("Sort by", selection: $viewModel.commentsSort) { 20 | ForEach(Comment.Sort.allCases, id: \.self) { sort in 21 | Text(sort.label()).tag(sort) 22 | } 23 | } 24 | .pickerStyle(MenuPickerStyle()) 25 | .frame(width: 170) 26 | .padding(.bottom, 8) 27 | 28 | RecursiveView(data: viewModel.comments ?? placeholderComments, 29 | children: \.repliesComments) { comment in 30 | CommentRow(comment: comment, 31 | isRoot: comment.parentId == "t3_" + viewModel.post.id || viewModel.comments == nil) 32 | .redacted(reason: viewModel.comments == nil ? .placeholder : []) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetailContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailContent.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import AVKit 11 | import Kingfisher 12 | import MarkdownUI 13 | 14 | struct PostDetailContent: View { 15 | let listing: SubredditPost 16 | @Binding var redrawLink: Bool 17 | 18 | @ViewBuilder 19 | var body: some View { 20 | if let text = listing.selftext ?? listing.description { 21 | Markdown(Document(text)) 22 | .font(.body) 23 | .fixedSize(horizontal: false, vertical: true) 24 | } 25 | if let video = listing.secureMedia?.video { 26 | HStack { 27 | Spacer() 28 | VideoPlayer(player: AVPlayer(url: video.url)) 29 | .frame(width: min(500, CGFloat(video.width)), 30 | height: min(500, CGFloat(video.height))) 31 | Spacer() 32 | } 33 | } else if let url = listing.url, let realURL = URL(string: url) { 34 | if realURL.pathExtension == "jpg" || realURL.pathExtension == "png" { 35 | HStack { 36 | Spacer() 37 | KFImage(realURL) 38 | .resizable() 39 | .aspectRatio(contentMode: .fit) 40 | .background(Color.gray) 41 | .frame(maxHeight: 400) 42 | .padding() 43 | Spacer() 44 | } 45 | } else if listing.selftext == nil || listing.selftext?.isEmpty == true { 46 | LinkPresentationView(url: realURL, redraw: $redrawLink) 47 | } 48 | } 49 | } 50 | } 51 | 52 | struct PostDetailContent_Previews: PreviewProvider { 53 | static var previews: some View { 54 | PostDetailContent(listing: static_listing, redrawLink: .constant(false)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetailHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailHeader.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import Kingfisher 11 | 12 | struct PostDetailHeader: View { 13 | let listing: SubredditPost 14 | 15 | var body: some View { 16 | HStack { 17 | Text(listing.title) 18 | .font(.title) 19 | .lineLimit(10) 20 | .multilineTextAlignment(.leading) 21 | .truncationMode(.tail) 22 | .fixedSize(horizontal: false, vertical: true) 23 | if let url = listing.thumbnailURL, url.pathExtension != "jpg", url.pathExtension != "png" { 24 | KFImage(url) 25 | .frame(width: 80, height: 60) 26 | .aspectRatio(contentMode: .fit) 27 | .cornerRadius(8) 28 | } 29 | Spacer() 30 | } 31 | } 32 | } 33 | 34 | struct PostDetailHeader_Previews: PreviewProvider { 35 | static var previews: some View { 36 | PostDetailHeader(listing: static_listing) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetailToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailToolbar.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 21/09/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PostDetailToolbar: ToolbarContent { 11 | let shareURL: URL? 12 | 13 | var body: some ToolbarContent { 14 | ToolbarItemGroup { 15 | SharingView(url: shareURL) 16 | Spacer() 17 | SearchView() 18 | } 19 | } 20 | } 21 | 22 | struct SearchView: View { 23 | @EnvironmentObject private var uiState: UIState 24 | 25 | var body: some View { 26 | if uiState.displayToolbarSearchBar { 27 | QuickSearchBar(showSuggestionPopover: true, 28 | onCommit: {}, 29 | onCancel: {}) 30 | .frame(width: 250) 31 | } 32 | } 33 | } 34 | 35 | struct SharingView: View { 36 | let url: URL? 37 | @State private var sharePickerShown = false 38 | 39 | var body: some View { 40 | Button(action: { 41 | sharePickerShown.toggle() 42 | }) { 43 | Image(systemName: "square.and.arrow.up") 44 | }.background(SharingsPicker(isPresented: $sharePickerShown, 45 | sharingItems: [url ?? ""])) 46 | 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import AVKit 11 | 12 | struct PostDetailView: View, Equatable { 13 | static func == (lhs: Self, rhs: Self) -> Bool { 14 | lhs.postId == rhs.postId 15 | } 16 | 17 | private let postId: String 18 | @EnvironmentObject private var uiState: UIState 19 | @ObservedObject var viewModel: PostViewModel 20 | @State private var redrawLink = false 21 | @State private var sharePickerShown = false 22 | 23 | init(viewModel: PostViewModel) { 24 | self.postId = viewModel.post.id 25 | self.viewModel = viewModel 26 | } 27 | 28 | var body: some View { 29 | List { 30 | VStack(alignment: .leading, spacing: 8) { 31 | HStack { 32 | PostVoteView(viewModel: viewModel) 33 | VStack(alignment: .leading) { 34 | PostInfoView(post: viewModel.post, display: .horizontal) 35 | PostDetailHeader(listing: viewModel.post) 36 | } 37 | } 38 | PostDetailContent(listing: viewModel.post, redrawLink: $redrawLink) 39 | PostDetailActionsView(viewModel: viewModel) 40 | }.padding(.bottom, 16) 41 | PostDetailCommentsSection(viewModel: viewModel) 42 | } 43 | .onAppear(perform: viewModel.fechComments) 44 | .onAppear(perform: viewModel.postVisit) 45 | .onAppear(perform: { 46 | uiState.selectedPost = viewModel 47 | }) 48 | .onDisappear(perform: { 49 | uiState.selectedPost = nil 50 | }) 51 | .toolbar { 52 | PostDetailToolbar(shareURL: viewModel.post.redditURL) 53 | } 54 | .frame(minWidth: 500, 55 | maxWidth: .infinity, 56 | maxHeight: .infinity) 57 | } 58 | } 59 | 60 | struct PostDetail_Previews: PreviewProvider { 61 | static var previews: some View { 62 | PostDetailView(viewModel: PostViewModel(post: static_listing)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailViewModel.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | import Backend 12 | 13 | class PostViewModel: ObservableObject { 14 | let post: SubredditPost 15 | @Published var comments: [Comment]? 16 | 17 | private var commentsCancellable: AnyCancellable? 18 | 19 | init(post: SubredditPost) { 20 | self.post = post 21 | } 22 | 23 | func fechComments() { 24 | commentsCancellable = Comment.fetch(subreddit: post.subreddit, id: post.id) 25 | .receive(on: DispatchQueue.main) 26 | .map{ $0.last?.comments } 27 | .sink{ [weak self] comments in 28 | self?.comments = comments 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /RedditOs/Features/Post/PostViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostDetailViewModel.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | import Backend 12 | 13 | class PostViewModel: ObservableObject { 14 | @Published var post: SubredditPost 15 | @Published var comments: [Comment]? 16 | @AppStorage(SettingsKey.comments_default_sort_order) var commentsSort = Comment.Sort.top { 17 | didSet { 18 | fechComments() 19 | } 20 | } 21 | 22 | private var cancellableStore: [AnyCancellable] = [] 23 | 24 | init(post: SubredditPost) { 25 | self.post = post 26 | } 27 | 28 | func postVisit() { 29 | let oldValue = post.visited 30 | let cancellable = post.visit() 31 | .receive(on: DispatchQueue.main) 32 | .sink { [weak self] response in 33 | if response.error != nil { 34 | self?.post.visited = oldValue 35 | } 36 | } 37 | cancellableStore.append(cancellable) 38 | } 39 | 40 | func postVote(vote: Vote) { 41 | let oldValue = post.likes 42 | let cancellable = post.vote(vote: vote) 43 | .receive(on: DispatchQueue.main) 44 | .sink{ [weak self] response in 45 | if response.error != nil { 46 | self?.post.likes = oldValue 47 | } 48 | } 49 | cancellableStore.append(cancellable) 50 | } 51 | 52 | func toggleSave() { 53 | let oldValue = post.saved 54 | let cancellable = (post.saved ? post.unsave() : post.save()) 55 | .receive(on: DispatchQueue.main) 56 | .sink{ [weak self] response in 57 | if response.error != nil { 58 | self?.post.saved = oldValue 59 | } 60 | } 61 | cancellableStore.append(cancellable) 62 | } 63 | 64 | func fechComments() { 65 | comments = nil 66 | let cancellable = Comment.fetch(subreddit: post.subreddit, id: post.id, sort: commentsSort) 67 | .receive(on: DispatchQueue.main) 68 | .map{ $0.last?.comments } 69 | .sink{ [weak self] comments in 70 | self?.comments = comments 71 | } 72 | cancellableStore.append(cancellable) 73 | } 74 | } 75 | 76 | extension Comment.Sort { 77 | public func label() -> String { 78 | switch self { 79 | case .best: 80 | return "Best" 81 | default: 82 | return self.rawValue.capitalized 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /RedditOs/Features/Profile/ProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 11/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct ProfileView: View { 12 | @EnvironmentObject private var oauthClient: OauthClient 13 | @EnvironmentObject private var currentUser: CurrentUserStore 14 | @Environment(\.openURL) private var openURL 15 | 16 | private let loadingPlaceholders = Array(repeating: static_listing, count: 10) 17 | 18 | var body: some View { 19 | List { 20 | headerView.padding(.vertical, 16) 21 | if currentUser.user != nil { 22 | userOverview 23 | } 24 | } 25 | .listStyle(InsetListStyle()) 26 | .frame(width: 500) 27 | .navigationTitle("Profile") 28 | .navigationSubtitle(currentUser.user?.name ?? "Login") 29 | .toolbar { 30 | ToolbarItem(placement: .primaryAction) { 31 | Button { 32 | oauthClient.logout() 33 | } label: { 34 | Text("Logout") 35 | } 36 | 37 | } 38 | } 39 | } 40 | 41 | @ViewBuilder 42 | private var headerView: some View { 43 | if let user = currentUser.user { 44 | UserHeaderView(user: user) 45 | .onAppear { 46 | currentUser.fetchOverview() 47 | } 48 | } else { 49 | authView 50 | } 51 | } 52 | 53 | @ViewBuilder 54 | private var userOverview: some View { 55 | if let overview = currentUser.overview { 56 | ForEach(overview) { content in 57 | switch content { 58 | case let .post(post): 59 | SubredditPostRow(post: post, displayMode: .constant(.large)) 60 | case let .comment(comment): 61 | CommentRow(comment: comment, isRoot: true) 62 | default: 63 | Text("Unsupported view") 64 | } 65 | } 66 | LoadingRow(text: "Loading next page") 67 | .onAppear { 68 | currentUser.fetchOverview() 69 | } 70 | } else { 71 | ForEach(loadingPlaceholders) { post in 72 | SubredditPostRow(post: post, 73 | displayMode: .constant(.large)) 74 | .redacted(reason: .placeholder) 75 | } 76 | } 77 | } 78 | 79 | @ViewBuilder 80 | private var authView: some View { 81 | HStack { 82 | Spacer() 83 | switch oauthClient.authState { 84 | case .signedOut: 85 | Button { 86 | if let url = oauthClient.startOauthFlow() { 87 | openURL(url) 88 | } 89 | } label: { 90 | Text("Sign in") 91 | } 92 | case .signinInProgress, .refreshing: 93 | ProgressView("Auth in progress") 94 | case .authenthicated: 95 | Text("Signed in") 96 | } 97 | Spacer() 98 | } 99 | } 100 | } 101 | 102 | struct ProfileView_Previews: PreviewProvider { 103 | static var previews: some View { 104 | ProfileView() 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /RedditOs/Features/Profile/SavedPostListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SavedPostsListView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 24/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct SavedPostsListView: View { 12 | @EnvironmentObject private var currentUser: CurrentUserStore 13 | 14 | var body: some View { 15 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) 16 | } 17 | } 18 | 19 | struct SavedPostListView_Previews: PreviewProvider { 20 | static var previews: some View { 21 | SavedPostsListView() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /RedditOs/Features/Profile/SavedPostsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SavedPostsListView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 24/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct SavedPostsListView: View { 12 | @EnvironmentObject private var currentUser: CurrentUserStore 13 | @State private var displayMode = SubredditPostRow.DisplayMode.large 14 | 15 | var body: some View { 16 | PostsListView(header: { EmptyView() }, 17 | posts: currentUser.savedPosts, 18 | displayMode: $displayMode) { 19 | currentUser.fetchSaved(after: currentUser.savedPosts?.last) 20 | }.onAppear { 21 | currentUser.fetchSaved(after: nil) 22 | } 23 | .navigationTitle("Saved") 24 | 25 | } 26 | } 27 | 28 | struct SavedPostListView_Previews: PreviewProvider { 29 | static var previews: some View { 30 | SavedPostsListView() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /RedditOs/Features/Profile/SubmittedPostsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubmittedPostsListView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct SubmittedPostsListView: View { 12 | @EnvironmentObject private var currentUser: CurrentUserStore 13 | @State private var displayMode: SubredditPostRow.DisplayMode = .large 14 | 15 | var body: some View { 16 | PostsListView(header: { EmptyView() }, 17 | posts: currentUser.submittedPosts, 18 | displayMode: $displayMode) { 19 | currentUser.fetchSubmitted(after: currentUser.submittedPosts?.last) 20 | }.onAppear { 21 | currentUser.fetchSubmitted(after: nil) 22 | } 23 | .navigationTitle("Submitted") 24 | } 25 | } 26 | 27 | struct SubmittedPostsListView_Previews: PreviewProvider { 28 | static var previews: some View { 29 | SubmittedPostsListView() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Quick/QuickSearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarSearchBar.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 28/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct QuickSearchBar: View { 11 | @EnvironmentObject private var searchState: QuickSearchState 12 | @State private var isFocused = false 13 | @State private var isPopoverPresented = false 14 | 15 | let showSuggestionPopover: Bool 16 | let onCommit: () -> Void 17 | let onCancel: () -> Void 18 | 19 | var body: some View { 20 | SearchBarView(placeholder: "Search anything", 21 | searchText: $searchState.searchText) { editing in 22 | if showSuggestionPopover { 23 | isPopoverPresented = editing 24 | } 25 | } onCommit: { 26 | onCommit() 27 | } onCancel: { 28 | onCancel() 29 | } 30 | .popover(isPresented: $isPopoverPresented) { 31 | ScrollView { 32 | VStack(alignment: .leading) { 33 | QuickSearchResultsView() 34 | }.padding() 35 | }.frame(width: 300, height: 500) 36 | } 37 | } 38 | } 39 | 40 | struct QuickSearchBar_Previews: PreviewProvider { 41 | static var previews: some View { 42 | QuickSearchBar(showSuggestionPopover: true, 43 | onCommit: { }, 44 | onCancel: { }).environmentObject(QuickSearchState()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Quick/QuickSearchFullResultsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchMainContentView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 15/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct QuickSearchFullResultsView: View { 11 | @EnvironmentObject private var uiState: UIState 12 | @EnvironmentObject private var searchState: QuickSearchState 13 | 14 | enum ResultsMode { 15 | case autocomplete, posts 16 | } 17 | 18 | @State private var resultsDisplayMode = ResultsMode.autocomplete 19 | 20 | var body: some View { 21 | VStack(alignment: .leading) { 22 | if let route = uiState.searchRoute { 23 | route.makeView() 24 | .toolbar { 25 | ToolbarItemGroup(placement: .navigation) { 26 | Button { 27 | uiState.searchRoute = nil 28 | resultsDisplayMode = .autocomplete 29 | } label: { 30 | Text("Back") 31 | } 32 | } 33 | } 34 | } else { 35 | QuickSearchBar(showSuggestionPopover: false, 36 | onCommit: { 37 | resultsDisplayMode = .posts 38 | }, onCancel: { 39 | resultsDisplayMode = .autocomplete 40 | }) 41 | .padding() 42 | resultsView 43 | .listStyle(PlainListStyle()) 44 | .padding(.horizontal) 45 | Spacer() 46 | } 47 | } 48 | .navigationTitle("Search") 49 | .onAppear { 50 | searchState.fetchTrending() 51 | } 52 | .frame(minWidth: 300) 53 | } 54 | 55 | @ViewBuilder 56 | private var resultsView: some View { 57 | switch resultsDisplayMode { 58 | case .autocomplete: 59 | List { 60 | if searchState.searchText.isEmpty { 61 | if let trending = searchState.trending { 62 | Section(header: Label("Trending", systemImage: "chart.bar.fill")) { 63 | ForEach(trending.subredditNames, id: \.self) { subreddit in 64 | Text(subreddit) 65 | .padding(.vertical, 4) 66 | .onTapGesture { 67 | uiState.searchRoute = .subreddit(subreddit: subreddit) 68 | } 69 | } 70 | } 71 | } 72 | } else { 73 | QuickSearchResultsView() 74 | } 75 | } 76 | case .posts: 77 | QuickSearchPostsResultView() 78 | } 79 | } 80 | } 81 | 82 | struct QuickSearchFullResultsView_Previews: PreviewProvider { 83 | static var previews: some View { 84 | QuickSearchFullResultsView().environmentObject(UIState()) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Quick/QuickSearchPostsResultView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuickSearchPostsResultView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 17/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct QuickSearchPostsResultView: View { 11 | @EnvironmentObject private var searchState: QuickSearchState 12 | 13 | var body: some View { 14 | List { 15 | if searchState.isLoading { 16 | LoadingRow(text: "Searching...") 17 | } else if let results = searchState.postResults, !results.isEmpty { 18 | ForEach(results) { result in 19 | SubredditPostRow(post: result, displayMode: .constant(.large)) 20 | } 21 | } else { 22 | Label("No matching search for \(searchState.searchText)", 23 | systemImage: "exclamationmark.triangle") 24 | .foregroundColor(.textColor) 25 | } 26 | } 27 | } 28 | } 29 | 30 | struct QuickSearchPostsResultView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | QuickSearchPostsResultView() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Quick/QuickSearchResultRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobalSearchSubRow.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 06/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Kingfisher 10 | import UI 11 | 12 | struct QuickSearchResultRow: View { 13 | struct TextViewContainer: View { 14 | @State private var isHovered = false 15 | 16 | let text: String 17 | 18 | var body: some View { 19 | Text(text) 20 | .foregroundColor(isHovered ? .accentColor : nil) 21 | .scaleEffect(isHovered ? 1.05 : 1.0) 22 | .whenHovered({ hovered in 23 | isHovered = hovered 24 | }) 25 | .animation(.interactiveSpring(), value: isHovered) 26 | } 27 | } 28 | 29 | let icon: String? 30 | let name: String 31 | 32 | var defaultImage: some View { 33 | Image(systemName: "globe") 34 | .resizable() 35 | .frame(width: 16, height: 16) 36 | } 37 | 38 | var body: some View { 39 | HStack { 40 | if let image = icon, 41 | let url = URL(string: image) { 42 | KFImage(url) 43 | .placeholder{ defaultImage } 44 | .resizable() 45 | .frame(width: 16, height: 16) 46 | .cornerRadius(8) 47 | } else { 48 | defaultImage 49 | } 50 | TextViewContainer(text: name) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Quick/QuickSearchResultsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobalSearchPopoverView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 05/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct QuickSearchResultsView: View { 12 | @EnvironmentObject private var uiState: UIState 13 | @EnvironmentObject private var currentUser: CurrentUserStore 14 | @EnvironmentObject private var searchState: QuickSearchState 15 | 16 | struct ResultContainerView: View { 17 | private let content: Content 18 | 19 | init(@ViewBuilder content: @escaping () -> Content) { 20 | self.content = content() 21 | } 22 | 23 | var body: some View { 24 | Group { 25 | content 26 | }.padding(4) 27 | } 28 | } 29 | 30 | var body: some View { 31 | Section(header: makeTitle("Quick access")) { 32 | makeQuickAccess() 33 | } 34 | 35 | Divider() 36 | 37 | Section(header: makeTitle("My subscriptions")) { 38 | makeMySubscriptionsSearch() 39 | } 40 | 41 | Divider() 42 | 43 | Section(header: makeTitle("Subreddit search")) { 44 | makeSubredditSearch() 45 | } 46 | } 47 | 48 | private func makeTitle(_ title: String) -> some View { 49 | Text(title) 50 | .font(.headline) 51 | .fontWeight(.bold) 52 | } 53 | 54 | private func makeQuickAccess() -> some View { 55 | ResultContainerView { 56 | QuickSearchResultRow(icon: nil, 57 | name: "Posts with '\(searchState.searchText)'") 58 | .onTapGesture { 59 | uiState.searchRoute = .searchPostsResult 60 | } 61 | 62 | QuickSearchResultRow(icon: nil, 63 | name: "Go to r/\(searchState.searchText)") 64 | .onTapGesture { 65 | uiState.searchRoute = .subreddit(subreddit: searchState.searchText) 66 | } 67 | } 68 | } 69 | 70 | private func makeMySubscriptionsSearch() -> some View { 71 | ResultContainerView { 72 | if let subs = searchState.filteredSubscriptions { 73 | if subs.isEmpty { 74 | Label("No matching subscriptions for \(searchState.searchText)", 75 | systemImage: "exclamationmark.triangle") 76 | .foregroundColor(.textColor) 77 | } else { 78 | ForEach(subs) { sub in 79 | makeSubRow(icon: sub.iconImg, name: sub.displayName) 80 | } 81 | } 82 | } 83 | } 84 | } 85 | 86 | private func makeSubredditSearch() -> some View { 87 | ResultContainerView { 88 | if searchState.isLoading { 89 | LoadingRow(text: nil) 90 | } else if let results = searchState.results { 91 | if results.isEmpty { 92 | Label("No matching search for \(searchState.searchText)", 93 | systemImage: "exclamationmark.triangle") 94 | .foregroundColor(.textColor) 95 | } else { 96 | ForEach(results) { sub in 97 | makeSubRow(icon: sub.iconImg, name: sub.name) 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | private func makeSubRow(icon: String?, name: String) -> some View { 105 | QuickSearchResultRow(icon: icon, name: name) 106 | .onTapGesture { 107 | uiState.searchRoute = .subreddit(subreddit: name) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Quick/QuickSearchState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSubredditsViewModel.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | import Backend 12 | 13 | class QuickSearchState: ObservableObject { 14 | @Published var searchText = "" 15 | @Published var results: [SubredditSmall]? 16 | @Published var postResults: [SubredditPost]? 17 | @Published var filteredSubscriptions: [Subreddit]? 18 | @Published var trending: TrendingSubreddits? 19 | @Published var isLoading = false 20 | 21 | private var currentUser: CurrentUserStore 22 | 23 | private var subredditSearchPublisher: AnyPublisher? 24 | private var postSearchPublisher: AnyPublisher, Never>? 25 | private var cancellableSet: Set = Set() 26 | private var searchCancellable: AnyCancellable? 27 | 28 | init(currentUser: CurrentUserStore = .shared) { 29 | self.currentUser = currentUser 30 | 31 | $searchText 32 | .subscribe(on: DispatchQueue.global()) 33 | .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) 34 | .removeDuplicates() 35 | .receive(on: DispatchQueue.main) 36 | .sink(receiveValue: { [weak self] text in 37 | if text.isEmpty { 38 | self?.isLoading = false 39 | self?.results = nil 40 | } else { 41 | self?.isLoading = true 42 | self?.search(with: text) 43 | } 44 | }) 45 | .store(in: &cancellableSet) 46 | 47 | $searchText 48 | .receive(on: DispatchQueue.main) 49 | .sink(receiveValue: { [weak self] text in 50 | guard let w = self else { return } 51 | if text.isEmpty { 52 | w.filteredSubscriptions = nil 53 | } else { 54 | w.filteredSubscriptions = w.currentUser.subscriptions.filter{ $0.displayName.lowercased().contains(text.lowercased()) } 55 | } 56 | }) 57 | .store(in: &cancellableSet) 58 | } 59 | 60 | private func search(with text: String) { 61 | searchCancellable?.cancel() 62 | 63 | subredditSearchPublisher = API.shared.request(endpoint: .searchSubreddit, httpMethod: "POST", params: ["query": text]) 64 | .subscribe(on: DispatchQueue.global()) 65 | .replaceError(with: SubredditResponse()) 66 | .eraseToAnyPublisher() 67 | 68 | postSearchPublisher = API.shared.request(endpoint: .search, params: ["q": text]) 69 | .subscribe(on: DispatchQueue.global()) 70 | .replaceError(with: ListingResponse(error: "error")) 71 | .eraseToAnyPublisher() 72 | 73 | searchCancellable = Publishers.Zip(subredditSearchPublisher!, 74 | postSearchPublisher!) 75 | .receive(on: DispatchQueue.main) 76 | .sink(receiveValue: { [weak self] subreddits, posts in 77 | self?.results = subreddits.subreddits.map{ $0 } 78 | self?.postResults = posts.data?.children.map{ $0.data } 79 | self?.isLoading = false 80 | }) 81 | 82 | } 83 | 84 | public func fetchTrending() { 85 | TrendingSubreddits.fetch() 86 | .subscribe(on: DispatchQueue.global()) 87 | .receive(on: DispatchQueue.main) 88 | .sink { [weak self] trending in 89 | self?.trending = trending 90 | } 91 | .store(in: &cancellableSet) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/SearchSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSubredditsPopover.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SearchSubredditsPopover: View { 11 | @State private var search = "" 12 | var body: some View { 13 | List { 14 | TextField("Search", text: $search) 15 | .textFieldStyle(RoundedBorderTextFieldStyle()) 16 | } 17 | .navigationTitle("Search") 18 | .toolbar { 19 | ToolbarItem(placement: .navigation) { 20 | Button(action: {}) { 21 | Image(systemName: "xmark.circle") 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | struct SearchSheet_Previews: PreviewProvider { 29 | static var previews: some View { 30 | SearchSubredditsPopover() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/SearchSubredditsPopover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSubredditsPopover.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct PopoverSearchSubredditView: View { 12 | @EnvironmentObject private var userData: LocalDataStore 13 | @StateObject private var viewModel = SearchSubredditsViewModel() 14 | 15 | var body: some View { 16 | List { 17 | TextField("Search", text: $viewModel.searchText) 18 | .textFieldStyle(RoundedBorderTextFieldStyle()) 19 | .padding(.vertical, 8) 20 | if viewModel.isLoading { 21 | LoadingRow(text: nil) 22 | } else if let results = viewModel.results { 23 | ForEach(results) { result in 24 | PopoverSearchSubredditRow(subreddit: result).onTapGesture { 25 | userData.add(favorite: result) 26 | } 27 | } 28 | } 29 | } 30 | .navigationTitle("Search") 31 | .toolbar { 32 | ToolbarItem(placement: .navigation) { 33 | Button(action: {}) { 34 | Image(systemName: "xmark.circle") 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | struct SearchSheet_Previews: PreviewProvider { 42 | static var previews: some View { 43 | PopoverSearchSubredditView() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Shared/SearchBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBarView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 17/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SearchBarView: View { 11 | let placeholder: String 12 | @Binding var searchText: String 13 | let onEditingChange: (Bool) -> Void 14 | let onCommit: () -> Void 15 | let onCancel: () -> Void 16 | 17 | @State private var isFocused = false 18 | 19 | var body: some View { 20 | HStack { 21 | TextField(placeholder, text: $searchText) { editing in 22 | isFocused = editing 23 | onEditingChange(editing) 24 | } onCommit: { 25 | onCommit() 26 | } 27 | .keyboardShortcut("f", modifiers: .command) 28 | .padding(8) 29 | .background(RoundedRectangle(cornerRadius: 8) 30 | .stroke(isFocused ? Color.accentColor : Color.clear) 31 | .background(Color.black.opacity(0.2).cornerRadius(8))) 32 | .textFieldStyle(PlainTextFieldStyle()) 33 | 34 | if !searchText.isEmpty { 35 | Button { 36 | searchText = "" 37 | onCancel() 38 | } label: { 39 | Image(systemName: "xmark.circle") 40 | .font(.title2) 41 | } 42 | .buttonStyle(BorderlessButtonStyle()) 43 | .transition(.move(edge: .trailing)) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Subreddit Search Popover/PopoverSearchSubredditRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubredditRow.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import Kingfisher 11 | 12 | struct PopoverSearchSubredditRow: View { 13 | let subreddit: SubredditSmall 14 | 15 | var body: some View { 16 | VStack(alignment: .leading) { 17 | HStack { 18 | if let image = subreddit.iconImg, 19 | let url = URL(string: image) { 20 | KFImage(url) 21 | .resizable() 22 | .frame(width: 30, height: 30) 23 | .cornerRadius(15) 24 | } else { 25 | RoundedRectangle(cornerRadius: 15, 26 | style: /*@START_MENU_TOKEN@*/.continuous/*@END_MENU_TOKEN@*/) 27 | .frame(width: 30, height: 30) 28 | .foregroundColor(.gray) 29 | } 30 | VStack(alignment: .leading) { 31 | Text(subreddit.name) 32 | .foregroundColor(.white) 33 | .font(.headline) 34 | .fontWeight(.bold) 35 | Text("Subscribers: \(subreddit.subscriberCount)") 36 | } 37 | } 38 | Divider() 39 | } 40 | } 41 | } 42 | 43 | struct SubredditRow_Previews: PreviewProvider { 44 | static var previews: some View { 45 | List { 46 | PopoverSearchSubredditRow(subreddit: static_subreddit) 47 | PopoverSearchSubredditRow(subreddit: static_subreddit) 48 | PopoverSearchSubredditRow(subreddit: static_subreddit) 49 | PopoverSearchSubredditRow(subreddit: static_subreddit) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /RedditOs/Features/Search/Subreddit Search Popover/PopoverSearchSubredditView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSubredditsPopover.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct PopoverSearchSubredditView: View { 12 | @EnvironmentObject private var userData: LocalDataStore 13 | @StateObject private var viewModel = QuickSearchState() 14 | 15 | var body: some View { 16 | List { 17 | TextField("Search", text: $viewModel.searchText) 18 | .textFieldStyle(RoundedBorderTextFieldStyle()) 19 | .padding(.vertical, 8) 20 | if viewModel.isLoading { 21 | LoadingRow(text: nil) 22 | } else if let results = viewModel.results { 23 | ForEach(results) { result in 24 | PopoverSearchSubredditRow(subreddit: result).onTapGesture { 25 | userData.add(favorite: result) 26 | } 27 | } 28 | } 29 | } 30 | .navigationTitle("Search") 31 | .toolbar { 32 | ToolbarItem(placement: .navigation) { 33 | Button(action: {}) { 34 | Image(systemName: "xmark.circle") 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | struct SearchSheet_Previews: PreviewProvider { 42 | static var previews: some View { 43 | PopoverSearchSubredditView() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /RedditOs/Features/Settings/GeneralTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralTabView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 17/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct GeneralTabView: View { 11 | @AppStorage(SettingsKey.subreddit_display_mode) private var displayMode: SubredditPostRow.DisplayMode = .large 12 | @AppStorage(SettingsKey.subreddit_defaut_sort_order) private var sortOrder: SubredditViewModel.SortOrder = .hot 13 | 14 | var body: some View { 15 | Form { 16 | Section(header: Text("Default Subreddit settings")) { 17 | 18 | Picker("Display layout style", selection: $displayMode) { 19 | ForEach(SubredditPostRow.DisplayMode.allCases, id: \.self) { mode in 20 | Label(mode.rawValue, systemImage: mode.symbol()).tag(mode) 21 | } 22 | } 23 | 24 | Picker(selection: $sortOrder, label: Text("Sort order")) { 25 | ForEach(SubredditViewModel.SortOrder.allCases, id: \.self) { sort in 26 | Text(sort.rawValue.capitalized).tag(sort) 27 | } 28 | } 29 | 30 | } 31 | } 32 | .padding() 33 | .frame(width: 400, height: 150) 34 | } 35 | } 36 | 37 | struct GeneralTabView_Previews: PreviewProvider { 38 | static var previews: some View { 39 | GeneralTabView() 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /RedditOs/Features/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | 12 | var body: some View { 13 | TabView { 14 | GeneralTabView() 15 | .tabItem { 16 | Image(systemName: "gearshape").imageScale(.large) 17 | Text("General") 18 | } 19 | 20 | SidebarTabView() 21 | .tabItem { 22 | Image(systemName: "sidebar.left").imageScale(.large) 23 | Text("Sidebar") 24 | } 25 | 26 | Text("Filters") 27 | .frame(width: 400, height: 150) 28 | .tabItem { 29 | Image(systemName: "stop.circle").imageScale(.large) 30 | Text("Filters") 31 | } 32 | 33 | Text("Search") 34 | .frame(width: 400, height: 150) 35 | .tabItem { 36 | Image(systemName: "magnifyingglass").imageScale(.large) 37 | Text("Search") 38 | } 39 | 40 | Text("Accounts") 41 | .frame(width: 400, height: 150) 42 | .tabItem { 43 | Image(systemName: "person").imageScale(.large) 44 | Text("Accounts") 45 | } 46 | } 47 | } 48 | } 49 | 50 | struct SettingsView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | SettingsView() 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /RedditOs/Features/Settings/SidebarTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarTabView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 17/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SidebarTabView: View { 11 | @AppStorage(SettingsKey.sidebar_enabled_section) var enabledSections = SidebarItem.allCases.map{ $0.rawValue } 12 | private let sortedSections = SidebarItem.allCases.map{ $0.rawValue } 13 | 14 | var body: some View { 15 | Form { 16 | Section(header: Text("Sidebar items")) { 17 | ForEach(SidebarItem.allCases) { item in 18 | HStack { 19 | Toggle(isOn: Binding(get: { 20 | enabledSections.contains(item.rawValue) 21 | }, set: { enabled in 22 | if enabled { 23 | enabledSections.append(item.rawValue) 24 | enabledSections = enabledSections.sorted(by: { sortedSections.firstIndex(of: $0)! < sortedSections.firstIndex(of: $1)! }) 25 | } else { 26 | enabledSections.removeAll(where: { $0 == item.rawValue }) 27 | } 28 | }), label: { 29 | Text(item.title()) 30 | }) 31 | 32 | } 33 | } 34 | } 35 | } 36 | .padding(20) 37 | .frame(width: 400, height: 300) 38 | } 39 | } 40 | 41 | struct SidebarTabView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | SidebarTabView() 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /RedditOs/Features/Sidebar/Sidebar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sidebar.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 08/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import SDWebImageSwiftUI 11 | 12 | struct SidebarView: View { 13 | @EnvironmentObject private var uiState: UIState 14 | @EnvironmentObject private var localData: LocalDataStore 15 | @EnvironmentObject private var currentUser: CurrentUserStore 16 | 17 | @State private var isSearchPopoverPresented = false 18 | @State private var isHovered = false 19 | @State private var isInEditMode = false 20 | 21 | var body: some View { 22 | List(selection: $uiState.sidebarSelection) { 23 | Section { 24 | ForEach(UIState.DefaultChannels.allCases, id: \.self) { item in 25 | NavigationLink(destination: SubredditPostsListView(name: item.rawValue)) { 26 | Label(LocalizedStringKey(item.rawValue.capitalized), systemImage: item.icon()) 27 | }.tag(item.rawValue) 28 | }.animation(nil) 29 | NavigationLink(destination: SubredditPostsListView(name: uiState.searchedSubreddit), 30 | isActive: $uiState.displaySearch) { 31 | EmptyView() 32 | }.hidden() 33 | } 34 | 35 | Section(header: Text("Account")) { 36 | NavigationLink(destination: ProfileView()) { 37 | if let user = currentUser.user { 38 | Label(user.name, systemImage: "person.crop.circle") 39 | } else { 40 | Label("Profile", systemImage: "person.crop.circle") 41 | } 42 | }.tag("profile") 43 | Label("Inbox", systemImage: "envelope") 44 | NavigationLink(destination: SubmittedPostsListView()) { 45 | Label("Posts", systemImage: "square.and.pencil") 46 | }.tag("Posts") 47 | Label("Comments", systemImage: "text.bubble") 48 | NavigationLink(destination: SavedPostsListView()) { 49 | Label("Saved", systemImage: "archivebox") 50 | }.tag("Saved") 51 | }.listItemTint(.redditBlue) 52 | 53 | Section(header: subredditsHeader) { 54 | ForEach(localData.favorites) { reddit in 55 | HStack { 56 | SidebarSubredditRow(name: reddit.name, 57 | iconURL: reddit.iconImg) 58 | .tag("local\(reddit.name)") 59 | if isInEditMode { 60 | Spacer() 61 | Button { 62 | localData.remove(favorite: reddit) 63 | } label: { 64 | Image(systemName: "minus.circle.fill") 65 | .imageScale(.large) 66 | .foregroundColor(.red) 67 | } 68 | .buttonStyle(BorderlessButtonStyle()) 69 | } 70 | } 71 | }.animation(nil) 72 | } 73 | .listItemTint(.redditGold) 74 | .animation(.easeInOut) 75 | 76 | if let subs = currentUser.subscriptions, currentUser.user != nil { 77 | Section(header: Text("Subscriptions")) { 78 | ForEach(subs) { reddit in 79 | HStack { 80 | SidebarSubredditRow(name: reddit.displayName, 81 | iconURL: reddit.iconImg) 82 | .tag(reddit.displayName) 83 | Spacer() 84 | if isHovered { 85 | let isfavorite = localData.favorites.first(where: { $0.name == reddit.displayName}) != nil 86 | Button { 87 | if isfavorite { 88 | localData.remove(favoriteNamed: reddit.displayName) 89 | } else { 90 | localData.add(favorite: SubredditSmall.makeSubredditSmall(with: reddit)) 91 | } 92 | } label: { 93 | Image(systemName: isfavorite ? "star.fill" : "star") 94 | .imageScale(.large) 95 | .foregroundColor(.yellow) 96 | .opacity(/*@START_MENU_TOKEN@*/0.8/*@END_MENU_TOKEN@*/) 97 | } 98 | .buttonStyle(BorderlessButtonStyle()) 99 | } 100 | } 101 | }.animation(nil) 102 | }.listItemTint(.redditBlue) 103 | } 104 | } 105 | .animation(nil) 106 | .listStyle(SidebarListStyle()) 107 | .frame(minWidth: 200, idealWidth: 200, maxWidth: 200, maxHeight: .infinity) 108 | .onHover { hovered in 109 | isHovered = hovered 110 | } 111 | } 112 | 113 | private var subredditsHeader: some View { 114 | HStack(spacing: 8) { 115 | Text("Favorites") 116 | if isHovered { 117 | Button { 118 | isSearchPopoverPresented = true 119 | } label: { 120 | Image(systemName: "plus.circle") 121 | .imageScale(.large) 122 | .foregroundColor(.blue) 123 | } 124 | .buttonStyle(BorderlessButtonStyle()) 125 | .popover(isPresented: $isSearchPopoverPresented) { 126 | PopoverSearchSubredditView() 127 | } 128 | 129 | Button { 130 | isInEditMode.toggle() 131 | } label: { 132 | Image(systemName: isInEditMode ? "trash.circle.fill" : "trash.circle") 133 | .imageScale(.large) 134 | .foregroundColor(.blue) 135 | } 136 | .buttonStyle(BorderlessButtonStyle()) 137 | } 138 | 139 | } 140 | } 141 | } 142 | 143 | struct Sidebar_Previews: PreviewProvider { 144 | static var previews: some View { 145 | SidebarView() 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /RedditOs/Features/Sidebar/SidebarItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarViewModel.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 17/05/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | enum SidebarItem: String, CaseIterable, Identifiable, Equatable { 12 | case home, account, recentlyVisited, favorites, subscription, multi 13 | 14 | var id: String { 15 | rawValue 16 | } 17 | 18 | func title() -> String { 19 | switch self { 20 | case .home: 21 | return "Home" 22 | case .account: 23 | return "Account" 24 | case .favorites: 25 | return "Favorites" 26 | case .recentlyVisited: 27 | return "Recently Visited" 28 | case .subscription: 29 | return "Subscriptions" 30 | case .multi: 31 | return "Multireddits" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /RedditOs/Features/Sidebar/SidebarMultiView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Backend 3 | import UI 4 | 5 | 6 | struct SidebarMultiView: View { 7 | @State private var isExpanded = false 8 | 9 | let multi: Multi 10 | 11 | var body: some View { 12 | HStack { 13 | Button(action: { 14 | isExpanded.toggle() 15 | }, label: { 16 | Image(systemName: isExpanded ? "chevron.down" : "chevron.forward") 17 | }) 18 | .buttonStyle(BorderlessButtonStyle()) 19 | NavigationLink(destination: 20 | SubredditPostsListView(name: multi.subredditsAsName, 21 | customTitle: multi.displayName) 22 | .equatable()) { 23 | Text(multi.displayName) 24 | } 25 | } 26 | if isExpanded { 27 | ForEach(multi.subreddits) { subreddit in 28 | HStack { 29 | NavigationLink(destination: SubredditPostsListView(name: subreddit.name) 30 | .equatable()) { 31 | Text(subreddit.name) 32 | } 33 | .padding(.leading, 8) 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RedditOs/Features/Sidebar/SidebarSubredditRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarSubredditRow.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 16/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import Kingfisher 11 | 12 | struct SidebarSubredditRow: View { 13 | let name: String 14 | let iconURL: String? 15 | 16 | var body: some View { 17 | NavigationLink(destination: SubredditPostsListView(name: name) 18 | .equatable()) { 19 | HStack { 20 | if let image = iconURL, 21 | let url = URL(string: image) { 22 | KFImage(url) 23 | .resizable() 24 | .frame(width: 16, height: 16) 25 | .cornerRadius(8) 26 | } else { 27 | Image(systemName: "globe") 28 | .resizable() 29 | .frame(width: 16, height: 16) 30 | } 31 | Text(name) 32 | } 33 | } 34 | } 35 | } 36 | 37 | struct SidebarSubredditRow_Previews: PreviewProvider { 38 | static var previews: some View { 39 | SidebarSubredditRow(name: "Test", iconURL: nil) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /RedditOs/Features/Subreddit/SubredditAboutPopoverView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubredditAboutPopoverView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 11/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct SubredditAboutPopoverView: View { 12 | @EnvironmentObject private var localData: LocalDataStore 13 | @ObservedObject var viewModel: SubredditViewModel 14 | @State private var isSubscribeHovered = false 15 | 16 | var isFavorite: Bool { 17 | guard let subreddit = viewModel.subreddit else { 18 | return false 19 | } 20 | return localData.favorites.contains(SubredditSmall.makeSubredditSmall(with: subreddit)) 21 | } 22 | 23 | var isSubscriber: Bool { 24 | viewModel.subreddit?.userIsSubscriber == true 25 | } 26 | 27 | var body: some View { 28 | ScrollView { 29 | VStack(alignment: .leading, spacing: 12) { 30 | HStack(alignment: .center, spacing: 16) { 31 | if let subreddit = viewModel.subreddit { 32 | Button(action: { 33 | viewModel.toggleSubscribe() 34 | }, label: { 35 | if isSubscribeHovered { 36 | Text(isSubscriber ? "Unsubscribe?" : "Subscribe?") 37 | } else { 38 | Text(isSubscriber ? "Subscribed" : "Subscribe") 39 | } 40 | }).onHover(perform: { hovering in 41 | isSubscribeHovered = hovering 42 | }) 43 | 44 | Button(action: { 45 | if isFavorite { 46 | localData.remove(favorite: SubredditSmall.makeSubredditSmall(with: subreddit)) 47 | } else { 48 | localData.add(favorite: SubredditSmall.makeSubredditSmall(with: subreddit)) 49 | } 50 | }, label: { 51 | Image(systemName: isFavorite ? "star.fill" : "star") 52 | .resizable() 53 | .frame(width: 16, height: 16) 54 | .foregroundColor(isFavorite ? .redditGold : nil) 55 | }).buttonStyle(BorderlessButtonStyle()) 56 | } 57 | } 58 | Text("About Community") 59 | .font(.title3) 60 | Text(viewModel.subreddit?.publicDescription ?? "") 61 | .font(.body) 62 | if let subscribers = viewModel.subreddit?.subscribers, 63 | let connected = viewModel.subreddit?.accountsActive { 64 | HStack(spacing: 16) { 65 | VStack(alignment: .leading) { 66 | Text("\(subscribers.toRoundedSuffixAsString())") 67 | .fontWeight(.bold) 68 | Text("Members") 69 | } 70 | 71 | VStack(alignment: .leading) { 72 | Text("\(connected.toRoundedSuffixAsString())") 73 | .fontWeight(.bold) 74 | Text("Online") 75 | } 76 | } 77 | } 78 | 79 | Divider() 80 | 81 | HStack(spacing: 4) { 82 | Image(systemName: "calendar.circle") 83 | .font(.title3) 84 | .foregroundColor(.white) 85 | Text(" Created ") + 86 | Text(viewModel.subreddit?.createdUtc ?? Date(), style: .date) 87 | } 88 | 89 | }.padding() 90 | }.frame(width: 250, height: 350) 91 | } 92 | } 93 | 94 | struct SubredditAboutPopoverView_Previews: PreviewProvider { 95 | static var previews: some View { 96 | SubredditAboutPopoverView(viewModel: SubredditViewModel(name: static_subreddit.name)).environmentObject(LocalDataStore()) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /RedditOs/Features/Subreddit/SubredditPostRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubredditPostRow.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct SubredditPostRow: View, Equatable { 12 | static func == (lhs: Self, rhs: Self) -> Bool { 13 | lhs.postId == rhs.postId && 14 | lhs.displayMode == rhs.displayMode 15 | } 16 | 17 | enum DisplayMode: String, CaseIterable { 18 | case compact = "Compact layout" 19 | case large = "Full detail layout" 20 | 21 | func symbol() -> String { 22 | switch self { 23 | case .compact: return "list.bullet" 24 | case .large: return "list.bullet.below.rectangle" 25 | } 26 | } 27 | 28 | func numberOfLines() -> Int? { 29 | switch self { 30 | case .compact: return 3 31 | default: return nil 32 | } 33 | } 34 | } 35 | 36 | private let postId: String 37 | 38 | @StateObject var viewModel: PostViewModel 39 | @Binding var displayMode: DisplayMode 40 | 41 | @Environment(\.openURL) private var openURL 42 | 43 | init(post: SubredditPost, displayMode: Binding) { 44 | self.postId = post.id 45 | _viewModel = StateObject(wrappedValue: PostViewModel(post: post)) 46 | _displayMode = displayMode 47 | } 48 | 49 | var body: some View { 50 | NavigationLink(destination: PostDetailView(viewModel: viewModel).equatable()) { 51 | HStack { 52 | VStack(alignment: .leading) { 53 | HStack(alignment: .center, spacing: 8) { 54 | PostVoteView(viewModel: viewModel) 55 | if displayMode == .large { 56 | SubredditPostThumbnailView(viewModel: viewModel) 57 | } 58 | 59 | VStack(alignment: .leading, spacing: 4) { 60 | Text(viewModel.post.title) 61 | .fontWeight(.bold) 62 | .font(.body) 63 | .lineLimit(displayMode.numberOfLines()) 64 | .foregroundColor(viewModel.post.visited ? .gray : nil) 65 | .help(viewModel.post.title) 66 | HStack { 67 | if let richText = viewModel.post.linkFlairRichtext, !richText.isEmpty { 68 | FlairView(richText: richText, 69 | textColorHex: viewModel.post.linkFlairTextColor, 70 | backgroundColorHex: viewModel.post.linkFlairBackgroundColor, 71 | display: .normal) 72 | } 73 | if (viewModel.post.selftext == nil || viewModel.post.selftext?.isEmpty == true), 74 | displayMode == .large, 75 | let urlString = viewModel.post.url, 76 | let url = URL(string: urlString) { 77 | Link(destination: url) { 78 | Text(url.host ?? url.absoluteString) 79 | .lineLimit(1) 80 | .truncationMode(.tail) 81 | } 82 | } 83 | } 84 | PostInfoView(post: viewModel.post, display: .vertical) 85 | } 86 | } 87 | } 88 | Spacer() 89 | } 90 | } 91 | .padding(.vertical, 8) 92 | .contextMenu { 93 | Button { 94 | viewModel.postVote(vote: .upvote) 95 | } label: { Text("Upvote") } 96 | Button { 97 | viewModel.postVote(vote: .downvote) 98 | } label: { Text("Downvote") } 99 | Button { 100 | viewModel.toggleSave() 101 | } label: { Text(viewModel.post.saved ? "Unsave": "Save") } 102 | Button { 103 | if let url = viewModel.post.redditURL { 104 | openURL(url) 105 | } 106 | } label: { Text("Open in browser") } 107 | Button { 108 | if let url = viewModel.post.redditURL { 109 | NSPasteboard.general.clearContents() 110 | NSPasteboard.general.setString(url.absoluteString, forType: .string) 111 | } 112 | 113 | } label: { Text("Copy URL") } 114 | } 115 | Divider() 116 | } 117 | } 118 | 119 | struct SubredditPostRow_Previews: PreviewProvider { 120 | static var previews: some View { 121 | NavigationView { 122 | List { 123 | SubredditPostRow(post: static_listing, displayMode: .constant(.large)) 124 | SubredditPostRow(post: static_listing, displayMode: .constant(.large)) 125 | SubredditPostRow(post: static_listing, displayMode: .constant(.large)) 126 | 127 | Divider() 128 | 129 | SubredditPostRow(post: static_listing, displayMode: .constant(.compact)) 130 | SubredditPostRow(post: static_listing, displayMode: .constant(.compact)) 131 | SubredditPostRow(post: static_listing, displayMode: .constant(.compact)) 132 | }.frame(width: 500) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /RedditOs/Features/Subreddit/SubredditPostThubnailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubredditPostThubnailView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 21/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import SDWebImageSwiftUI 11 | 12 | struct SubredditPostThumbnailView: View { 13 | let post: SubredditPost 14 | 15 | @ViewBuilder 16 | var body: some View { 17 | if let url = post.thumbnailURL { 18 | WebImage(url: url) 19 | .frame(width: 80, height: 60) 20 | .aspectRatio(contentMode: .fit) 21 | .cornerRadius(8) 22 | } else { 23 | ZStack(alignment: .center) { 24 | RoundedRectangle(cornerRadius: 8) 25 | .frame(width: 80, height: 60) 26 | .foregroundColor(Color.gray) 27 | if post.url != nil { 28 | if post.selftext == nil || post.selftext?.isEmpty == true { 29 | Image(systemName: "link") 30 | .imageScale(.large) 31 | .foregroundColor(.blue) 32 | } else { 33 | Image(systemName: "bubble.left.and.bubble.right.fill") 34 | .imageScale(.large) 35 | .foregroundColor(.white) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | struct SubredditPostThubnailView_Previews: PreviewProvider { 44 | static var previews: some View { 45 | SubredditPostThumbnailView(post: static_listing) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /RedditOs/Features/Subreddit/SubredditPostThumbnailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubredditPostThubnailView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 21/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import Kingfisher 11 | 12 | struct SubredditPostThumbnailView: View { 13 | @ObservedObject var viewModel: PostViewModel 14 | 15 | private var post: SubredditPost { 16 | viewModel.post 17 | } 18 | 19 | var body: some View { 20 | ZStack(alignment: .topLeading) { 21 | if let url = post.thumbnailURL ?? post.secureMedia?.oembed?.thumbnailUrlAsURL { 22 | KFImage(url) 23 | .frame(width: 80, height: 60) 24 | .aspectRatio(contentMode: .fit) 25 | .cornerRadius(8) 26 | } else { 27 | ZStack(alignment: .center) { 28 | RoundedRectangle(cornerRadius: 8) 29 | .frame(width: 80, height: 60) 30 | .foregroundColor(.redditGray) 31 | if post.url != nil { 32 | if post.selftext == nil || post.selftext?.isEmpty == true { 33 | Image(systemName: "link") 34 | .imageScale(.large) 35 | .foregroundColor(.gray) 36 | } else { 37 | Image(systemName: "bubble.left.and.bubble.right.fill") 38 | .imageScale(.large) 39 | .foregroundColor(.gray) 40 | } 41 | } 42 | } 43 | } 44 | if post.saved { 45 | Image(systemName: "bookmark.fill") 46 | .imageScale(.medium) 47 | .foregroundColor(.accentColor) 48 | .padding(.leading, 6) 49 | .padding(.top, -2) 50 | .transition(.move(edge: .top)) 51 | .animation(.interactiveSpring(), value: post.saved) 52 | } 53 | }.padding(4) 54 | } 55 | } 56 | 57 | struct SubredditPostThubnailView_Previews: PreviewProvider { 58 | static var previews: some View { 59 | SubredditPostThumbnailView(viewModel: PostViewModel(post: static_listing)) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /RedditOs/Features/Subreddit/SubredditPostsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubredditPostsListView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import UI 11 | import Kingfisher 12 | 13 | struct SubredditPostsListView: View, Equatable { 14 | static func == (lhs: Self, rhs: Self) -> Bool { 15 | lhs.name == rhs.name && lhs.displayMode == rhs.displayMode 16 | } 17 | 18 | private let name: String 19 | private let title: String? 20 | private let loadingPlaceholders = Array(repeating: static_listing, count: 10) 21 | 22 | @EnvironmentObject private var uiState: UIState 23 | @EnvironmentObject private var localData: LocalDataStore 24 | 25 | @StateObject private var viewModel: SubredditViewModel 26 | @AppStorage(SettingsKey.subreddit_display_mode) private var displayMode = SubredditPostRow.DisplayMode.large 27 | 28 | @State private var subredditAboutPopoverShown = false 29 | 30 | init(name: String, customTitle: String? = nil) { 31 | self.name = name 32 | self.title = customTitle 33 | _viewModel = StateObject(wrappedValue: SubredditViewModel(name: name)) 34 | } 35 | 36 | var isDefaultChannel: Bool { 37 | UIState.DefaultChannels.allCases.map{ $0.rawValue }.contains(viewModel.name) 38 | } 39 | 40 | var subtitle: String { 41 | if isDefaultChannel { 42 | return "" 43 | } 44 | if let subscribers = viewModel.subreddit?.subscribers, let connected = viewModel.subreddit?.accountsActive { 45 | return "\(subscribers.toRoundedSuffixAsString()) members - \(connected.toRoundedSuffixAsString()) online" 46 | } 47 | return "" 48 | } 49 | 50 | var posts: [SubredditPost]? { 51 | if viewModel.isSearchLoading { 52 | return nil 53 | } else if !viewModel.searchText.isEmpty && viewModel.searchResults != nil { 54 | return viewModel.searchResults 55 | } 56 | return viewModel.listings 57 | } 58 | 59 | private var placeholderIcon: some View { 60 | Image(systemName: "globe") 61 | .resizable() 62 | .frame(width: 20, height: 20) 63 | } 64 | 65 | var body: some View { 66 | PostsListView(header: { 67 | if !isDefaultChannel { 68 | SearchBarView(placeholder: "Search", 69 | searchText: $viewModel.searchText) { editing in 70 | 71 | } onCommit: { 72 | 73 | } onCancel: { 74 | 75 | } 76 | .padding(.bottom, 8) 77 | } 78 | }, 79 | posts: posts, 80 | displayMode: .constant(displayMode)) { 81 | if !viewModel.searchText.isEmpty && viewModel.searchResults != nil { 82 | viewModel.fetchSearch(text: viewModel.searchText, after: viewModel.searchResults?.last?.id) 83 | } else { 84 | viewModel.fetchListings(after: viewModel.listings?.last?.id) 85 | } 86 | } 87 | .toolbar { 88 | ToolbarItem(placement: .navigation) { 89 | Group { 90 | if isDefaultChannel { 91 | EmptyView() 92 | } else if let icon = viewModel.subreddit?.iconImg, 93 | let url = URL(string: icon) { 94 | KFImage(url) 95 | .placeholder{ placeholderIcon } 96 | .resizable() 97 | .frame(width: 20, height: 20) 98 | .cornerRadius(10) 99 | } else { 100 | placeholderIcon 101 | } 102 | } 103 | .onTapGesture { 104 | subredditAboutPopoverShown = true 105 | } 106 | .popover(isPresented: $subredditAboutPopoverShown, 107 | content: { SubredditAboutPopoverView(viewModel: viewModel) }) 108 | } 109 | 110 | ToolbarItem { 111 | Picker("Display layout", selection: $displayMode) { 112 | ForEach(SubredditPostRow.DisplayMode.allCases, id: \.self) { item in 113 | Image(systemName: item.symbol()) 114 | .tag(item) 115 | } 116 | } 117 | .pickerStyle(InlinePickerStyle()) 118 | .help("Select display layout style") 119 | } 120 | 121 | ToolbarItem { 122 | if isDefaultChannel { 123 | Text("") 124 | } else { 125 | Picker(selection: $viewModel.sortOrder, 126 | label: Text("Sorting"), 127 | content: { 128 | ForEach(SubredditViewModel.SortOrder.allCases, id: \.self) { sort in 129 | Text(sort.rawValue.capitalized).tag(sort) 130 | } 131 | }) 132 | } 133 | } 134 | } 135 | .navigationTitle(title ?? viewModel.name.capitalized) 136 | .navigationSubtitle(subtitle) 137 | .frame(minHeight: 500) 138 | .onAppear { 139 | if !isDefaultChannel { 140 | viewModel.fetchAbout() 141 | } 142 | uiState.selectedSubreddit = viewModel 143 | viewModel.fetchListings(after: nil) 144 | } 145 | } 146 | } 147 | 148 | struct Listing_Previews: PreviewProvider { 149 | static var previews: some View { 150 | SubredditPostsListView(name: "Best") 151 | } 152 | } 153 | 154 | 155 | -------------------------------------------------------------------------------- /RedditOs/Features/Subreddit/SubredditViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubredditViewModel.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | import Backend 12 | 13 | class SubredditViewModel: ObservableObject { 14 | enum SortOrder: String, CaseIterable { 15 | case hot, new, top, rising 16 | } 17 | 18 | let name: String 19 | 20 | @Published var subreddit: Subreddit? 21 | @Published var listings: [SubredditPost]? 22 | @Published var searchResults: [SubredditPost]? 23 | 24 | @Published var searchText = "" 25 | @Published var isSearchLoading = false 26 | 27 | @AppStorage(SettingsKey.subreddit_defaut_sort_order) var sortOrder = SortOrder.hot { 28 | didSet { 29 | listings = nil 30 | fetchListings(after: nil) 31 | } 32 | } 33 | 34 | private var postsSearchPublisher: AnyPublisher, Never>? 35 | private var cancellableSet: Set = Set() 36 | 37 | private let localData: LocalDataStore 38 | 39 | init(name: String, localData: LocalDataStore = .shared) { 40 | self.name = name 41 | self.localData = localData 42 | 43 | $searchText 44 | .debounce(for: .milliseconds(500), scheduler: RunLoop.main) 45 | .removeDuplicates() 46 | .compactMap{ $0 } 47 | .sink(receiveValue: { [weak self] text in 48 | if text.isEmpty { 49 | self?.isSearchLoading = false 50 | self?.searchResults = nil 51 | } else { 52 | self?.isSearchLoading = true 53 | self?.fetchSearch(text: text, after: nil) 54 | } 55 | }) 56 | .store(in: &cancellableSet) 57 | } 58 | 59 | func fetchAbout() { 60 | Subreddit.fetchAbout(name: name) 61 | .receive(on: DispatchQueue.main) 62 | .compactMap{ $0?.data } 63 | .sink { [weak self] subreddit in 64 | self?.subreddit = subreddit 65 | self?.localData.add(recent: SubredditSmall.makeSubredditSmall(with: subreddit)) 66 | } 67 | .store(in: &cancellableSet) 68 | } 69 | 70 | func fetchListings(after: String?) { 71 | SubredditPost.fetch(subreddit: name, 72 | sort: sortOrder.rawValue, 73 | after: after) 74 | .receive(on: DispatchQueue.main) 75 | .map{ $0.data?.children.map{ $0.data }} 76 | .sink{ [weak self] listings in 77 | if after != nil, let listings = listings { 78 | self?.listings?.append(contentsOf: listings) 79 | } else if self?.listings == nil { 80 | self?.listings = listings 81 | } 82 | } 83 | .store(in: &cancellableSet) 84 | } 85 | 86 | func fetchSearch(text: String, after: String?) { 87 | var params = ["q": text, "restrict_sr": "1"] 88 | if let after = after { 89 | params["after"] = after 90 | } 91 | postsSearchPublisher = API.shared.request(endpoint: .searchPosts(name: name), params: params) 92 | .subscribe(on: DispatchQueue.global()) 93 | .replaceError(with: ListingResponse(error: "error")) 94 | .eraseToAnyPublisher() 95 | 96 | postsSearchPublisher?.receive(on: DispatchQueue.main) 97 | .map{ $0.data?.children.map{ $0.data }} 98 | .sink(receiveValue: { [weak self] results in 99 | self?.isSearchLoading = false 100 | if after != nil, let results = results { 101 | self?.searchResults?.append(contentsOf: results) 102 | } else { 103 | self?.searchResults = results 104 | } 105 | }) 106 | .store(in: &cancellableSet) 107 | } 108 | 109 | func toggleSubscribe() { 110 | if subreddit?.userIsSubscriber == true { 111 | subreddit?.unSubscribe() 112 | .receive(on: DispatchQueue.main) 113 | .sink { [weak self] response in 114 | if response.error != nil { 115 | self?.subreddit?.userIsSubscriber = true 116 | } 117 | } 118 | .store(in: &cancellableSet) 119 | } else { 120 | subreddit?.subscribe() 121 | .receive(on: DispatchQueue.main) 122 | .sink { [weak self] response in 123 | if response.error != nil { 124 | self?.subreddit?.userIsSubscriber = false 125 | } 126 | } 127 | .store(in: &cancellableSet) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /RedditOs/Features/Users/popover/UserPopoverView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserPopoverView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UserPopoverView: View { 11 | @Environment(\.presentationMode) var presentation 12 | @EnvironmentObject private var uiState: UIState 13 | @StateObject private var viewModel: UserViewModel 14 | 15 | init(username: String) { 16 | _viewModel = StateObject(wrappedValue: UserViewModel(username: username)) 17 | } 18 | var body: some View { 19 | Group { 20 | if let user = viewModel.user { 21 | VStack(alignment: .center) { 22 | Text(user.name) 23 | .font(.title2) 24 | .fontWeight(.bold) 25 | .padding(.top, 16) 26 | Spacer() 27 | UserHeaderView(user: user) 28 | Spacer() 29 | Button(action: { 30 | if let user = viewModel.user { 31 | presentation.wrappedValue.dismiss() 32 | uiState.presentedSheetRoute = .user(user: user) 33 | } 34 | }) { 35 | Text("View full profile") 36 | } 37 | .padding(.bottom, 16) 38 | } 39 | } else { 40 | LoadingRow(text: "Loading user") 41 | } 42 | }.frame(width: 420, height: 200) 43 | } 44 | } 45 | 46 | struct UserPopoverView_Previews: PreviewProvider { 47 | static var previews: some View { 48 | UserPopoverView(username: "").environmentObject(UIState()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /RedditOs/Features/Users/shared/UserHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserHeaderVIew.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import Kingfisher 11 | 12 | struct UserHeaderView: View { 13 | let user: User 14 | 15 | var body: some View { 16 | HStack(spacing: 32) { 17 | Spacer() 18 | if let avatar = user.avatarURL { 19 | KFImage(avatar) 20 | .resizable() 21 | .frame(width: 100, height: 100) 22 | .cornerRadius(8) 23 | } else { 24 | RoundedRectangle(cornerRadius: 8) 25 | .fill(Color.redditGray) 26 | .frame(width: 100, height: 100) 27 | } 28 | 29 | VStack(alignment: .center, spacing: 16) { 30 | HStack(spacing: 32) { 31 | makeStatsView(number: user.commentKarma.toRoundedSuffixAsString(), 32 | name: "Comment Karma") 33 | makeStatsView(number: user.linkKarma.toRoundedSuffixAsString(), 34 | name: "Link Karma") 35 | } 36 | if let created = user.createdUtc { 37 | VStack { 38 | Text(created, style: .relative) 39 | .font(.title) 40 | .fontWeight(.bold) 41 | Text("User since") 42 | .font(.subheadline) 43 | .fontWeight(.bold) 44 | .foregroundColor(.gray) 45 | } 46 | } 47 | } 48 | Spacer() 49 | } 50 | } 51 | 52 | private func makeStatsView(number: String, name: String) -> some View { 53 | VStack { 54 | Text(number) 55 | .font(.title) 56 | .fontWeight(.bold) 57 | Text(name) 58 | .font(.subheadline) 59 | .fontWeight(.bold) 60 | .foregroundColor(.gray) 61 | } 62 | } 63 | } 64 | 65 | struct UserView_Previews: PreviewProvider { 66 | static var previews: some View { 67 | UserHeaderView(user: static_user) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /RedditOs/Features/Users/shared/UserViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserViewModel.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import Foundation 9 | import Backend 10 | import Combine 11 | import SwiftUI 12 | 13 | class UserViewModel: ObservableObject { 14 | let username: String 15 | 16 | @Published var user: User? 17 | @Published public private(set) var overview: [GenericListingContent]? 18 | @Published public private(set) var submittedPosts: [SubredditPost]? 19 | @Published public private(set) var comments: [Comment]? 20 | 21 | private var cancellable: AnyCancellable? 22 | private var disposables: [AnyCancellable?] = [] 23 | private var afterOverview: String? 24 | 25 | init(username: String) { 26 | self.username = username 27 | fetchUser() 28 | } 29 | 30 | init(user: User) { 31 | self.username = user.name 32 | self.user = user 33 | 34 | fetchOverview() 35 | fetchSubmitted(after: nil) 36 | fetchComment(after: nil) 37 | } 38 | 39 | func fetchUser() { 40 | cancellable = User.fetchUserAbout(username: username)? 41 | .receive(on: DispatchQueue.main) 42 | .sink(receiveCompletion: { error in 43 | print(error) 44 | }, receiveValue: { data in 45 | self.user = data.data 46 | }) 47 | } 48 | 49 | func fetchOverview() { 50 | let cancellable = user?.fetchOverview(after: afterOverview) 51 | .receive(on: DispatchQueue.main) 52 | .sink{ content in 53 | self.afterOverview = content.data?.after 54 | let listings = content.data?.children.map{ $0.data } 55 | if self.overview?.last != nil, let listings = listings { 56 | self.overview?.append(contentsOf: listings) 57 | } else if self.overview == nil { 58 | self.overview = listings 59 | } 60 | } 61 | disposables.append(cancellable) 62 | } 63 | 64 | public func fetchSubmitted(after: SubredditPost?) { 65 | let cancellable = user?.fetchSubmitted(after: after) 66 | .receive(on: DispatchQueue.main) 67 | .map{ $0.data?.children.map{ $0.data }} 68 | .sink{ listings in 69 | if self.submittedPosts?.last != nil, let listings = listings { 70 | self.submittedPosts?.append(contentsOf: listings) 71 | } else if self.submittedPosts == nil { 72 | self.submittedPosts = listings 73 | } 74 | } 75 | disposables.append(cancellable) 76 | } 77 | 78 | public func fetchComment(after: Comment?) { 79 | let cancellable = user?.fetchComments(after: after) 80 | .receive(on: DispatchQueue.main) 81 | .map{ $0.data?.children.map{ $0.data }} 82 | .sink{ listings in 83 | if self.comments?.last != nil, let listings = listings { 84 | self.comments?.append(contentsOf: listings) 85 | } else if self.comments == nil { 86 | self.comments = listings 87 | } 88 | } 89 | disposables.append(cancellable) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /RedditOs/Features/Users/sheet/UserSheetCommentsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserSheetCommentsView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import Backend 11 | 12 | struct UserSheetCommentsView: View { 13 | @ObservedObject var viewModel: UserViewModel 14 | private let loadingPlaceholders = Array(repeating: static_comment, count: 20) 15 | 16 | var body: some View { 17 | List { 18 | ForEach(viewModel.comments ?? loadingPlaceholders) { comment in 19 | CommentRow(comment: comment, isRoot: true).redacted(reason: viewModel.comments != nil ? [] : .placeholder) 20 | } 21 | if viewModel.comments != nil { 22 | LoadingRow(text: "Loading next page") 23 | .onAppear { 24 | viewModel.fetchComment(after: viewModel.comments?.last) 25 | } 26 | } 27 | }.frame(width: 500) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /RedditOs/Features/Users/sheet/UserSheetOverviewView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserSheetOverviewView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import Backend 11 | 12 | struct UserSheetOverviewView: View { 13 | @ObservedObject var viewModel: UserViewModel 14 | private let loadingPlaceholders = Array(repeating: static_listing, count: 10) 15 | 16 | var body: some View { 17 | List { 18 | if let user = viewModel.user { 19 | UserHeaderView(user: user) 20 | .padding(.vertical, 16) 21 | } 22 | if let overview = viewModel.overview { 23 | let posts = overview.compactMap{ $0.post } 24 | ForEach(posts) { post in 25 | SubredditPostRow(post: post, displayMode: .constant(.large)) 26 | } 27 | LoadingRow(text: "Loading next page") 28 | .onAppear { 29 | viewModel.fetchOverview() 30 | } 31 | } else { 32 | ForEach(loadingPlaceholders) { post in 33 | SubredditPostRow(post: post, displayMode: .constant(.large)) 34 | .redacted(reason: .placeholder) 35 | } 36 | } 37 | } 38 | .listStyle(InsetListStyle()) 39 | .frame(width: 500) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /RedditOs/Features/Users/sheet/UserSheetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserSheetView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 26/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct UserSheetView: View { 12 | @Environment(\.presentationMode) var presentation 13 | @StateObject private var viewModel: UserViewModel 14 | @State private var sidebarSelection: Set = [1] 15 | 16 | init(user: User) { 17 | _viewModel = StateObject(wrappedValue: UserViewModel(user: user)) 18 | } 19 | 20 | init(username: String) { 21 | _viewModel = StateObject(wrappedValue: UserViewModel(username: username)) 22 | } 23 | 24 | var body: some View { 25 | NavigationView { 26 | List(selection: $sidebarSelection) { 27 | Section(header: Text(viewModel.username)) { 28 | Label("Overview", systemImage: "square.and.pencil").tag(1) 29 | Label("Posts", systemImage: "square.and.pencil").tag(2) 30 | Label("Comments", systemImage: "text.bubble").tag(3) 31 | Label("Awards", systemImage: "rosette").tag(4) 32 | }.accentColor(.redditBlue) 33 | 34 | } 35 | .listStyle(SidebarListStyle()) 36 | 37 | NavigationView { 38 | if sidebarSelection.first == 1 { 39 | UserSheetOverviewView(viewModel: viewModel) 40 | } else if sidebarSelection.first == 2 { 41 | if let posts = viewModel.submittedPosts { 42 | PostsListView(header: { EmptyView() }, 43 | posts: posts, displayMode: .constant(.large)) { 44 | viewModel.fetchSubmitted(after: posts.last) 45 | } 46 | } 47 | } else if sidebarSelection.first == 3 { 48 | UserSheetCommentsView(viewModel: viewModel) 49 | } 50 | 51 | PostNoSelectionPlaceholder() 52 | } 53 | } 54 | .frame(width: 1200, height: 500) 55 | .toolbar { 56 | ToolbarItem(placement: .confirmationAction) { 57 | Button { 58 | presentation.wrappedValue.dismiss() 59 | } label: { 60 | Text("Close") 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | struct UserSheetView_Previews: PreviewProvider { 68 | static var previews: some View { 69 | UserSheetView(user: static_user) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /RedditOs/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleURLTypes 20 | 21 | 22 | CFBundleTypeRole 23 | Editor 24 | CFBundleURLName 25 | redditos 26 | CFBundleURLSchemes 27 | 28 | redditos 29 | 30 | 31 | 32 | CFBundleVersion 33 | $(CURRENT_PROJECT_VERSION) 34 | LSApplicationCategoryType 35 | public.app-category.news 36 | LSMinimumSystemVersion 37 | $(MACOSX_DEPLOYMENT_TARGET) 38 | 39 | 40 | -------------------------------------------------------------------------------- /RedditOs/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RedditOs/RedditOs.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /RedditOs/RedditOsApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RedditOsApp.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 08/07/2020. 6 | // 7 | 8 | import AppKit 9 | import SwiftUI 10 | import Backend 11 | 12 | @main 13 | struct RedditOsApp: App { 14 | @StateObject private var uiState = UIState() 15 | @StateObject private var localData = LocalDataStore.shared 16 | private let searchText = QuickSearchState() 17 | 18 | @SceneBuilder 19 | var body: some Scene { 20 | WindowGroup { 21 | NavigationView { 22 | SidebarView() 23 | ProgressView() 24 | PostNoSelectionPlaceholder() 25 | .toolbar { 26 | PostDetailToolbar(shareURL: nil) 27 | } 28 | } 29 | .frame(minWidth: 1300, minHeight: 600) 30 | .environmentObject(localData) 31 | .environmentObject(OauthClient.shared) 32 | .environmentObject(CurrentUserStore.shared) 33 | .environmentObject(uiState) 34 | .environmentObject(searchText) 35 | .onOpenURL { url in 36 | OauthClient.shared.handleNextURL(url: url) 37 | } 38 | .sheet(item: $uiState.presentedSheetRoute, content: { $0.makeView() }) 39 | } 40 | .commands { 41 | CommandMenu("Subreddit") { 42 | Button(action: { 43 | uiState.selectedSubreddit?.listings = nil 44 | uiState.selectedSubreddit?.fetchListings(after: nil) 45 | }) { 46 | Text("Refresh") 47 | } 48 | .disabled(uiState.selectedSubreddit == nil) 49 | .keyboardShortcut("r", modifiers: [.command]) 50 | 51 | Divider() 52 | 53 | Button(action: { 54 | if let subreddit = uiState.selectedSubreddit?.subreddit { 55 | let small = SubredditSmall.makeSubredditSmall(with: subreddit) 56 | if localData.favorites.contains(small) { 57 | localData.remove(favorite: small) 58 | } else { 59 | localData.add(favorite: small) 60 | } 61 | } 62 | 63 | }) { 64 | Text("Toggle favorite") 65 | } 66 | .disabled(uiState.selectedSubreddit == nil) 67 | .keyboardShortcut("f", modifiers: [.command, .shift]) 68 | } 69 | 70 | CommandMenu("Post") { 71 | Button(action: { 72 | uiState.selectedPost?.fechComments() 73 | }) { 74 | Text("Refresh comments") 75 | } 76 | .disabled(uiState.selectedPost != nil) 77 | .keyboardShortcut("r", modifiers: [.command, .shift]) 78 | 79 | Button(action: { 80 | uiState.selectedPost?.toggleSave() 81 | }) { 82 | Text(uiState.selectedPost?.post.saved == true ? "Unsave" : "Save") 83 | } 84 | .disabled(uiState.selectedPost == nil) 85 | .keyboardShortcut("s", modifiers: .command) 86 | 87 | Divider() 88 | Button(action: { 89 | uiState.selectedPost?.postVote(vote: .upvote) 90 | }) { 91 | Text("Upvote") 92 | } 93 | .disabled(uiState.selectedPost == nil) 94 | .keyboardShortcut(.upArrow, modifiers: .shift) 95 | 96 | Button(action: { 97 | uiState.selectedPost?.postVote(vote: .downvote) 98 | }) { 99 | Text("Downvote") 100 | } 101 | .disabled(uiState.selectedPost == nil) 102 | .keyboardShortcut(.downArrow, modifiers: .shift) 103 | } 104 | 105 | #if DEBUG 106 | CommandMenu("Debug") { 107 | Button(action: { 108 | switch OauthClient.shared.authState { 109 | case let .authenthicated(token): 110 | NSPasteboard.general.clearContents() 111 | NSPasteboard.general.setString(token, forType: .string) 112 | default: 113 | break 114 | } 115 | }) { 116 | Text("Copy oauth token to pasteboard") 117 | } 118 | } 119 | #endif 120 | } 121 | 122 | Settings { 123 | SettingsView() 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /RedditOs/Representables/LinkPresentationRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkPresentationRepresentable.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import Foundation 9 | import LinkPresentation 10 | import SwiftUI 11 | 12 | public struct LinkPresentationView: NSViewRepresentable { 13 | public let url: URL 14 | @Binding var redraw: Bool 15 | 16 | public func makeNSView(context: Context) -> some NSView { 17 | let view = LPLinkView(url: url) 18 | let provider = LPMetadataProvider() 19 | provider.startFetchingMetadata(for: url) { (metadata, error) in 20 | if let md = metadata { 21 | DispatchQueue.main.async { 22 | view.metadata = md 23 | redraw.toggle() 24 | } 25 | } 26 | } 27 | return view 28 | } 29 | 30 | public func updateNSView(_ nsView: NSViewType, context: Context) { 31 | 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /RedditOs/Representables/SharingPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SharingPicker.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 12/08/2020. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import SwiftUI 11 | 12 | struct SharingsPicker: NSViewRepresentable { 13 | @Binding var isPresented: Bool 14 | var sharingItems: [Any] = [] 15 | 16 | func makeNSView(context: Context) -> NSView { 17 | let view = NSView() 18 | return view 19 | } 20 | 21 | func updateNSView(_ nsView: NSView, context: Context) { 22 | if isPresented { 23 | let picker = NSSharingServicePicker(items: sharingItems) 24 | picker.delegate = context.coordinator 25 | DispatchQueue.main.async { 26 | picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY) 27 | } 28 | } 29 | } 30 | 31 | func makeCoordinator() -> Coordinator { 32 | Coordinator(owner: self) 33 | } 34 | 35 | class Coordinator: NSObject, NSSharingServicePickerDelegate { 36 | let owner: SharingsPicker 37 | 38 | init(owner: SharingsPicker) { 39 | self.owner = owner 40 | } 41 | 42 | func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose service: NSSharingService?) { 43 | sharingServicePicker.delegate = nil 44 | self.owner.isPresented = false 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /RedditOs/Shared/AwardView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostAwardView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 05/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import Kingfisher 11 | 12 | struct AwardsView: View { 13 | let awards: [Award] 14 | 15 | @State private var popoverPresented = false 16 | @State private var descriptionPopoverPresented = false 17 | 18 | var body: some View { 19 | HStack { 20 | ForEach(awards.prefix(3)) { award in 21 | HStack(spacing: 2) { 22 | KFImage(award.staticIconUrl) 23 | .resizable() 24 | .frame(width: 16, height: 16) 25 | } 26 | } 27 | Text("\(awards.map{ $0.count }.reduce(0, +))") 28 | .foregroundColor(.gray) 29 | .font(.footnote) 30 | } 31 | .onTapGesture(count: 1, perform: { 32 | popoverPresented = true 33 | }) 34 | .popover(isPresented: $popoverPresented, content: { 35 | ScrollView { 36 | VStack { 37 | ForEach(awards) { award in 38 | HStack(spacing: 8) { 39 | KFImage(award.staticIconUrl) 40 | .resizable() 41 | .frame(width: 30, height: 30) 42 | Text(award.name) 43 | Spacer() 44 | Text("\(award.count)") 45 | } 46 | Divider() 47 | } 48 | }.padding() 49 | }.frame(width: 250, height: 300) 50 | }) 51 | } 52 | } 53 | 54 | struct PostAwardsView_Previews: PreviewProvider { 55 | static var previews: some View { 56 | AwardsView(awards: [Award.default, Award.default, 57 | Award.default, Award.default, 58 | Award.default, Award.default]) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /RedditOs/Shared/FlairView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlairView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 17/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | import Kingfisher 11 | 12 | struct FlairView: View { 13 | enum Display { 14 | case small, normal 15 | } 16 | 17 | let richText: [FlairRichText]? 18 | let textColorHex: String? 19 | let backgroundColorHex: String? 20 | let display: Display 21 | 22 | var backgroundColor: Color { 23 | if let color = backgroundColorHex { 24 | if color.isEmpty { 25 | return .gray 26 | } 27 | return Color(hex: color) 28 | } 29 | return .redditBlue 30 | } 31 | 32 | var textColor: Color { 33 | if backgroundColor == .gray { 34 | return .white 35 | } 36 | return textColorHex == "dark" ? .textColor : .white 37 | } 38 | 39 | @ViewBuilder 40 | var body: some View { 41 | if let texts = richText { 42 | HStack(spacing: 4) { 43 | ForEach(texts, id: \.self) { text in 44 | if text.e == "emoji" { 45 | KFImage(text.u!) 46 | .resizable() 47 | .aspectRatio(contentMode: .fit) 48 | .frame(width: 20, height: 20) 49 | } else if text.e == "text" { 50 | Text(text.t!) 51 | .foregroundColor(textColor) 52 | .font(display == .small ? .footnote : .callout) 53 | .fontWeight(.semibold) 54 | .lineLimit(1) 55 | .truncationMode(.tail) 56 | } else { 57 | EmptyView() 58 | } 59 | } 60 | } 61 | .padding(4) 62 | .background( 63 | RoundedRectangle(cornerRadius: 6) 64 | .fill(backgroundColor) 65 | ) 66 | } else { 67 | EmptyView() 68 | } 69 | } 70 | } 71 | 72 | struct FlairView_Previews: PreviewProvider { 73 | static var previews: some View { 74 | FlairView(richText: nil, 75 | textColorHex: nil, 76 | backgroundColorHex: "#dadada", 77 | display: .normal) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /RedditOs/Shared/LoadingRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingRow.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 10/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoadingRow: View { 11 | let text: String? 12 | 13 | var body: some View { 14 | HStack { 15 | Spacer() 16 | if let text = text { 17 | ProgressView(text) 18 | } else { 19 | ProgressView() 20 | } 21 | Spacer() 22 | } 23 | } 24 | } 25 | 26 | struct LoadingRow_Previews: PreviewProvider { 27 | static var previews: some View { 28 | LoadingRow(text: nil) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /RedditOs/Shared/PostInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListingInfoView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct PostInfoView: View { 12 | enum Display { 13 | case vertical, horizontal 14 | } 15 | 16 | @EnvironmentObject private var uiState: UIState 17 | 18 | let post: SubredditPost 19 | let display: Display 20 | 21 | @State private var showUserPopover = false 22 | 23 | var body: some View { 24 | switch display { 25 | case .vertical: 26 | VStack(alignment: .leading, spacing: 6) { 27 | content 28 | }.font(.callout) 29 | case .horizontal: 30 | HStack(spacing: 12) { 31 | content 32 | }.font(.callout) 33 | } 34 | } 35 | 36 | @ViewBuilder 37 | var content: some View { 38 | HStack(spacing: 6) { 39 | Button(action: { 40 | uiState.searchRoute = .subreddit(subreddit: post.subreddit) 41 | }, label: { 42 | Text("r/\(post.subreddit)") 43 | .fontWeight(.bold) 44 | }) 45 | .buttonStyle(BorderlessButtonStyle()) 46 | 47 | Button(action: { 48 | showUserPopover = true 49 | }, label: { 50 | HStack(spacing: 4) { 51 | Image(systemName: "person") 52 | Text("u/\(post.author)") 53 | } 54 | }) 55 | .buttonStyle(BorderlessButtonStyle()) 56 | .popover(isPresented: $showUserPopover, content: { 57 | UserPopoverView(username: post.author) 58 | }) 59 | if let richText = post.authorFlairRichtext, !richText.isEmpty { 60 | FlairView(richText: richText, 61 | textColorHex: post.authorFlairTextColor, 62 | backgroundColorHex: post.authorFlairBackgroundColor, 63 | display: .small) 64 | } 65 | } 66 | HStack(spacing: 12) { 67 | HStack(spacing: 4) { 68 | Image(systemName: "clock") 69 | Text(post.createdUtc, style: .offset) 70 | } 71 | HStack(spacing: 4) { 72 | Image(systemName: "bubble.middle.bottom") 73 | .imageScale(.small) 74 | Text("\(post.numComments)") 75 | } 76 | if !post.allAwardings.isEmpty { 77 | AwardsView(awards: post.allAwardings) 78 | } 79 | } 80 | .foregroundColor(.gray) 81 | } 82 | } 83 | 84 | struct ListingInfoView_Previews: PreviewProvider { 85 | static var previews: some View { 86 | Group { 87 | PostInfoView(post: static_listing, display: .horizontal) 88 | PostInfoView(post: static_listing, display: .vertical) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /RedditOs/Shared/PostNoSelectionPlaceholder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostNoSelectionPlaceholder.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 25/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PostNoSelectionPlaceholder: View { 11 | var body: some View { 12 | VStack(alignment: .center, spacing: 16) { 13 | Text("No post selected") 14 | .font(.headline) 15 | .fontWeight(.bold) 16 | Image(systemName: "bolt.slash") 17 | .resizable() 18 | .frame(width: 20, height: 20) 19 | } 20 | .frame(minWidth: 500, 21 | maxWidth: .infinity, 22 | maxHeight: .infinity) 23 | } 24 | } 25 | 26 | struct PostNoSelectionPlaceholder_Previews: PreviewProvider { 27 | static var previews: some View { 28 | PostNoSelectionPlaceholder() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /RedditOs/Shared/PostVoteView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostVoteView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 09/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct PostVoteView: View { 12 | @ObservedObject var viewModel: PostViewModel 13 | 14 | var body: some View { 15 | VStack(spacing: 2) { 16 | Button(action: { 17 | viewModel.postVote(vote: viewModel.post.likes == true ? .neutral : .upvote) 18 | }, 19 | label: { 20 | Image(systemName: "arrowtriangle.up.circle") 21 | .resizable() 22 | .frame(width: 16, height: 16) 23 | .foregroundColor(viewModel.post.likes == true ? .accentColor : nil) 24 | }).buttonStyle(BorderlessButtonStyle()) 25 | 26 | Text(viewModel.post.ups.toRoundedSuffixAsString()) 27 | .fontWeight(.bold) 28 | .minimumScaleFactor(0.1) 29 | .lineLimit(1) 30 | 31 | Button(action: { 32 | viewModel.postVote(vote: viewModel.post.likes == false ? .neutral : .downvote) 33 | }, 34 | label: { 35 | Image(systemName: "arrowtriangle.down.circle") 36 | .resizable() 37 | .frame(width: 16, height: 16) 38 | .foregroundColor(viewModel.post.likes == false ? .redditBlue : nil) 39 | }).buttonStyle(BorderlessButtonStyle()) 40 | } 41 | .frame(width: 40) 42 | .padding(4) 43 | } 44 | } 45 | 46 | struct ListingVoteView_Previews: PreviewProvider { 47 | static var previews: some View { 48 | PostVoteView(viewModel: PostViewModel(post: static_listing)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /RedditOs/Shared/PostsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostsListView.swift 3 | // RedditOs 4 | // 5 | // Created by Thomas Ricouard on 25/07/2020. 6 | // 7 | 8 | import SwiftUI 9 | import Backend 10 | 11 | struct PostsListView: View, Equatable { 12 | static func == (lhs: Self, rhs: Self) -> Bool { 13 | lhs.posts?.count == rhs.posts?.count && 14 | rhs.displayMode == lhs.displayMode 15 | } 16 | 17 | private let loadingPlaceholders = Array(repeating: static_listing, count: 10) 18 | 19 | @ViewBuilder var header: () -> Header 20 | let posts: [SubredditPost]? 21 | @Binding var displayMode: SubredditPostRow.DisplayMode 22 | var onNextPage: (() -> Void) 23 | 24 | var body: some View { 25 | List { 26 | header() 27 | ForEach(posts ?? loadingPlaceholders) { post in 28 | SubredditPostRow(post: post, 29 | displayMode: $displayMode) 30 | .equatable() 31 | .redacted(reason: posts == nil ? .placeholder : []) 32 | } 33 | if posts != nil { 34 | LoadingRow(text: "Loading next page") 35 | .onAppear { 36 | self.onNextPage() 37 | } 38 | } 39 | } 40 | .listStyle(.inset) 41 | .frame(minWidth: 400, idealWidth: 500, maxWidth: 700) 42 | } 43 | } 44 | 45 | struct PostsListView_Previews: PreviewProvider { 46 | static var previews: some View { 47 | PostsListView(header: { EmptyView() }, 48 | posts: nil, 49 | displayMode: .constant(.large), 50 | onNextPage: { 51 | 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sketches/AppIcon.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/RedditOS/525cd2417db7b99ce506b6bfc2684d6311cb2c4b/Sketches/AppIcon.sketch --------------------------------------------------------------------------------