├── HackerNews ├── ionicons.ttf ├── Assets.xcassets │ ├── Contents.json │ ├── expand.imageset │ │ ├── expand-button.png │ │ ├── expand-button-2.png │ │ └── Contents.json │ ├── right_arrow.imageset │ │ ├── keyboard-right-arrow-button.png │ │ ├── keyboard-right-arrow-button-2.png │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── SettingsTableView.swift ├── SettingsNodeType.swift ├── WebViewControllerType.swift ├── OptionType.swift ├── StoryNodeDelegate.swift ├── Collection.swift ├── ASDisplayNode.swift ├── UserCommentNodeDelegate.swift ├── UserAboutBioNodeDelegate.swift ├── AuthRequiredNodeDelegate.swift ├── HNPostStoryDelegate.swift ├── UIColor+Hex.swift ├── UITabBar.swift ├── HNSearchProtocol.swift ├── StoryBarDelegate.swift ├── UINavigationBar.swift ├── AuthDelegate.swift ├── CommentNodeDelegate.swift ├── CommentOptionsNodeDelegate.swift ├── FullButtonNode.swift ├── MiddleButtonNode.swift ├── StoryDetailNodeDelegate.swift ├── UserOptionsNavigationRight.swift ├── CommentsOptionsNavigationRight.swift ├── UIButton.swift ├── MBProgressHUD.swift ├── NSError.swift ├── HNTableNode.swift ├── ShareHelper.swift ├── TextFieldNode.swift ├── GeneralType.swift ├── Utils.swift ├── WebOptionType.swift ├── CommentsOptionsComment.swift ├── Constants.swift ├── UINavigationItem.swift ├── OptionSelectView.swift ├── CommentsTableBackgroundView.swift ├── StoryTypeNode.swift ├── ProfileOptionsType.swift ├── AlertBuilder.swift ├── NSMutableAttributedString.swift ├── AlgoliaResponse.swift ├── OptionSelectViewDelegate.swift ├── HNPostTextCellNode.swift ├── HNDimensions.swift ├── BackgroundDividerNode.swift ├── OptionSelectTableView.swift ├── Localizable.strings ├── ThemeType.swift ├── DataRequest.swift ├── AppDelegate.swift ├── GoogleService-Info.plist ├── SearchSortType.swift ├── HNOptionActionNode.swift ├── CommentsTableView.swift ├── FontSizeType.swift ├── HNPagerViewController.swift ├── StoryType.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── OptionSelectNode.swift ├── Info.plist ├── SettingsProvider.swift ├── TextHelper.swift ├── HNOptionHeaderNode.swift ├── UserAboutBioNode.swift ├── SettingsType.swift ├── BaseViewController.swift ├── NavigationController.swift ├── HNAPI.swift ├── SearchTimeRangeType.swift ├── FontType.swift ├── UserAboutNode.swift ├── UserCommentNode.swift ├── HNPostViewController.swift ├── FontManager.swift ├── UserPostsViewController.swift ├── WebViewNode.swift ├── User.swift ├── OptionsViewController.swift ├── FirebaseAPI.swift ├── AuthRequiredNode.swift ├── ItemProvider.swift ├── UserAboutViewController.swift ├── SearchStoriesViewController.swift ├── TabBarController.swift ├── HNPostStoryViewController.swift ├── HistoryViewController.swift ├── StoryNode.swift ├── UserViewController.swift ├── AuthViewController.swift ├── SettingsNode.swift ├── CommentOptionsNode.swift ├── StoryDetailNode.swift ├── StoryBarNode.swift ├── 3DTouchHelper.swift └── RestAPI.swift ├── screenshots ├── screenshot1.png ├── screenshot2.png └── screenshot3.png ├── HackerNews.xcodeproj └── project.xcworkspace │ └── contents.xcworkspacedata ├── HackerNews.xcworkspace └── contents.xcworkspacedata ├── .gitignore ├── Podfile ├── LICENSE.md ├── README.md └── Podfile.lock /HackerNews/ionicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/ionicons.ttf -------------------------------------------------------------------------------- /HackerNews/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/screenshots/screenshot1.png -------------------------------------------------------------------------------- /screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/screenshots/screenshot2.png -------------------------------------------------------------------------------- /screenshots/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/screenshots/screenshot3.png -------------------------------------------------------------------------------- /HackerNews/Assets.xcassets/expand.imageset/expand-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/Assets.xcassets/expand.imageset/expand-button.png -------------------------------------------------------------------------------- /HackerNews/Assets.xcassets/expand.imageset/expand-button-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/Assets.xcassets/expand.imageset/expand-button-2.png -------------------------------------------------------------------------------- /HackerNews/Assets.xcassets/right_arrow.imageset/keyboard-right-arrow-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/Assets.xcassets/right_arrow.imageset/keyboard-right-arrow-button.png -------------------------------------------------------------------------------- /HackerNews/Assets.xcassets/right_arrow.imageset/keyboard-right-arrow-button-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/Assets.xcassets/right_arrow.imageset/keyboard-right-arrow-button-2.png -------------------------------------------------------------------------------- /HackerNews.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /HackerNews/SettingsTableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsTableView.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class SettingsTableView: HNTableNode { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /HackerNews.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /HackerNews/SettingsNodeType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsNodeType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/5/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum SettingsNodeType { 12 | case dropdown 13 | case action 14 | case open 15 | } 16 | -------------------------------------------------------------------------------- /HackerNews/WebViewControllerType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebViewControllerType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 12/4/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum WebViewControllerType { 12 | case item(Item) 13 | case link(String) 14 | } 15 | -------------------------------------------------------------------------------- /HackerNews/OptionType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol OptionType { 12 | 13 | var title: String { get } 14 | var icon: IonIconType { get } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /HackerNews/StoryNodeDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryNodeDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/22/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol StoryNodeDelegate { 12 | @objc optional func storyNode(_ storyNode: StoryNode, didPressOnPosterButton sender: ASDisplayNode) 13 | } 14 | -------------------------------------------------------------------------------- /HackerNews/Collection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/5/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Collection { 12 | subscript (safe index: Index) -> Iterator.Element? { 13 | return index >= startIndex && index < endIndex ? self[index] : nil 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Build generated 2 | build/ 3 | DerivedData/ 4 | 5 | ## Various settings 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata/ 15 | 16 | ## Other 17 | *.moved-aside 18 | *.xcuserstate 19 | 20 | ## Obj-C/Swift specific 21 | *.hmap 22 | *.ipa 23 | *.dSYM.zip 24 | *.dSYM 25 | 26 | Pods/ 27 | -------------------------------------------------------------------------------- /HackerNews/ASDisplayNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ASDisplayNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/4/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | extension ASDisplayNode { 12 | func layoutThatFits(size: CGSize) -> ASLayout { 13 | return self.layoutThatFits(.init(min: CGSize.zero, max: size)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /HackerNews/UserCommentNodeDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserCommentNodeDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 12/4/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol UserCommentNodeDelegate { 12 | @objc optional func userCommentNode(_ userCommentNode: UserCommentNode, didPressLink link: String, withSender sender: ASDisplayNode) 13 | } 14 | -------------------------------------------------------------------------------- /HackerNews/UserAboutBioNodeDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAboutBioNodeDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 12/4/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol UserAboutBioNodeDelegate { 12 | @objc optional func userAboutBioNode(_ userAboutBioNode: UserAboutBioNode, didPressLink link: String, withSender sender: ASDisplayNode) 13 | } 14 | -------------------------------------------------------------------------------- /HackerNews/AuthRequiredNodeDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthRequiredNodeDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol AuthRequiredNodeDelegate { 12 | func authRequiredNode(didPressLoginButton button: ASDisplayNode) 13 | func authRequiredNode(didPressSignupButton button: ASDisplayNode) 14 | } 15 | -------------------------------------------------------------------------------- /HackerNews/HNPostStoryDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNPostStoryDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/5/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol HNPostStoryDelegate { 12 | func postStory(didPressSubmitButton button: ASDisplayNode, title: String, url: String, text: String) 13 | func postStory(didPressCloseButton button: ASDisplayNode) 14 | } 15 | -------------------------------------------------------------------------------- /HackerNews/Assets.xcassets/expand.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "expand-button-2.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "expand-button.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } -------------------------------------------------------------------------------- /HackerNews/Assets.xcassets/right_arrow.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "keyboard-right-arrow-button-2.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "keyboard-right-arrow-button.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } -------------------------------------------------------------------------------- /HackerNews/UIColor+Hex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Hex.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/1/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIColor { 12 | 13 | convenience init(hex: Int) { 14 | let components = ( 15 | R: CGFloat((hex >> 16) & 0xff) / 255, 16 | G: CGFloat((hex >> 08) & 0xff) / 255, 17 | B: CGFloat((hex >> 00) & 0xff) / 255 18 | ) 19 | self.init(red: components.R, green: components.G, blue: components.B, alpha: 1) 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /HackerNews/UITabBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITabBar.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/26/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UITabBar { 12 | 13 | func set(topBorderColor color: UIColor, height: CGFloat) { 14 | let borderView = UIView(frame: CGRect(x: 0, y: -0.5, width: frame.width, height: height)) 15 | borderView.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin] 16 | borderView.backgroundColor = color 17 | 18 | self.addSubview(borderView) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /HackerNews/HNSearchProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNSearchProtocol.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/3/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol HNSearchProtocol { 12 | 13 | var query: String { get set } 14 | var sortType: SearchSortType { get set } 15 | var timeRangeType: SearchTimeRangeType { get set } 16 | 17 | func onRefreshNotificationReceived(notification: Notification) 18 | 19 | func refresh(refreshControl: UIRefreshControl?) 20 | 21 | func fetchMoreItems(completion: ((Bool) -> ())?) 22 | 23 | } 24 | -------------------------------------------------------------------------------- /HackerNews/StoryBarDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryBarDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/22/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol StoryBarDelegate { 12 | @objc optional func storyBar(_ storyBar: StoryBarNode, didPressShareButtonWithSender sender: ASDisplayNode) 13 | @objc optional func storyBar(_ storyBar: StoryBarNode, didPressCommentsButtonWithSender sender: ASDisplayNode) 14 | @objc optional func storyBar(_ storyBar: StoryBarNode, didPressVoteButtonWithSender sender: ASDisplayNode) 15 | } 16 | -------------------------------------------------------------------------------- /HackerNews/UINavigationBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UINavigationBar.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/16/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UINavigationBar { 12 | 13 | func set(bottomBorderColor color: UIColor, height: CGFloat) { 14 | let bottomBorderView = UIView(frame: CGRect(x: 0, y: frame.height, width: frame.width, height: height)) 15 | bottomBorderView.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] 16 | bottomBorderView.backgroundColor = color 17 | 18 | self.addSubview(bottomBorderView) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /HackerNews/AuthDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol AuthNodeDelegate { 12 | func authNode(didPressSwitchToSignupButton button: ASDisplayNode) 13 | func authNode(didPressSwitchToLoginButton button: ASDisplayNode) 14 | func authNode(didPressSwitchToForgotPasswordButton button: ASDisplayNode) 15 | func authNode(didPressSubmitButton button: ASDisplayNode, username: String, password: String) 16 | func authNode(didPressCloseButton button: ASDisplayNode) 17 | } 18 | -------------------------------------------------------------------------------- /HackerNews/CommentNodeDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentNodeDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/23/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol CommentNodeDelegate { 12 | @objc optional func commentNode(_ commentNode: CommentNode, didPressExpandButtonWithSender sender: ASDisplayNode, expanded: Bool) 13 | @objc optional func commentNode(_ commentNode: CommentNode, didPressPosterButtonWithSender sender: ASDisplayNode) 14 | @objc optional func commentNode(_ commentNode: CommentNode, didPressLink link: String, withSender sender: ASDisplayNode) 15 | } 16 | -------------------------------------------------------------------------------- /HackerNews/CommentOptionsNodeDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentOptionsNodeDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/23/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol CommentOptionsNodeDelegate { 12 | @objc optional func commentOptions(_ commentOptions: CommentOptionsNode, didPressMoreButtonWithSender sender: ASDisplayNode) 13 | @objc optional func commentOptions(_ commentOptions: CommentOptionsNode, didPressReplyButtonWithSender sender: ASDisplayNode) 14 | @objc optional func commentOptions(_ commentOptions: CommentOptionsNode, didPressVoteButtonWithSender sender: ASDisplayNode) 15 | } 16 | -------------------------------------------------------------------------------- /HackerNews/FullButtonNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthButton.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class FullButtonNode: ASButtonNode { 12 | 13 | // MARK: - Inits 14 | 15 | override init() { 16 | super.init() 17 | 18 | self.setupTheme() 19 | } 20 | 21 | // MARK: - Helpers 22 | 23 | func set(title: String) { 24 | self.setAttributedTitle( 25 | NSAttributedString.init( 26 | string: title, 27 | attributes: Styles.others.fullButton), 28 | for: .normal 29 | ) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /HackerNews/MiddleButtonNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MiddleButtonNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class MiddleButtonNode: ASButtonNode { 12 | 13 | // MARK: - Inits 14 | 15 | override init() { 16 | super.init() 17 | 18 | self.setupTheme() 19 | } 20 | 21 | // MARK: - Helpers 22 | 23 | func set(title: String) { 24 | self.setAttributedTitle( 25 | NSAttributedString.init( 26 | string: title, 27 | attributes: Styles.others.middleButton), 28 | for: .normal 29 | ) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | target 'HackerNews' do 5 | # Comment this line if you're not using Swift and don't want to use dynamic frameworks 6 | use_frameworks! 7 | 8 | # Pods for HackerNews 9 | pod 'Firebase/Database' 10 | pod "PromiseKit", "~> 4.1.7" 11 | pod 'NSDate+TimeAgo' 12 | pod 'Alamofire', '~> 4.0' 13 | pod "Texture" 14 | pod 'AMScrollingNavbar' 15 | pod 'PINCache' 16 | pod 'XLPagerTabStrip', '~> 6.0' 17 | pod 'MBProgressHUD' 18 | 19 | end 20 | 21 | post_install do |installer| 22 | installer.pods_project.targets.each do |target| 23 | target.build_configurations.each do |config| 24 | config.build_settings['SWIFT_VERSION'] = '3.0' 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /HackerNews/StoryDetailNodeDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryDetailNodeDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/24/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | @objc protocol StoryDetailNodeDelegate { 12 | @objc optional func storyDetail(_ storyDetail: StoryDetailNode, didPressPosterButtonWithSender sender: ASDisplayNode) 13 | @objc optional func storyDetail(_ storyDetail: StoryDetailNode, didPressTitleWithSender sender: ASDisplayNode) 14 | @objc optional func storyDetail(_ storyDetail: StoryDetailNode, didPressSiteButtonWithSender sender: ASDisplayNode) 15 | @objc optional func storyDetail(_ storyDetail: StoryDetailNode, didPressLink link: String, withSender sender: ASDisplayNode) 16 | } 17 | -------------------------------------------------------------------------------- /HackerNews/UserOptionsNavigationRight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserOptionsNavigationRight.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/6/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum UserOptionsNavigationRight: OptionType { 12 | case openInBrowser 13 | 14 | var title: String { 15 | switch self { 16 | case .openInBrowser: 17 | return "kOpenInBrowser".localized 18 | } 19 | } 20 | 21 | var icon: IonIconType { 22 | switch self { 23 | case .openInBrowser: 24 | return .ion_earth 25 | } 26 | } 27 | 28 | static var values: [UserOptionsNavigationRight] { 29 | return [ 30 | openInBrowser 31 | ] 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /HackerNews/CommentsOptionsNavigationRight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentsOptionsNavigationRight.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum CommentsOptionsNavigationRight: OptionType { 12 | case openInBrowser 13 | 14 | var title: String { 15 | switch self { 16 | case .openInBrowser: 17 | return "kOpenInBrowser".localized 18 | } 19 | } 20 | 21 | var icon: IonIconType { 22 | switch self { 23 | case .openInBrowser: 24 | return .ion_earth 25 | } 26 | } 27 | 28 | static var values: [CommentsOptionsNavigationRight] { 29 | return [ 30 | openInBrowser 31 | ] 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /HackerNews/UIButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIButton.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/20/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIButton { 12 | 13 | func setTitleWithoutAnimation(_ title: String?, for state: UIControlState) { 14 | UIView.setAnimationsEnabled(false) 15 | self.setTitle(title, for: state) 16 | self.layoutIfNeeded() 17 | UIView.setAnimationsEnabled(true) 18 | } 19 | 20 | func setAttributedTitleWithoutAnimation(_ title: NSAttributedString?, for state: UIControlState) { 21 | UIView.setAnimationsEnabled(false) 22 | self.setAttributedTitle(title, for: state) 23 | self.layoutIfNeeded() 24 | UIView.setAnimationsEnabled(true) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /HackerNews/MBProgressHUD.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MBProgressHUD.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import MBProgressHUD 10 | 11 | extension MBProgressHUD { 12 | 13 | static func showLoader(view: UIView, animated: Bool = true, interactionDisabled: Bool = true) { 14 | if interactionDisabled { 15 | view.isUserInteractionEnabled = false 16 | } 17 | MBProgressHUD.showAdded(to: view, animated: animated) 18 | } 19 | 20 | static func hideLoader(view: UIView, animated: Bool = true, interactionEnabled: Bool = true) { 21 | if interactionEnabled { 22 | view.isUserInteractionEnabled = true 23 | } 24 | MBProgressHUD.hide(for: view, animated: animated) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /HackerNews/NSError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSError.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/11/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Firebase 11 | 12 | extension NSError { 13 | 14 | static func from(item: Item) -> NSError { 15 | return NSError(domain: "HACKER_NEWS", code: -1, userInfo: [ 16 | "id": item.id 17 | ]) 18 | } 19 | 20 | static func from(snapshot: DataSnapshot) -> NSError { 21 | return NSError(domain: "FIREBASE", code: -1, userInfo: [ 22 | "snapshot": snapshot.debugDescription 23 | ]) 24 | } 25 | 26 | static func from(string: String) -> NSError { 27 | return NSError(domain: "HACKER_NEWS", code: -1, userInfo: [ 28 | NSLocalizedDescriptionKey: string 29 | ]) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /HackerNews/HNTableNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNTableView.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class HNTableNode: ASTableNode { 12 | 13 | // MARK: - Lifecycle 14 | 15 | init() { 16 | super.init(style: .plain) 17 | 18 | self.setup() 19 | } 20 | 21 | // MARK: - Setup Helpers 22 | 23 | func setup() { 24 | self.setupTableView() 25 | self.setupTheme() 26 | } 27 | 28 | func setupTableView() { 29 | self.view.separatorStyle = .singleLine 30 | self.view.separatorInset = UIEdgeInsets.zero 31 | self.view.tableFooterView = UIView() 32 | self.view.alwaysBounceVertical = true 33 | self.view.layoutMargins = UIEdgeInsets.zero 34 | self.clipsToBounds = true 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /HackerNews/ShareHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareHelper.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/10/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ShareHelper { 12 | 13 | static func share(items: [Any], fromViewController viewController: UIViewController, sourceView: UIView?) { 14 | let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil) 15 | if activityVC.responds(to: #selector(getter: UIViewController.popoverPresentationController)) { 16 | activityVC.popoverPresentationController?.sourceView = sourceView ?? viewController.view 17 | activityVC.popoverPresentationController?.sourceRect = activityVC.popoverPresentationController?.sourceView?.bounds ?? CGRect.zero 18 | } 19 | viewController.present(activityVC, animated: true, completion: nil) 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /HackerNews/TextFieldNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextFieldNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class TextFieldNode: ASEditableTextNode { 12 | 13 | // MARK: - Inits 14 | 15 | override init() { 16 | super.init() 17 | 18 | self.setupTheme() 19 | } 20 | 21 | override init(textKitComponents: ASTextKitComponents, placeholderTextKitComponents: ASTextKitComponents) { 22 | super.init(textKitComponents: textKitComponents, placeholderTextKitComponents: placeholderTextKitComponents) 23 | } 24 | 25 | // MARK: - Helpers 26 | 27 | func set(placeholderText: String) { 28 | self.attributedPlaceholderText = NSAttributedString( 29 | string: placeholderText, 30 | attributes: Styles.textField.placeholder 31 | ) 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /HackerNews/GeneralType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum GeneralType: OptionType { 12 | case cancel 13 | case none 14 | case close 15 | 16 | var title: String { 17 | switch self { 18 | case .cancel: 19 | return "Cancel" 20 | case .none: 21 | return "" 22 | case .close: 23 | return "Close" 24 | } 25 | } 26 | 27 | var icon: IonIconType { 28 | switch self { 29 | case .cancel: 30 | return .ion_close 31 | case .none: 32 | return .ion_ios_help 33 | case .close: 34 | return .ion_checkmark_round 35 | } 36 | } 37 | 38 | static var values: [GeneralType] { 39 | return [ 40 | none, 41 | cancel, 42 | close, 43 | ] 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /HackerNews/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utils.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class Utils { 12 | 13 | static func nibName(forClassType type: AnyClass) -> String { 14 | return type.description().components(separatedBy: ".").last ?? "" 15 | } 16 | 17 | static func nib(forClassType type: AnyClass) -> UINib { 18 | return UINib.init(nibName: Utils.nibName(forClassType: type), bundle: nil) 19 | } 20 | 21 | static func loadNib(forClassType type: AnyClass) -> Any? { 22 | return Bundle.main.loadNibNamed(Utils.nibName(forClassType: type), owner: nil, options: nil)?.first 23 | } 24 | 25 | static func openURL(urlString: String) { 26 | if let url = URL(string: urlString) { 27 | UIApplication.shared.openURL(url) 28 | } 29 | } 30 | 31 | static func copy(string: String) { 32 | UIPasteboard.general.string = string 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /HackerNews/WebOptionType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebOptionType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/3/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum WebOptionType: OptionType { 12 | case refresh 13 | case share 14 | case openInBrowser 15 | 16 | var title: String { 17 | switch self { 18 | case .refresh: 19 | return "kRefresh".localized 20 | case .share: 21 | return "kShare".localized 22 | case .openInBrowser: 23 | return "kOpenInBrowser".localized 24 | } 25 | } 26 | 27 | var icon: IonIconType { 28 | switch self { 29 | case .refresh: 30 | return .ion_refresh 31 | case .share: 32 | return .ion_ios_upload 33 | case .openInBrowser: 34 | return .ion_earth 35 | } 36 | } 37 | 38 | static var values: [WebOptionType] { 39 | return [ 40 | refresh, 41 | share, 42 | openInBrowser, 43 | ] 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /HackerNews/CommentsOptionsComment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentsOptionsComment.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum CommentsOptionsComment: OptionType { 12 | case share 13 | case copy 14 | case openInBrowser 15 | 16 | var title: String { 17 | switch self { 18 | case .share: 19 | return "kShare".localized 20 | case .copy: 21 | return "kCopy".localized 22 | case .openInBrowser: 23 | return "kOpenInBrowser".localized 24 | } 25 | } 26 | 27 | var icon: IonIconType { 28 | switch self { 29 | case .share: 30 | return .ion_ios_upload 31 | case .copy: 32 | return .ion_ios_copy 33 | case .openInBrowser: 34 | return .ion_earth 35 | } 36 | } 37 | 38 | static var values: [CommentsOptionsComment] { 39 | return [ 40 | share, 41 | copy, 42 | openInBrowser, 43 | ] 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /HackerNews/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/9/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class Constants { 12 | 13 | static let kFirebaseURL = "https://hacker-news.firebaseio.com/v0/" 14 | static let kLoginURL = "https://news.ycombinator.com/login" 15 | static let kHackerNewsURL = "https://news.ycombinator.com/" 16 | static let kForgotPasswordURL = "https://news.ycombinator.com/forgot" 17 | 18 | static let kTextLinkAttributeName = "TextLinkAttributeName" 19 | 20 | static let kItemExpiratinTime: TimeInterval = 60 21 | static let kHomePreloadItemCount: Int = 10 22 | static let kCommentsPreloadItemCount: Int = 25 23 | 24 | static let kClearCacheNotification: Notification = Notification(name: Notification.Name("kClearCacheNotification")) 25 | static let kSearchNotification: Notification = Notification(name: Notification.Name("kSearchNotification")) 26 | static let kLoginNotification: Notification = Notification(name: Notification.Name("kLoginNotification")) 27 | 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Mihail Cristian Dumitru 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /HackerNews/UINavigationItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UINavigationItem.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/20/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UINavigationItem { 12 | 13 | func set(icon: IonIconType, forButton button: UIBarButtonItem) { 14 | button.title = icon.rawValue 15 | button.setTitleTextAttributes([ 16 | NSFontAttributeName: UIFont.ionIconFont(ofSize: FontManager.currentFont.titleSize) 17 | ], for: .normal) 18 | } 19 | 20 | func set(leftButtonIcon icon: IonIconType, target: Any?, action: Selector?) { 21 | let button = UIBarButtonItem(title: nil, style: .plain, target: target, action: action) 22 | self.set(icon: icon, forButton: button) 23 | self.leftBarButtonItem = button 24 | } 25 | 26 | func set(rightButtonIcon icon: IonIconType, target: Any?, action: Selector?) { 27 | let button = UIBarButtonItem(title: nil, style: .plain, target: target, action: action) 28 | self.set(icon: icon, forButton: button) 29 | self.rightBarButtonItem = button 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /HackerNews/OptionSelectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionSelectView.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/14/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class OptionSelectView: ASDisplayNode { 12 | 13 | // MARK: - Properties 14 | 15 | var tableView = OptionSelectTableView() 16 | 17 | // MARK: - Inits 18 | 19 | override init() { 20 | super.init() 21 | 22 | self.automaticallyManagesSubnodes = true 23 | } 24 | 25 | // MARK: - Layout 26 | 27 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 28 | 29 | self.style.width = .init(unit: .fraction, value: 1) 30 | self.style.height = .init(unit: .fraction, value: 1) 31 | 32 | let insetStack = ASInsetLayoutSpec( 33 | insets: UIEdgeInsetsMake( 34 | HNDimensions.padding / 2 + 20, 35 | HNDimensions.padding / 2, 36 | HNDimensions.padding / 2, 37 | HNDimensions.padding / 2), 38 | child: self.tableView) 39 | 40 | return insetStack 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /HackerNews/CommentsTableBackgroundView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentsTableBackgroundView.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 12/4/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class CommentsTableBackgroundView: UIView { 12 | 13 | // MARK: - Lifecycle 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | 18 | self.setup() 19 | } 20 | 21 | required init?(coder aDecoder: NSCoder) { 22 | super.init(coder: aDecoder) 23 | 24 | self.setup() 25 | } 26 | 27 | // MARK: - Setup Helpers 28 | 29 | func setup() { 30 | let width = UIScreen.main.bounds.height // get the height because of landscape 31 | 32 | for i in 1..<(Int(floor(width / HNDimensions.comments.paddingPerLevel))) { 33 | let divider = UIView(frame: CGRect(x: CGFloat(i) * HNDimensions.comments.paddingPerLevel, y: 0, width: 1, height: 0)) 34 | divider.backgroundColor = Styles.comment.divider 35 | divider.autoresizingMask = [.flexibleHeight] 36 | self.addSubview(divider) 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /HackerNews/StoryTypeNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryTypeNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/22/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | 12 | class StoryTypeNode: ASCellNode { 13 | 14 | // MARK: - Properties 15 | 16 | var titleNode: ASTextNode! 17 | 18 | // MARK: - Inits 19 | 20 | init(withTitle title: String) { 21 | super.init() 22 | 23 | // title 24 | self.titleNode = ASTextNode() 25 | self.titleNode.attributedText = NSAttributedString( 26 | string: title, 27 | attributes: Styles.storyType.title) 28 | self.addSubnode(self.titleNode) 29 | 30 | self.setupTheme() 31 | } 32 | 33 | // MARK: - Layout 34 | 35 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 36 | return ASInsetLayoutSpec( 37 | insets: UIEdgeInsetsMake( 38 | HNDimensions.padding / 1.5, 39 | HNDimensions.padding, 40 | HNDimensions.padding / 1.5, 41 | HNDimensions.padding), 42 | child: self.titleNode) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /HackerNews/ProfileOptionsType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileOptionsType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum ProfileOptionsType: OptionType { 12 | case history 13 | case posts 14 | case comments 15 | case logout 16 | 17 | var title: String { 18 | switch self { 19 | case .history: 20 | return "History" 21 | case .posts: 22 | return "My posts" 23 | case .comments: 24 | return "My comments" 25 | case .logout: 26 | return "Logout" 27 | } 28 | } 29 | 30 | var icon: IonIconType { 31 | switch self { 32 | case .history: 33 | return .ion_ios_clock 34 | case .posts: 35 | return .ion_ios_list 36 | case .comments: 37 | return .ion_chatbox 38 | case .logout: 39 | return .ion_log_out 40 | } 41 | } 42 | 43 | static var values: [ProfileOptionsType] { 44 | return [ 45 | history, 46 | posts, 47 | comments, 48 | logout 49 | ] 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /HackerNews/AlertBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertBuilder.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class AlertBuilder { 12 | 13 | // MARK: - Properties 14 | 15 | var alertController = UIAlertController(title: nil, message: nil, preferredStyle: .alert) 16 | 17 | // MARK: - Helpers 18 | 19 | func set(title: String?) -> Self { 20 | self.alertController.title = title 21 | return self 22 | } 23 | 24 | func set(message: String?) -> Self { 25 | self.alertController.message = message 26 | return self 27 | } 28 | 29 | func add(actionWithTitle title: String?, style: UIAlertActionStyle = .default, handler: ((UIAlertAction) -> Void)? = nil) -> Self { 30 | self.alertController.addAction( 31 | UIAlertAction( 32 | title: title, 33 | style: style, 34 | handler: handler) 35 | ) 36 | return self 37 | } 38 | 39 | func present(sender: UIViewController, animated: Bool = true, completion: (() -> Void)? = nil) { 40 | sender.present(self.alertController, animated: animated, completion: completion) 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /HackerNews/NSMutableAttributedString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSMutableAttributedString.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/24/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension NSMutableAttributedString { 12 | 13 | func detectLinks() -> NSMutableAttributedString { 14 | do { 15 | let urlDetector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) 16 | 17 | urlDetector.enumerateMatches(in: self.string, options: [], range: NSMakeRange(0, self.string.characters.count)) { (result, flags, stop) in 18 | 19 | if let result = result { 20 | if result.resultType == .link { 21 | if let absoluteString = result.url?.absoluteString { 22 | var attributes = Styles.others.link 23 | attributes[Constants.kTextLinkAttributeName] = NSURL(string: absoluteString) 24 | self.addAttributes(attributes, range: result.range) 25 | } 26 | } 27 | } 28 | } 29 | } catch let error { 30 | print(error) 31 | } 32 | 33 | return self 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /HackerNews/AlgoliaResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlgoliaResponse.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/5/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class AlgoliaResponse: NSObject { 12 | 13 | // MARK: - Properties 14 | 15 | var hits: [Item] = [] 16 | var hitCount: Int = 0 17 | var page: Int = 0 18 | var pageCount: Int = 0 19 | var hitsPerPage: Int = 0 20 | 21 | // MARK: - Inits 22 | 23 | override init() { 24 | super.init() 25 | 26 | self.setData(withJSON: [:]) 27 | } 28 | 29 | init(withJSON json: [String: AnyObject]) { 30 | super.init() 31 | 32 | self.setData(withJSON: json) 33 | } 34 | 35 | func setData(withJSON json: [String: AnyObject]) { 36 | self.hitCount = json["nbHits"] as? Int ?? self.hitCount 37 | self.page = json["page"] as? Int ?? self.page 38 | self.pageCount = json["nbPages"] as? Int ?? self.pageCount 39 | self.hitsPerPage = json["hitsPerPage"] as? Int ?? self.hitsPerPage 40 | 41 | if let hits = json["hits"] as? [[String: AnyObject]] { 42 | for hit in hits { 43 | self.hits.append(Item(withAlgoliaJSON: hit)) 44 | } 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /HackerNews/OptionSelectViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionSelectViewDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/21/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AsyncDisplayKit 11 | 12 | protocol OptionSelectViewDelegate { 13 | func optionSelectView(optionSelectView: OptionsViewController, numberOfItemsInSection section: Int) -> Int 14 | func numberOfSections(in optionSelectView: OptionsViewController) -> Int 15 | func optionSelectView(optionSelectView: OptionsViewController, didSelectItemAtIndexPath indexPath: IndexPath) 16 | func optionSelectView(optionSelectView: OptionsViewController, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock 17 | func optionSelectView(optionSelectView: OptionsViewController, viewForHeaderInSection section: Int) -> UIView? 18 | func optionSelectView(optionSelectView: OptionsViewController, heightForHeaderInSection section: Int) -> CGFloat 19 | } 20 | 21 | extension OptionSelectViewDelegate { 22 | func optionSelectView(optionSelectView: OptionsViewController, viewForHeaderInSection section: Int) -> UIView? { 23 | return nil 24 | } 25 | func optionSelectView(optionSelectView: OptionsViewController, heightForHeaderInSection section: Int) -> CGFloat { 26 | return 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /HackerNews/HNPostTextCellNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNTextCellNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/24/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class HNPostTextCellNode: ASCellNode { 12 | 13 | // MARK: - Properties 14 | 15 | var textNode = TextFieldNode() 16 | 17 | var delegate: ASEditableTextNodeDelegate? { 18 | set { 19 | self.textNode.delegate = newValue 20 | } 21 | get { 22 | return self.textNode.delegate 23 | } 24 | } 25 | 26 | // MARK: - Inits 27 | 28 | override init() { 29 | super.init() 30 | 31 | self.automaticallyManagesSubnodes = true 32 | 33 | self.selectionStyle = .none 34 | } 35 | 36 | // MARK: - Layout 37 | 38 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 39 | 40 | let insetContentStack = ASInsetLayoutSpec( 41 | insets: UIEdgeInsetsMake(HNDimensions.padding, 42 | HNDimensions.padding / 2, 43 | HNDimensions.padding, 44 | HNDimensions.padding / 2 45 | ), 46 | child: self.textNode) 47 | 48 | return insetContentStack 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /HackerNews/HNDimensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNDimensions.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/2/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /** 12 | * HNDimensions holds the default dimensions for the nodes. 13 | */ 14 | class HNDimensions { 15 | 16 | /** 17 | * Comments dimensions 18 | */ 19 | open class comments { 20 | 21 | /** 22 | * Left padding per comment level (depth) 23 | */ 24 | static let paddingPerLevel: CGFloat = 16 25 | 26 | } 27 | 28 | /** 29 | * Item bar 30 | */ 31 | open class itemBar { 32 | 33 | /** 34 | * Item bar height 35 | */ 36 | static let height: CGFloat = 40 37 | 38 | /** 39 | * Item bar divider height 40 | */ 41 | static let dividerHeight: CGFloat = 16 42 | 43 | } 44 | 45 | /** 46 | * Navigation bar (in auth and post story/comment) 47 | */ 48 | open class navigation { 49 | 50 | /** 51 | * Navigation bar height 52 | */ 53 | static let height: CGFloat = 44 54 | 55 | /** 56 | * Status bar height 57 | */ 58 | static let statusBarHeight: CGFloat = 20 59 | 60 | } 61 | 62 | /** 63 | * Default padding 64 | */ 65 | static let padding: CGFloat = 16 66 | 67 | } 68 | -------------------------------------------------------------------------------- /HackerNews/BackgroundDividerNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundDividerNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 12/4/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class BackgroundDividerNode: ASDisplayNode { 12 | 13 | // MARK: - Properties 14 | 15 | var dividers: [ASDisplayNode] = [] 16 | 17 | var level: Int = 0 18 | 19 | // MARK: - Lifecycle 20 | 21 | init(level: Int) { 22 | super.init() 23 | 24 | self.level = level 25 | } 26 | 27 | // MARK: - Helpers 28 | 29 | func createRequiredDividers() { 30 | while self.dividers.count <= self.level { 31 | let node = ASDisplayNode() 32 | self.addSubnode(node) 33 | self.dividers.append(node) 34 | } 35 | } 36 | 37 | func arrangeDividers() { 38 | for i in 0.. ThemeType? { 57 | for type in values { 58 | if type.value == value { 59 | return type 60 | } 61 | } 62 | return nil 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /HackerNews/DataRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataRequest.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/21/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | import PromiseKit 12 | 13 | extension DataRequest { 14 | 15 | func responseJSON() -> Promise<(DataResponse, [String: AnyObject])> { 16 | return Promise(resolvers: { (fulfill, reject) in 17 | self.responseJSON { response in 18 | switch response.result { 19 | case .success(let value): 20 | if let json = value as? [String: AnyObject] { 21 | fulfill(response, json) 22 | return; 23 | } 24 | reject(NSError.from(string: "Could not parse the data.")) 25 | case .failure(let error): 26 | reject(error) 27 | } 28 | } 29 | }) 30 | } 31 | 32 | func responseString() -> Promise<(DataResponse, String)> { 33 | return Promise(resolvers: { (fulfill, reject) in 34 | self.responseString { (response) in 35 | switch response.result { 36 | case .success(let value): 37 | fulfill(response, value) 38 | case .failure(let error): 39 | reject(error) 40 | } 41 | } 42 | }) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /HackerNews/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/9/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Firebase 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | // MARK: - Properties 16 | 17 | var window: UIWindow? 18 | static var tabBarController: TabBarController! 19 | var launchedShortcutItem: Any? 20 | 21 | // MARK: - Delegates 22 | 23 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 24 | 25 | self.setup() 26 | 27 | return app(application, didFinishLaunchingWithOptions: launchOptions) 28 | } 29 | 30 | // MARK: - Helpers 31 | 32 | func setup() { 33 | ThemeManager.set(theme: ThemeManager.current) 34 | FontManager.set(font: FontManager.currentFont) 35 | self.setupTabBarController() 36 | self.setupWindow() 37 | self.setupFirebase() 38 | } 39 | 40 | func setupTabBarController() { 41 | AppDelegate.tabBarController = TabBarController() 42 | } 43 | 44 | func setupWindow() { 45 | self.window = UIWindow(frame: UIScreen.main.bounds) 46 | self.window?.rootViewController = AppDelegate.tabBarController 47 | self.window?.makeKeyAndVisible() 48 | } 49 | 50 | func setupFirebase() { 51 | FirebaseApp.configure() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /HackerNews/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AD_UNIT_ID_FOR_BANNER_TEST 6 | ca-app-pub-3940256099942544/2934735716 7 | AD_UNIT_ID_FOR_INTERSTITIAL_TEST 8 | ca-app-pub-3940256099942544/4411468910 9 | CLIENT_ID 10 | 378846131057-3qak3gab1l08sfpoje0dahbt8piq6cvh.apps.googleusercontent.com 11 | REVERSED_CLIENT_ID 12 | com.googleusercontent.apps.378846131057-3qak3gab1l08sfpoje0dahbt8piq6cvh 13 | API_KEY 14 | AIzaSyBN1qskozD8jnkLfvQ0WJ4RblXQrPjLgBw 15 | GCM_SENDER_ID 16 | 378846131057 17 | PLIST_VERSION 18 | 1 19 | BUNDLE_ID 20 | com.xzya.HackerNews 21 | PROJECT_ID 22 | hackernews-97d33 23 | STORAGE_BUCKET 24 | hackernews-97d33.appspot.com 25 | IS_ADS_ENABLED 26 | 27 | IS_ANALYTICS_ENABLED 28 | 29 | IS_APPINVITE_ENABLED 30 | 31 | IS_GCM_ENABLED 32 | 33 | IS_SIGNIN_ENABLED 34 | 35 | GOOGLE_APP_ID 36 | 1:378846131057:ios:65fc4772c827de68 37 | DATABASE_URL 38 | https://hacker-news.firebaseio.com/ 39 | 40 | 41 | -------------------------------------------------------------------------------- /HackerNews/SearchSortType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSortType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/3/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum SearchSortType: OptionType { 12 | case relevance 13 | case date 14 | 15 | var title: String { 16 | switch self { 17 | case .relevance: 18 | return "Most relevant" 19 | case .date: 20 | return "Most recent" 21 | } 22 | } 23 | 24 | var icon: IonIconType { 25 | switch self { 26 | case .relevance: 27 | return .ion_ios_checkmark 28 | case .date: 29 | return .ion_ios_clock 30 | } 31 | } 32 | 33 | var value: String { 34 | switch self { 35 | case .relevance: 36 | return "relevant" 37 | case .date: 38 | return "date" 39 | } 40 | } 41 | 42 | var url: String { 43 | switch self { 44 | case .relevance: 45 | return "/search" 46 | case .date: 47 | return "/search_by_date" 48 | } 49 | } 50 | 51 | static var values: [SearchSortType] { 52 | return [ 53 | relevance, 54 | date, 55 | ] 56 | } 57 | 58 | static func initWithValue(value: String) -> SearchSortType? { 59 | for type in values { 60 | if type.value == value { 61 | return type 62 | } 63 | } 64 | return nil 65 | } 66 | 67 | static let label: String = "Sort by" 68 | 69 | } 70 | -------------------------------------------------------------------------------- /HackerNews/HNOptionActionNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionActionNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/3/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | /** 12 | * Full width button with centered text. 13 | * Used for buttons like cancel/close in the options select controller. 14 | */ 15 | class HNOptionActionNode: ASCellNode { 16 | 17 | // MARK: - Properties 18 | 19 | var titleNode = ASTextNode() 20 | 21 | // MARK: - Inits 22 | 23 | init(optionType type: OptionType) { 24 | super.init() 25 | 26 | self.automaticallyManagesSubnodes = true 27 | 28 | // title 29 | self.titleNode.attributedText = NSAttributedString( 30 | string: type.title, 31 | attributes: Styles.options.title 32 | ) 33 | self.titleNode.style.alignSelf = .center 34 | 35 | self.setupTheme() 36 | } 37 | 38 | // MARK: - Layout 39 | 40 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 41 | 42 | let centered = ASCenterLayoutSpec( 43 | centeringOptions: .XY, 44 | sizingOptions: [], 45 | child: self.titleNode 46 | ) 47 | 48 | let insetTitle = ASInsetLayoutSpec( 49 | insets: UIEdgeInsetsMake( 50 | HNDimensions.padding / 1.5, 51 | HNDimensions.padding, 52 | HNDimensions.padding / 1.5, 53 | HNDimensions.padding), 54 | child: centered) 55 | 56 | return insetTitle 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /HackerNews/CommentsTableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentsTableView.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/23/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class CommentsTableView: ASTableNode { 12 | 13 | // MARK: - Properties 14 | 15 | var selectedIndexPaths: [IndexPath] = [] 16 | var collapsedIndexPaths: [IndexPath] = [] 17 | 18 | // MARK: - Lifecycle 19 | 20 | init() { 21 | super.init(style: .plain) 22 | 23 | self.setup() 24 | } 25 | 26 | // MARK: - Setup Helpers 27 | 28 | func setup() { 29 | // self.setupBackView() 30 | self.setupTableView() 31 | self.setupTheme() 32 | } 33 | 34 | func setupTableView() { 35 | self.view.separatorStyle = .none 36 | self.view.separatorInset = UIEdgeInsets.zero 37 | self.view.tableFooterView = UIView() 38 | self.view.alwaysBounceVertical = true 39 | self.view.layoutMargins = UIEdgeInsets.zero 40 | self.allowsMultipleSelection = true 41 | self.clipsToBounds = true 42 | self.view.backgroundView?.clipsToBounds = true 43 | } 44 | 45 | func setupBackView() { 46 | let backView = UIView(frame: UIScreen.main.bounds) 47 | 48 | for i in 1..<(Int(floor(UIScreen.main.bounds.width / HNDimensions.comments.paddingPerLevel))) { 49 | let divider = UIView(frame: CGRect(x: CGFloat(i) * HNDimensions.comments.paddingPerLevel, y: 0, width: 1, height: UIScreen.main.bounds.height)) 50 | divider.backgroundColor = Styles.comment.divider 51 | backView.addSubview(divider) 52 | } 53 | 54 | self.view.backgroundView = backView 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /HackerNews/FontSizeType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontSizeType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum FontSizeType: OptionType { 12 | case smallest 13 | case small 14 | case `default` 15 | case large 16 | case largest 17 | 18 | var value: String { 19 | switch self { 20 | case .smallest: 21 | return "smaller" 22 | case .small: 23 | return "small" 24 | case .default: 25 | return "default" 26 | case .large: 27 | return "large" 28 | case .largest: 29 | return "largest" 30 | } 31 | } 32 | 33 | var title: String { 34 | switch self { 35 | case .smallest: 36 | return "Smallest" 37 | case .small: 38 | return "Small" 39 | case .default: 40 | return "Default" 41 | case .large: 42 | return "Large" 43 | case .largest: 44 | return "Largest" 45 | } 46 | } 47 | 48 | var icon: IonIconType { 49 | switch self { 50 | default: 51 | return .ion_arrow_resize 52 | } 53 | } 54 | 55 | static var values: [FontSizeType] { 56 | return [ 57 | smallest, 58 | small, 59 | `default`, 60 | large, 61 | largest 62 | ] 63 | } 64 | 65 | static func initWithValue(value: String) -> FontSizeType? { 66 | for type in values { 67 | if type.value == value { 68 | return type 69 | } 70 | } 71 | return nil 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /HackerNews/HNPagerViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNPagerViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/2/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import XLPagerTabStrip 11 | 12 | class HNPagerViewController: ButtonBarPagerTabStripViewController { 13 | 14 | // MARK: - Lifecycle 15 | 16 | override func viewDidLoad() { 17 | self.setupPager() 18 | 19 | super.viewDidLoad() 20 | } 21 | 22 | // MARK: - Setup Helpers 23 | 24 | func setupPager() { 25 | self.settings.style.buttonBarBackgroundColor = Styles.pager.buttonBarBackground 26 | self.settings.style.buttonBarItemBackgroundColor = Styles.pager.buttonBarItemBackground 27 | self.settings.style.selectedBarBackgroundColor = Styles.pager.selectedBarBackground 28 | self.settings.style.buttonBarItemFont = Styles.pager.buttonBarItemFont 29 | self.settings.style.selectedBarHeight = 1.5 30 | self.settings.style.buttonBarMinimumLineSpacing = 0 31 | self.settings.style.buttonBarItemTitleColor = Styles.pager.buttonBarItemTitleSelected 32 | self.settings.style.buttonBarItemsShouldFillAvailiableWidth = true 33 | self.settings.style.buttonBarLeftContentInset = 0 34 | self.settings.style.buttonBarRightContentInset = 0 35 | 36 | self.changeCurrentIndex = { (oldCell: ButtonBarViewCell?, newCell: ButtonBarViewCell?, animated: Bool) -> Void in 37 | 38 | oldCell?.label.textColor = Styles.pager.buttonBarItemTitle 39 | newCell?.label.textColor = Styles.pager.buttonBarItemTitleSelected 40 | } 41 | 42 | self.pagerBehaviour = .common(skipIntermediateViewControllers: true) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /HackerNews/StoryType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum StoryType: OptionType { 12 | case top 13 | case new 14 | case best 15 | case ask 16 | case show 17 | case job 18 | 19 | var title: String { 20 | switch self { 21 | case .top: 22 | return "Top" 23 | case .new: 24 | return "New" 25 | case .best: 26 | return "Best" 27 | case .ask: 28 | return "Ask" 29 | case .show: 30 | return "Show" 31 | case .job: 32 | return "Job" 33 | } 34 | } 35 | 36 | var icon: IonIconType { 37 | switch self { 38 | default: 39 | return .ion_ios_star 40 | } 41 | } 42 | 43 | var value: String { 44 | switch self { 45 | case .top: 46 | return "topstories" 47 | case .new: 48 | return "newstories" 49 | case .best: 50 | return "beststories" 51 | case .ask: 52 | return "askstories" 53 | case .show: 54 | return "showstories" 55 | case .job: 56 | return "jobstories" 57 | } 58 | } 59 | 60 | static var values: [StoryType] { 61 | return [ 62 | top, 63 | new, 64 | best, 65 | ask, 66 | show, 67 | job, 68 | ] 69 | } 70 | 71 | static func initWithValue(value: String) -> StoryType? { 72 | for type in values { 73 | if type.value == value { 74 | return type 75 | } 76 | } 77 | return nil 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /HackerNews/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /HackerNews/OptionSelectNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionSelectNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/23/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class OptionSelectNode: ASCellNode { 12 | 13 | // MARK: - Properties 14 | 15 | var iconNode: ASTextNode! 16 | var titleNode: ASTextNode! 17 | 18 | // MARK: - Inits 19 | 20 | init(optionType type: OptionType, selected: Bool = false) { 21 | super.init() 22 | 23 | // icon 24 | self.iconNode = ASTextNode() 25 | self.iconNode.attributedText = IonIcon.textIcon( 26 | type: type.icon, 27 | color: selected ? Styles.options.highlightedIcon : Styles.options.icon 28 | ) 29 | self.addSubnode(self.iconNode) 30 | 31 | // title 32 | self.titleNode = ASTextNode() 33 | self.titleNode.attributedText = NSAttributedString( 34 | string: type.title, 35 | attributes: Styles.options.title 36 | ) 37 | self.addSubnode(self.titleNode) 38 | 39 | self.setupTheme() 40 | } 41 | 42 | // MARK: - Layout 43 | 44 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 45 | 46 | let mainStack = ASStackLayoutSpec( 47 | direction: .horizontal, 48 | spacing: HNDimensions.padding, 49 | justifyContent: .start, 50 | alignItems: .center, 51 | children: [self.iconNode, self.titleNode]) 52 | 53 | let insetTitle = ASInsetLayoutSpec( 54 | insets: UIEdgeInsetsMake( 55 | HNDimensions.padding / 1.5, 56 | HNDimensions.padding, 57 | HNDimensions.padding / 1.5, 58 | HNDimensions.padding), 59 | child: mainStack) 60 | 61 | return insetTitle 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /HackerNews/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | } 88 | ], 89 | "info" : { 90 | "version" : 1, 91 | "author" : "xcode" 92 | } 93 | } -------------------------------------------------------------------------------- /HackerNews/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | NSAppTransportSecurity 24 | 25 | NSAllowsArbitraryLoads 26 | 27 | NSAllowsArbitraryLoadsInWebContent 28 | 29 | 30 | UIAppFonts 31 | 32 | ionicons.ttf 33 | 34 | UILaunchStoryboardName 35 | LaunchScreen 36 | UIRequiredDeviceCapabilities 37 | 38 | armv7 39 | 40 | UIStatusBarTintParameters 41 | 42 | UINavigationBar 43 | 44 | Style 45 | UIBarStyleDefault 46 | Translucent 47 | 48 | 49 | 50 | UISupportedInterfaceOrientations 51 | 52 | UIInterfaceOrientationPortrait 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /HackerNews/SettingsProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsProvider.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import PINCache 11 | 12 | class SettingsProvider { 13 | 14 | static func get(ofType type: SettingsType) -> Any? { 15 | return PINCache.shared.object(forKey: type.value) 16 | } 17 | 18 | static func set(value: String, forType type: SettingsType) { 19 | PINCache.shared.setObject(value as NSCoding, forKey: type.value) 20 | } 21 | 22 | static var storyType: StoryType { 23 | get { 24 | return StoryType.initWithValue(value: (self.get(ofType: .defaultStoryType) as? String ?? "")) ?? .top 25 | } 26 | set { 27 | self.set(value: newValue.value, forType: .defaultStoryType) 28 | } 29 | } 30 | 31 | static var font: FontType { 32 | get { 33 | return FontType.initWithValue(value: (self.get(ofType: .font) as? String ?? "")) ?? .default 34 | } 35 | set { 36 | self.set(value: newValue.value, forType: .font) 37 | 38 | FontManager.set(fontType: newValue, andSizeType: self.fontSize) 39 | } 40 | } 41 | 42 | static var fontSize: FontSizeType { 43 | get { 44 | return FontSizeType.initWithValue(value: (self.get(ofType: .fontSize) as? String ?? "")) ?? .default 45 | } 46 | set { 47 | self.set(value: newValue.value, forType: .fontSize) 48 | 49 | FontManager.set(fontType: self.font, andSizeType: newValue) 50 | } 51 | } 52 | 53 | static var theme: ThemeType { 54 | get { 55 | return ThemeType.initWithValue(value: (self.get(ofType: .theme) as? String ?? "")) ?? .dark 56 | } 57 | set { 58 | self.set(value: newValue.value, forType: .theme) 59 | 60 | ThemeManager.set(themeType: newValue) 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /HackerNews/TextHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextHelper.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/19/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TextHelper { 12 | 13 | static func text(commentButtonWithString string: String) -> NSAttributedString { 14 | let attributedString = IonIcon.subtitleIcon(type: .ion_chatbox) 15 | attributedString.append(NSAttributedString( 16 | string: string, 17 | attributes: Styles.storyBar.button)) 18 | 19 | return attributedString 20 | } 21 | 22 | static func text(shareButtonWithString string: String) -> NSAttributedString { 23 | let attributedString = IonIcon.subtitleIcon(type: .ion_ios_upload) 24 | attributedString.append(NSAttributedString( 25 | string: string, 26 | attributes: Styles.storyBar.button)) 27 | 28 | return attributedString 29 | } 30 | 31 | static func text(voteButtonWithString string: String) -> NSAttributedString { 32 | let attributedString = IonIcon.subtitleIcon(type: .ion_arrow_up_a) 33 | attributedString.append(NSAttributedString( 34 | string: string, 35 | attributes: Styles.storyBar.button)) 36 | 37 | return attributedString 38 | } 39 | 40 | static func text(replyButtonWithString string: String) -> NSAttributedString { 41 | let attributedString = IonIcon.subtitleIcon(type: .ion_reply) 42 | attributedString.append(NSAttributedString( 43 | string: string, 44 | attributes: Styles.storyBar.button)) 45 | 46 | return attributedString 47 | } 48 | 49 | static func text(moreButtonWithString string: String) -> NSAttributedString { 50 | let attributedString = IonIcon.subtitleIcon(type: .ion_more) 51 | attributedString.append(NSAttributedString( 52 | string: string, 53 | attributes: Styles.storyBar.button)) 54 | 55 | return attributedString 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hacker News iOS Client 2 | 3 | This is a [Hacker News](https://news.ycombinator.com/) iOS client which I wrote some time ago. 4 | 5 | It supports most of the things you would expect from a news client with some exceptions (posting stories and commenting is not supported currently), though I wanted to include many more features. 6 | 7 | Unfortunately, I lost interest in the project, and I don't think I will work on it any time soon, so I'm open sourcing the code in case someone finds it usefull. 8 | 9 | ## Screenshots 10 | 11 | ![Screenshot 1](./screenshots/screenshot1.png) 12 | ![Screenshot 2](./screenshots/screenshot2.png) 13 | ![Screenshot 3](./screenshots/screenshot3.png) 14 | 15 | ## Features 16 | 17 | - See posts by top/new/best/ask/show/job 18 | - View link inside the app 19 | - View post comments 20 | - Search, powered by Algolia 21 | - Stories 22 | - Comments 23 | - Filter by time period 24 | - Sort by relevance/recent 25 | - Login/Signup 26 | - View own posts 27 | - View own comments 28 | - History of viewed posts (stored locally on the device) 29 | - Own upvotes 30 | - View other people's profile 31 | - About 32 | - Posts 33 | - Comments 34 | - Themes 35 | - Change font/size 36 | 37 | ## Not supported 38 | 39 | - Post story 40 | - Reply to post 41 | 42 | ## Getting started 43 | 44 | ### Requirements 45 | 46 | - XCode 8+ 47 | - iOS 9+ 48 | - [CocoaPods](https://cocoapods.org/) 49 | 50 | ### Running the project 51 | 52 | Install the dependencies 53 | 54 | ```bash 55 | pod install 56 | ``` 57 | 58 | Open `HackerNews.xcworkspace`. 59 | 60 | ## Built with 61 | 62 | - [AsyncDisplayKit 63 | ](https://github.com/facebookarchive/AsyncDisplayKit) (now [Texture](https://github.com/texturegroup/texture/)) - Layouts 64 | - [Alamofire](https://github.com/Alamofire/Alamofire) - Networking 65 | - [PromiseKit](https://github.com/mxcl/PromiseKit) - JavaScript style promises for easier async operations 66 | - [PINCache](https://github.com/pinterest/PINCache) - Caching 67 | 68 | ## License 69 | 70 | Open sourced under the [MIT license](./LICENSE.md). -------------------------------------------------------------------------------- /HackerNews/HNOptionHeaderNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNOptionHeaderNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/4/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | /** 12 | * Header node for an option. 13 | */ 14 | class HNOptionHeaderNode: ASCellNode { 15 | 16 | // MARK: - Properties 17 | 18 | var titleNode = ASTextNode() 19 | var topDividerNode = ASDisplayNode() 20 | var bottomDividerNode = ASDisplayNode() 21 | 22 | // MARK: - Inits 23 | 24 | init(withTitle title: String) { 25 | super.init() 26 | 27 | self.automaticallyManagesSubnodes = true 28 | 29 | // title 30 | self.titleNode.attributedText = NSAttributedString( 31 | string: title, 32 | attributes: Styles.options.header 33 | ) 34 | self.titleNode.style.alignSelf = .start 35 | 36 | self.setupTheme() 37 | } 38 | 39 | // MARK: - Layout 40 | 41 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 42 | 43 | // grow horizontally 44 | self.topDividerNode.style.flexGrow = 1 45 | self.bottomDividerNode.style.flexGrow = 1 46 | 47 | // height 1 48 | self.topDividerNode.style.preferredSize = CGSize(width: 0, height: 1) 49 | self.bottomDividerNode.style.preferredSize = CGSize(width: 0, height: 1) 50 | 51 | // align the title to the left 52 | self.titleNode.style.alignSelf = .start 53 | 54 | let insetTitle = ASInsetLayoutSpec( 55 | insets: UIEdgeInsetsMake( 56 | HNDimensions.padding / 1.5, 57 | HNDimensions.padding, 58 | HNDimensions.padding / 1.5, 59 | HNDimensions.padding), 60 | child: self.titleNode) 61 | 62 | let mainStack = ASStackLayoutSpec( 63 | direction: .vertical, 64 | spacing: 0, 65 | justifyContent: .center, 66 | alignItems: .stretch, 67 | children: [self.topDividerNode, insetTitle, self.bottomDividerNode] 68 | ) 69 | 70 | return mainStack 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /HackerNews/UserAboutBioNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAboutBioNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/6/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class UserAboutBioNode: ASCellNode { 12 | 13 | // MARK: - Properties 14 | 15 | var aboutNode: ASTextNode! 16 | 17 | weak var delegate: UserAboutBioNodeDelegate? 18 | 19 | // MARK: - Inits 20 | 21 | init(withUser user: User) { 22 | super.init() 23 | 24 | self.selectionStyle = .none 25 | 26 | // about 27 | self.aboutNode = ASTextNode() 28 | self.aboutNode.attributedText = NSMutableAttributedString( 29 | string: user.about, 30 | attributes: Styles.user.aboutBio).detectLinks() 31 | self.aboutNode.isUserInteractionEnabled = true 32 | self.aboutNode.linkAttributeNames = [Constants.kTextLinkAttributeName] 33 | self.aboutNode.passthroughNonlinkTouches = true 34 | self.aboutNode.delegate = self 35 | self.addSubnode(self.aboutNode) 36 | 37 | self.setupTheme() 38 | } 39 | 40 | // MARK: - Layout 41 | 42 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 43 | 44 | let insetStack = ASInsetLayoutSpec( 45 | insets: UIEdgeInsetsMake( 46 | HNDimensions.padding / 2, 47 | HNDimensions.padding, 48 | HNDimensions.padding / 2, 49 | HNDimensions.padding), 50 | child: self.aboutNode) 51 | 52 | return insetStack 53 | } 54 | 55 | } 56 | 57 | // MARK: - ASTextNodeDelegate 58 | 59 | extension UserAboutBioNode: ASTextNodeDelegate { 60 | 61 | func textNode(_ textNode: ASTextNode, shouldHighlightLinkAttribute attribute: String, value: Any, at point: CGPoint) -> Bool { 62 | return true 63 | } 64 | 65 | func textNode(_ textNode: ASTextNode, tappedLinkAttribute attribute: String, value: Any, at point: CGPoint, textRange: NSRange) { 66 | if let value = value as? URL { 67 | self.delegate?.userAboutBioNode?(self, didPressLink: value.absoluteString, withSender: textNode) 68 | } 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /HackerNews/SettingsType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum SettingsType: OptionType { 12 | case defaultStoryType 13 | case font 14 | case fontSize 15 | case theme 16 | case clearCache 17 | 18 | case none 19 | 20 | var value: String { 21 | switch self { 22 | case .defaultStoryType: 23 | return "defaultStoryType" 24 | case .font: 25 | return "font" 26 | case .fontSize: 27 | return "fontSize" 28 | case .theme: 29 | return "theme" 30 | case .clearCache: 31 | return "clearCache" 32 | default: 33 | return "" 34 | } 35 | } 36 | 37 | var title: String { 38 | switch self { 39 | case .defaultStoryType: 40 | return "Default story type" 41 | case .font: 42 | return "Font" 43 | case .fontSize: 44 | return "Font size" 45 | case .theme: 46 | return "Theme" 47 | case .clearCache: 48 | return "Clear cache" 49 | default: 50 | return "" 51 | } 52 | } 53 | 54 | var icon: IonIconType { 55 | switch self { 56 | case .defaultStoryType: 57 | return .ion_social_rss 58 | case .font: 59 | return .ion_ios_list 60 | case .fontSize: 61 | return .ion_arrow_resize 62 | case .theme: 63 | return .ion_android_color_palette 64 | case .clearCache: 65 | return .ion_trash_a 66 | default: 67 | return .ion_ios_help 68 | } 69 | } 70 | 71 | static var values: [SettingsType] { 72 | return [ 73 | defaultStoryType, 74 | font, 75 | fontSize, 76 | theme, 77 | clearCache 78 | ] 79 | } 80 | 81 | static func initWithValue(value: String) -> SettingsType? { 82 | for type in values { 83 | if type.value == value { 84 | return type 85 | } 86 | } 87 | return nil 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /HackerNews/BaseViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/9/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AMScrollingNavbar 11 | import MBProgressHUD 12 | import AsyncDisplayKit 13 | 14 | class BaseViewController: ASViewController { 15 | 16 | // MARK: - Lifecycle 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | 21 | self.setupTheme() 22 | } 23 | 24 | // MARK: - Properties 25 | 26 | override var nibName: String? { 27 | get { 28 | // return self.classForCoder.description().components(separatedBy: ".").last ?? super.nibName 29 | return nil 30 | } 31 | } 32 | 33 | var navController: NavigationController { 34 | return self.navigationController as! NavigationController 35 | } 36 | 37 | // MARK: - Helpers 38 | 39 | func authenticated() -> Bool { 40 | if !User.sharedInstance.isLoggedIn() { 41 | self.present(AuthViewController(state: .login), animated: true, completion: nil) 42 | return false 43 | } 44 | return true 45 | } 46 | 47 | } 48 | 49 | extension BaseViewController { 50 | 51 | func navigationController(followScrollView scrollView: UIView, delay: Double) { 52 | if let navigationController = navigationController as? ScrollingNavigationController { 53 | navigationController.followScrollView(scrollView, delay: delay) 54 | } 55 | } 56 | 57 | func stopFollowingScrollView() { 58 | if let navigationController = navigationController as? ScrollingNavigationController { 59 | navigationController.stopFollowingScrollView() 60 | } 61 | } 62 | 63 | func showNavbar(animated: Bool = true) { 64 | if let navigationController = navigationController as? ScrollingNavigationController { 65 | navigationController.showNavbar(animated: animated) 66 | } 67 | } 68 | 69 | } 70 | 71 | extension BaseViewController { 72 | 73 | func showLoader() { 74 | MBProgressHUD.showLoader(view: self.view) 75 | } 76 | 77 | func hideLoader() { 78 | MBProgressHUD.hideLoader(view: self.view) 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /HackerNews/NavigationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/9/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AMScrollingNavbar 11 | 12 | class NavigationController: ScrollingNavigationController { 13 | 14 | // MARK: - Inits 15 | 16 | init() { 17 | super.init(rootViewController: HomeViewController()) 18 | 19 | self.setup() 20 | } 21 | 22 | override init(rootViewController: UIViewController) { 23 | super.init(rootViewController: rootViewController) 24 | 25 | self.setup() 26 | } 27 | 28 | override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { 29 | super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) 30 | 31 | self.setup() 32 | } 33 | 34 | required init?(coder aDecoder: NSCoder) { 35 | super.init(coder: aDecoder) 36 | 37 | self.setup() 38 | } 39 | 40 | // MARK: - Setup Helpers 41 | 42 | func setup() { 43 | self.setupTheme() 44 | } 45 | 46 | // MARK: - Helpers 47 | 48 | func push(viewController: UIViewController, animated: Bool = true) { 49 | self.pushViewController(viewController, animated: animated) 50 | } 51 | 52 | func pop(toViewControllerOfType type: AnyClass, animated: Bool = true) -> Bool { 53 | for viewController in self.viewControllers { 54 | if viewController.classForCoder == type { 55 | let _ = self.popToViewController(viewController, animated: animated) 56 | return true 57 | } 58 | } 59 | return false 60 | } 61 | 62 | func back(animated: Bool = true) { 63 | let _ = self.popViewController(animated: animated) 64 | } 65 | 66 | func displayHome(animated: Bool = true) { 67 | if !self.pop(toViewControllerOfType: HomeViewController.classForCoder(), animated: animated) { 68 | self.push(viewController: HomeViewController(), animated: animated) 69 | } 70 | } 71 | 72 | func present(viewController: UIViewController, animated: Bool = true, completionHandler handler: (() -> Void)? = nil) { 73 | self.present(viewController: viewController, animated: animated, completionHandler: handler) 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /HackerNews/HNAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNAPI.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/9/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import PromiseKit 11 | import Alamofire 12 | 13 | class HNAPI { 14 | 15 | static let HNAPIQueue = DispatchQueue(label: "HNAPIQueue") 16 | 17 | static func stories(ofType type: StoryType) -> Promise<[Int]> { 18 | return FirebaseAPI.stories(ofType: type) 19 | } 20 | 21 | static func item(id: Int) -> Promise { 22 | return FirebaseAPI.item(id: id) 23 | } 24 | 25 | static func itemDetails(id: Int) -> Promise { 26 | return RestAPI.item(id: id) 27 | } 28 | 29 | static func user(id: String) -> Promise { 30 | return FirebaseAPI.user(id: id) 31 | } 32 | 33 | static func userPosts(id: String, page: Int = 0) -> Promise { 34 | return RestAPI.userPosts(id: id, page: page) 35 | } 36 | 37 | static func userComments(id: String, page: Int = 0) -> Promise { 38 | return RestAPI.userComments(id: id, page: page) 39 | } 40 | 41 | static func searchStories(query: String, timeRange: SearchTimeRangeType = .all, sortBy: SearchSortType = .relevance, page: Int = 0) -> Promise { 42 | return RestAPI.searchStories(query: query, timeRange: timeRange, sortBy: sortBy, page: page) 43 | } 44 | 45 | static func searchComments(query: String, timeRange: SearchTimeRangeType = .all, sortBy: SearchSortType = .relevance, page: Int = 0) -> Promise { 46 | return RestAPI.searchComments(query: query, timeRange: timeRange, sortBy: sortBy, page: page) 47 | } 48 | 49 | static func vote(item: Item, up: Bool = true) -> Promise { 50 | return SiteAPI.vote(item: item, up: up) 51 | } 52 | 53 | static func login(username: String, password: String) -> Promise { 54 | return SiteAPI.login(username: username, password: password) 55 | } 56 | 57 | static func register(username: String, password: String) -> Promise { 58 | return SiteAPI.register(username: username, password: password) 59 | } 60 | 61 | static func postStory(title: String, url: String?, text: String?) -> Promise { 62 | return SiteAPI.postStory(title: title, url: url, text: text) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /HackerNews/SearchTimeRangeType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchTimeType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/3/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum SearchTimeRangeType: OptionType { 12 | case all 13 | case day 14 | case week 15 | case month 16 | case year 17 | 18 | var title: String { 19 | switch self { 20 | case .all: 21 | return "All time" 22 | case .day: 23 | return "Last 24h" 24 | case .week: 25 | return "Last week" 26 | case .month: 27 | return "Last month" 28 | case .year: 29 | return "Last year" 30 | } 31 | } 32 | 33 | var icon: IonIconType { 34 | switch self { 35 | default: 36 | return .ion_ios_star 37 | } 38 | } 39 | 40 | var value: String { 41 | switch self { 42 | case .all: 43 | return "all" 44 | case .day: 45 | return "day" 46 | case .week: 47 | return "week" 48 | case .month: 49 | return "month" 50 | case .year: 51 | return "year" 52 | } 53 | } 54 | 55 | var startTime: Date? { 56 | let calendar = Calendar.current 57 | 58 | switch self { 59 | 60 | case .all: 61 | 62 | // in case we want all results, simply ignore the time 63 | return nil 64 | 65 | case .day: 66 | 67 | return calendar.date(byAdding: .day, value: -1, to: Date(), wrappingComponents: false) 68 | 69 | case .week: 70 | 71 | return calendar.date(byAdding: .day, value: -7, to: Date(), wrappingComponents: false) 72 | 73 | case .month: 74 | 75 | return calendar.date(byAdding: .month, value: -1, to: Date(), wrappingComponents: false) 76 | 77 | case .year: 78 | 79 | return calendar.date(byAdding: .year, value: -1, to: Date(), wrappingComponents: false) 80 | } 81 | 82 | } 83 | 84 | static var values: [SearchTimeRangeType] { 85 | return [ 86 | all, 87 | day, 88 | week, 89 | month, 90 | year, 91 | ] 92 | } 93 | 94 | static func initWithValue(value: String) -> SearchTimeRangeType? { 95 | for type in values { 96 | if type.value == value { 97 | return type 98 | } 99 | } 100 | return nil 101 | } 102 | 103 | static let label: String = "Since" 104 | 105 | } 106 | -------------------------------------------------------------------------------- /HackerNews/FontType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontType.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum FontType: OptionType { 12 | case `default` 13 | case helvetica 14 | case helveticaNeue 15 | case georgia 16 | case timesNewRoman 17 | case trebuchetMS 18 | case courier 19 | case courierNew 20 | case arial 21 | case avenir 22 | 23 | var value: String { 24 | switch self { 25 | case .default: 26 | return "default" 27 | case .helvetica: 28 | return "helvetica" 29 | case .helveticaNeue: 30 | return "helveticaNeue" 31 | case .georgia: 32 | return "georgia" 33 | case .timesNewRoman: 34 | return "timesNewRoman" 35 | case .trebuchetMS: 36 | return "trebuchetMS" 37 | case .courier: 38 | return "courier" 39 | case .courierNew: 40 | return "courierNew" 41 | case .arial: 42 | return "arial" 43 | case .avenir: 44 | return "avenir" 45 | } 46 | } 47 | 48 | var title: String { 49 | switch self { 50 | case .default: 51 | return "Default" 52 | case .helvetica: 53 | return "Helvetica" 54 | case .helveticaNeue: 55 | return "Helvetica Neue" 56 | case .georgia: 57 | return "Georgia" 58 | case .timesNewRoman: 59 | return "Times New Roman" 60 | case .trebuchetMS: 61 | return "Trebuchet MS" 62 | case .courier: 63 | return "Courier" 64 | case .courierNew: 65 | return "Courier New" 66 | case .arial: 67 | return "Arial" 68 | case .avenir: 69 | return "Avenir" 70 | } 71 | } 72 | 73 | var icon: IonIconType { 74 | switch self { 75 | default: 76 | return .ion_ios_list 77 | } 78 | } 79 | 80 | static var values: [FontType] { 81 | return [ 82 | `default`, 83 | helvetica, 84 | helveticaNeue, 85 | georgia, 86 | timesNewRoman, 87 | trebuchetMS, 88 | courier, 89 | arial, 90 | avenir, 91 | ] 92 | } 93 | 94 | static func initWithValue(value: String) -> FontType? { 95 | for type in values { 96 | if type.value == value { 97 | return type 98 | } 99 | } 100 | return nil 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /HackerNews/UserAboutNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAboutNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/6/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class UserAboutNode: ASCellNode { 12 | 13 | // MARK: - Properties 14 | 15 | var karmaLabelNode: ASTextNode! 16 | var karmaNode: ASTextNode! 17 | var createdLabelNode: ASTextNode! 18 | var createdNode: ASTextNode! 19 | 20 | // MARK: - Inits 21 | 22 | init(withUser user: User) { 23 | super.init() 24 | 25 | self.selectionStyle = .none 26 | 27 | // karma label 28 | self.karmaLabelNode = ASTextNode() 29 | self.karmaLabelNode.attributedText = NSAttributedString( 30 | string: "KARMA", 31 | attributes: Styles.user.aboutLabel) 32 | self.addSubnode(self.karmaLabelNode) 33 | 34 | // karma 35 | self.karmaNode = ASTextNode() 36 | self.karmaNode.attributedText = NSAttributedString( 37 | string: "\(user.karma)", 38 | attributes: Styles.user.aboutValue) 39 | self.addSubnode(self.karmaNode) 40 | 41 | // created label 42 | self.createdLabelNode = ASTextNode() 43 | self.createdLabelNode.attributedText = NSAttributedString( 44 | string: "MEMBER SINCE", 45 | attributes: Styles.user.aboutLabel) 46 | self.addSubnode(self.createdLabelNode) 47 | 48 | // created 49 | self.createdNode = ASTextNode() 50 | self.createdNode.attributedText = NSAttributedString( 51 | string: "\(NSDate.init(timeIntervalSince1970: user.created).timeAgo() ?? "")", 52 | attributes: Styles.user.aboutValue) 53 | self.addSubnode(self.createdNode) 54 | 55 | self.setupTheme() 56 | } 57 | 58 | // MARK: - Layout 59 | 60 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 61 | 62 | let karmaStack = ASStackLayoutSpec( 63 | direction: .vertical, 64 | spacing: HNDimensions.padding / 4, 65 | justifyContent: .start, 66 | alignItems: .center, 67 | children: [self.karmaLabelNode, self.karmaNode]) 68 | 69 | let createdStack = ASStackLayoutSpec( 70 | direction: .vertical, 71 | spacing: HNDimensions.padding / 4, 72 | justifyContent: .start, 73 | alignItems: .center, 74 | children: [self.createdLabelNode, self.createdNode]) 75 | 76 | let mainStack = ASStackLayoutSpec( 77 | direction: .horizontal, 78 | spacing: 0, 79 | justifyContent: .spaceBetween, 80 | alignItems: .center, 81 | children: [karmaStack, createdStack]) 82 | 83 | let insetStack = ASInsetLayoutSpec( 84 | insets: UIEdgeInsetsMake( 85 | HNDimensions.padding / 2, 86 | HNDimensions.padding, 87 | HNDimensions.padding / 2, 88 | HNDimensions.padding), 89 | child: mainStack) 90 | 91 | return insetStack 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /HackerNews/UserCommentNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserCommentNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/6/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class UserCommentNode: ASCellNode { 12 | 13 | // MARK: - Properties 14 | 15 | var posterNode = ASTextNode() 16 | var titleNode = ASTextNode() 17 | var textNode = ASTextNode() 18 | 19 | weak var delegate: UserCommentNodeDelegate? 20 | 21 | // MARK: - Inits 22 | 23 | init(withItem item: Item) { 24 | super.init() 25 | 26 | self.automaticallyManagesSubnodes = true 27 | self.selectionStyle = .none 28 | 29 | // poster 30 | self.posterNode.attributedText = NSAttributedString( 31 | string: "\(item.by) • \(NSDate.init(timeIntervalSince1970: item.time).timeAgo() ?? "")", 32 | attributes: Styles.story.poster 33 | ) 34 | self.posterNode.truncationMode = .byTruncatingTail 35 | self.posterNode.maximumNumberOfLines = 1 36 | self.posterNode.style.flexShrink = 1 37 | 38 | // title 39 | self.titleNode.attributedText = NSAttributedString( 40 | string: item.storyTitle, 41 | attributes: Styles.storyDetails.title 42 | ) 43 | 44 | // text 45 | self.textNode.attributedText = NSMutableAttributedString( 46 | string: item.text, 47 | attributes: Styles.storyDetails.text).detectLinks() 48 | self.textNode.isUserInteractionEnabled = true 49 | self.textNode.linkAttributeNames = [Constants.kTextLinkAttributeName] 50 | self.textNode.passthroughNonlinkTouches = true 51 | self.textNode.delegate = self 52 | 53 | self.setupTheme() 54 | } 55 | 56 | // MARK: - Layout 57 | 58 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 59 | 60 | let mainStack = ASStackLayoutSpec( 61 | direction: .vertical, 62 | spacing: HNDimensions.padding / 2, 63 | justifyContent: .start, 64 | alignItems: .start, 65 | children: [self.posterNode, self.titleNode, self.textNode]) 66 | 67 | let insetStack = ASInsetLayoutSpec( 68 | insets: UIEdgeInsetsMake( 69 | HNDimensions.padding / 2, 70 | HNDimensions.padding, 71 | HNDimensions.padding / 2, 72 | HNDimensions.padding), 73 | child: mainStack) 74 | 75 | return insetStack 76 | } 77 | 78 | } 79 | 80 | // MARK: - ASTextNodeDelegate 81 | 82 | extension UserCommentNode: ASTextNodeDelegate { 83 | 84 | func textNode(_ textNode: ASTextNode, shouldHighlightLinkAttribute attribute: String, value: Any, at point: CGPoint) -> Bool { 85 | return true 86 | } 87 | 88 | func textNode(_ textNode: ASTextNode, tappedLinkAttribute attribute: String, value: Any, at point: CGPoint, textRange: NSRange) { 89 | if let value = value as? URL { 90 | self.delegate?.userCommentNode?(self, didPressLink: value.absoluteString, withSender: textNode) 91 | } 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /HackerNews/HNPostViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNPostViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/24/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class HNPostViewController: BaseViewController { 12 | 13 | // MARK: - IBOutlets 14 | 15 | var tableView = HNTableNode() 16 | 17 | // MARK: - Lifecycle 18 | 19 | init() { 20 | super.init(node: self.tableView) 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | super.init(coder: aDecoder) 25 | } 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | 30 | self.setup() 31 | } 32 | 33 | override func viewWillAppear(_ animated: Bool) { 34 | super.viewWillAppear(animated) 35 | 36 | self.registerForKeyboardNotifications() 37 | } 38 | 39 | override func viewWillDisappear(_ animated: Bool) { 40 | super.viewWillDisappear(animated) 41 | 42 | NotificationCenter.default.removeObserver(self) 43 | } 44 | 45 | // MARK: - Setup Helpers 46 | 47 | func setup() { 48 | self.setupTableView() 49 | 50 | self.setupNavigationItem() 51 | } 52 | 53 | func setupTableView() { 54 | self.tableView.dataSource = self 55 | self.tableView.delegate = self 56 | } 57 | 58 | func setupNavigationItem() { 59 | self.navigationItem.set(leftButtonIcon: .ion_close, target: self, action: #selector(self.onCloseButtonPressed)) 60 | self.navigationItem.set(rightButtonIcon: .ion_checkmark_round, target: self, action: #selector(self.onPostButtonPressed)) 61 | self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 62 | } 63 | 64 | func registerForKeyboardNotifications() { 65 | NotificationCenter.default.addObserver(self, selector: #selector(self.onKeyboardDidShow(notification:)), name: .UIKeyboardDidShow, object: nil) 66 | NotificationCenter.default.addObserver(self, selector: #selector(self.onKeyboardWillHide(notification:)), name: .UIKeyboardWillHide, object: nil) 67 | } 68 | 69 | // MARK: - Helpers 70 | 71 | func onCloseButtonPressed() { 72 | self.dismiss(animated: true, completion: nil) 73 | } 74 | 75 | func onPostButtonPressed() { 76 | } 77 | 78 | func onKeyboardDidShow(notification: Notification) { 79 | } 80 | 81 | func onKeyboardWillHide(notification: Notification) { 82 | } 83 | 84 | } 85 | 86 | // MARK: - ASTableDataSource, ASTableDelegate 87 | 88 | extension HNPostViewController: ASTableDataSource, ASTableDelegate { 89 | 90 | func numberOfSections(in tableView: UITableView) -> Int { 91 | return 0 92 | } 93 | 94 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 95 | return 0 96 | } 97 | 98 | func tableView(_ tableView: ASTableView, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { 99 | return { 100 | return ASCellNode() 101 | } 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /HackerNews/FontManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontManager.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/19/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class Font { 12 | 13 | static let kIconSizeOffset: CGFloat = 4 14 | 15 | var titleSize: CGFloat = 17 16 | var textSize: CGFloat = 15 17 | var subtitleSize: CGFloat = 11 18 | 19 | var titleFont: UIFont 20 | var textFont: UIFont 21 | var subtitleFont: UIFont 22 | 23 | var titleIconFont: UIFont 24 | var textIconFont: UIFont 25 | var subtitleIconFont: UIFont 26 | 27 | init(fontName: String, offset: CGFloat) { 28 | self.titleSize += offset 29 | self.textSize += offset 30 | self.subtitleSize += offset 31 | 32 | if let titleFont = UIFont(name: fontName, size: self.titleSize) { 33 | self.titleFont = titleFont 34 | } else { 35 | self.titleFont = UIFont.systemFont(ofSize: self.titleSize) 36 | } 37 | 38 | if let textFont = UIFont(name: fontName, size: self.textSize) { 39 | self.textFont = textFont 40 | } else { 41 | self.textFont = UIFont.systemFont(ofSize: self.textSize) 42 | } 43 | 44 | if let subtitleFont = UIFont(name: fontName, size: self.subtitleSize) { 45 | self.subtitleFont = subtitleFont 46 | } else { 47 | self.subtitleFont = UIFont.systemFont(ofSize: self.subtitleSize) 48 | } 49 | 50 | self.titleIconFont = UIFont.ionIconFont(ofSize: self.titleSize + Font.kIconSizeOffset) 51 | self.textIconFont = UIFont.ionIconFont(ofSize: self.textSize + Font.kIconSizeOffset) 52 | self.subtitleIconFont = UIFont.ionIconFont(ofSize: self.subtitleSize + Font.kIconSizeOffset) 53 | } 54 | 55 | static let normal: Font = { 56 | return Font(fontName: UIFont.systemFont(ofSize: 1).fontName, offset: 1) 57 | }() 58 | 59 | } 60 | 61 | class FontManager { 62 | 63 | static var currentFont: Font = { 64 | return FontManager.get(ofFontType: SettingsProvider.font, andSizeType: SettingsProvider.fontSize) 65 | }() 66 | 67 | static func set(font: Font) { 68 | self.currentFont = font 69 | } 70 | 71 | static func get(ofFontType type: FontType, andSizeType sizeType: FontSizeType) -> Font { 72 | var fontName = "" 73 | var fontOffset: CGFloat = 1 74 | 75 | switch type { 76 | case .default: 77 | fontName = UIFont.systemFont(ofSize: 1).fontName 78 | default: 79 | fontName = type.title 80 | } 81 | 82 | switch sizeType { 83 | case .smallest: 84 | fontOffset = -2 85 | case .small: 86 | fontOffset = -1 87 | case .default: 88 | fontOffset = 1 89 | case .large: 90 | fontOffset = 4 91 | case .largest: 92 | fontOffset = 6 93 | } 94 | 95 | return Font(fontName: fontName, offset: fontOffset) 96 | } 97 | 98 | static func set(fontType type: FontType, andSizeType sizeType: FontSizeType) { 99 | self.set(font: self.get(ofFontType: type, andSizeType: sizeType)) 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /HackerNews/UserPostsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserPostsViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/5/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AsyncDisplayKit 11 | import PromiseKit 12 | import XLPagerTabStrip 13 | 14 | class UserPostsViewController: StoriesViewController { 15 | 16 | // MARK: - Properties 17 | 18 | var user: String = "" 19 | 20 | var state: AlgoliaResponse? 21 | 22 | // MARK: - Lifecycle 23 | 24 | init(withUser user: String) { 25 | self.user = user 26 | 27 | super.init() 28 | } 29 | 30 | required init?(coder aDecoder: NSCoder) { 31 | super.init(coder: aDecoder) 32 | } 33 | 34 | // MARK: - Setup Helpers 35 | 36 | override func setup() { 37 | super.setup() 38 | self.setupNavigationItem() 39 | 40 | self.refresh() 41 | } 42 | 43 | func setupNavigationItem() { 44 | self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 45 | } 46 | 47 | // MARK: - Helpers 48 | 49 | override func refresh(refreshControl: UIRefreshControl? = nil) { 50 | DispatchQueue.default.async { 51 | self.state = nil 52 | 53 | // remove all existing items 54 | self.removeAllRows(completion: { (finished) in 55 | // load new items 56 | self.fetchMoreItems(completion: { (finished) in 57 | self.refreshControl.endRefreshing() 58 | self.isFetching = false 59 | }) 60 | }) 61 | } 62 | } 63 | 64 | override func fetchMoreItems(completion: ((Bool) -> ())? = nil) { 65 | guard !self.isFetching else { 66 | completion?(true) 67 | return; 68 | } 69 | 70 | var page = 0 71 | 72 | if let state = self.state { 73 | if state.page + 1 >= state.pageCount { 74 | completion?(true) 75 | return; 76 | } 77 | 78 | page = state.page + 1 79 | } 80 | 81 | self.isFetching = true 82 | 83 | HNAPI.userPosts(id: self.user, page: page) 84 | .then { result -> Promise in 85 | self.state = result 86 | 87 | return ItemProvider.add(items: result.hits) 88 | .then { Void -> Void in 89 | self.append(items: result.hits, completion: completion) 90 | } 91 | }.catch { error in 92 | print(error) // TODO: - Handle this 93 | completion?(false) 94 | } 95 | } 96 | 97 | } 98 | 99 | // MARK: - IndicatorInfoProvider 100 | 101 | extension UserPostsViewController: IndicatorInfoProvider { 102 | 103 | func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { 104 | return IndicatorInfo(title: "Posts") 105 | } 106 | 107 | } 108 | 109 | // MARK: - ASTableView 110 | 111 | extension UserPostsViewController { 112 | 113 | override func shouldBatchFetch(for tableView: ASTableView) -> Bool { 114 | return true 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /HackerNews/WebViewNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebViewNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/23/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | 12 | class WebViewNode: ASDisplayNode { 13 | 14 | // MARK: - Properties 15 | 16 | var webView = UIWebView() 17 | var webViewNode = ASDisplayNode() 18 | var storyBarNode: StoryBarNode! 19 | 20 | var delegate: UIWebViewDelegate? { 21 | set { 22 | self.webView.delegate = newValue 23 | } 24 | get { 25 | return self.webView.delegate 26 | } 27 | } 28 | 29 | var storyBarDelegate: StoryBarDelegate? { 30 | set { 31 | self.storyBarNode?.delegate = newValue 32 | } 33 | get { 34 | return self.storyBarNode?.delegate 35 | } 36 | } 37 | 38 | // MARK: - Inits 39 | 40 | init(withItem item: Item) { 41 | super.init() 42 | 43 | self.commonInit() 44 | 45 | // story bar node 46 | self.storyBarNode = StoryBarNode(withItem: item) 47 | } 48 | 49 | override init() { 50 | super.init() 51 | 52 | self.commonInit() 53 | } 54 | 55 | func commonInit() { 56 | self.automaticallyManagesSubnodes = true 57 | self.autoresizingMask = [UIViewAutoresizing.flexibleWidth, UIViewAutoresizing.flexibleHeight] 58 | 59 | // web view 60 | self.webViewNode = ASDisplayNode { () -> UIView in 61 | self.webView.scalesPageToFit = true 62 | self.webView.autoresizingMask = [UIViewAutoresizing.flexibleWidth, UIViewAutoresizing.flexibleHeight] 63 | return self.webView 64 | } 65 | 66 | self.setupTheme() 67 | } 68 | 69 | // MARK: - Helpers 70 | 71 | func setupTheme() { 72 | self.backgroundColor = UIColor.clear 73 | } 74 | 75 | // MARK: - Layout 76 | 77 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 78 | 79 | // story bar 80 | self.storyBarNode?.style.height = .init(unit: .points, value: HNDimensions.itemBar.height) 81 | 82 | // web view 83 | if self.storyBarNode != nil { 84 | self.webViewNode.style.maxSize = CGSize(width: constrainedSize.max.width, height: constrainedSize.max.height - HNDimensions.itemBar.height) 85 | } else { 86 | self.webViewNode.style.maxSize = CGSize(width: constrainedSize.max.width, height: constrainedSize.max.height) 87 | } 88 | 89 | let mainStack = ASStackLayoutSpec( 90 | direction: .vertical, 91 | spacing: 0, 92 | justifyContent: .spaceAround, 93 | alignItems: .stretch, 94 | children: [self.webViewNode]) 95 | mainStack.style.width = .init(unit: .fraction, value: 1) 96 | mainStack.style.height = .init(unit: .fraction, value: 1) 97 | 98 | if self.storyBarNode != nil { 99 | mainStack.children?.append(self.storyBarNode) 100 | } 101 | 102 | let insetStack = ASInsetLayoutSpec( 103 | insets: UIEdgeInsetsMake(0, 0, 0, 0), 104 | child: mainStack) 105 | 106 | return insetStack 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Alamofire (4.4.0) 3 | - AMScrollingNavbar (3.3.3) 4 | - Firebase/Core (4.0.0): 5 | - FirebaseAnalytics (= 4.0.0) 6 | - FirebaseCore (= 4.0.0) 7 | - Firebase/Database (4.0.0): 8 | - Firebase/Core 9 | - FirebaseDatabase (= 4.0.0) 10 | - FirebaseAnalytics (4.0.0): 11 | - FirebaseCore (~> 4.0) 12 | - FirebaseInstanceID (~> 2.0) 13 | - GoogleToolboxForMac/NSData+zlib (~> 2.1) 14 | - FirebaseCore (4.0.0): 15 | - GoogleToolboxForMac/NSData+zlib (~> 2.1) 16 | - FirebaseDatabase (4.0.0): 17 | - FirebaseAnalytics (~> 4.0) 18 | - FirebaseInstanceID (2.0.0): 19 | - FirebaseCore (~> 4.0) 20 | - GoogleToolboxForMac/Defines (2.1.1) 21 | - GoogleToolboxForMac/NSData+zlib (2.1.1): 22 | - GoogleToolboxForMac/Defines (= 2.1.1) 23 | - MBProgressHUD (1.0.0) 24 | - NSDate+TimeAgo (1.0.6) 25 | - PINCache (3.0.1-beta.4): 26 | - PINCache/Arc-exception-safe (= 3.0.1-beta.4) 27 | - PINCache/Core (= 3.0.1-beta.4) 28 | - PINCache/Arc-exception-safe (3.0.1-beta.4): 29 | - PINCache/Core 30 | - PINCache/Core (3.0.1-beta.4): 31 | - PINOperation (= 1.0.3) 32 | - PINOperation (1.0.3) 33 | - PINRemoteImage/Core (3.0.0-beta.9): 34 | - PINOperation 35 | - PINRemoteImage/iOS (3.0.0-beta.9): 36 | - PINRemoteImage/Core 37 | - PINRemoteImage/PINCache (3.0.0-beta.9): 38 | - PINCache (= 3.0.1-beta.4) 39 | - PINRemoteImage/Core 40 | - PromiseKit (4.1.7): 41 | - PromiseKit/Foundation (= 4.1.7) 42 | - PromiseKit/QuartzCore (= 4.1.7) 43 | - PromiseKit/UIKit (= 4.1.7) 44 | - PromiseKit/CorePromise (4.1.7) 45 | - PromiseKit/Foundation (4.1.7): 46 | - PromiseKit/CorePromise 47 | - PromiseKit/QuartzCore (4.1.7): 48 | - PromiseKit/CorePromise 49 | - PromiseKit/UIKit (4.1.7): 50 | - PromiseKit/CorePromise 51 | - Texture (2.3.2): 52 | - Texture/PINRemoteImage (= 2.3.2) 53 | - Texture/Core (2.3.2) 54 | - Texture/PINRemoteImage (2.3.2): 55 | - PINRemoteImage/iOS (= 3.0.0-beta.9) 56 | - PINRemoteImage/PINCache 57 | - Texture/Core 58 | - XLPagerTabStrip (6.0.0) 59 | 60 | DEPENDENCIES: 61 | - Alamofire (~> 4.0) 62 | - AMScrollingNavbar 63 | - Firebase/Database 64 | - MBProgressHUD 65 | - NSDate+TimeAgo 66 | - PINCache 67 | - PromiseKit (~> 4.1.7) 68 | - Texture 69 | - XLPagerTabStrip (~> 6.0) 70 | 71 | SPEC CHECKSUMS: 72 | Alamofire: dc44b1600b800eb63da6a19039a0083d62a6a62d 73 | AMScrollingNavbar: 6a2c01a912b96aa36581268759f18d85b3d1575e 74 | Firebase: 284eea779b73fdff309791817da7c68bff8dd572 75 | FirebaseAnalytics: 6f08e746f7d66f5452931bc2e822b5df9c66b64a 76 | FirebaseCore: 85ad466044c2f013cdb167f85d426d15b128114a 77 | FirebaseDatabase: d829b3a8c3e2ac7a16773c5df226966b0805dfc2 78 | FirebaseInstanceID: 9fbf536668f4d3f0880e7438456dabd1376e294b 79 | GoogleToolboxForMac: 8e329f1b599f2512c6b10676d45736bcc2cbbeb0 80 | MBProgressHUD: 4890f671c94e8a0f3cf959aa731e9de2f036d71a 81 | NSDate+TimeAgo: 35601c619b2d59290055e4fe76e61d97677a2360 82 | PINCache: a89c8f609232afac77128557bd12c861844423d7 83 | PINOperation: ac23db44796d4a564ecf9b5ea7163510f579b71d 84 | PINRemoteImage: e4e02e15a6f96cedbcef2abf1c9c1228dc9897d7 85 | PromiseKit: 779f2e41faf62d854e7593026ddbcb0bb5c5002d 86 | Texture: 18664254419f35de27af76399b3b265cbad5740a 87 | XLPagerTabStrip: c46e802a8dcac29edbcea573de8fe920e052455b 88 | 89 | PODFILE CHECKSUM: 1dcabce92461c70b8e51f1b5dfb73cd33bed31f0 90 | 91 | COCOAPODS: 1.2.1 92 | -------------------------------------------------------------------------------- /HackerNews/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/29/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class User: NSObject, NSCoding { 12 | 13 | // MARK: - Properties 14 | 15 | var id: String = "" 16 | var about: String = "" 17 | var created: TimeInterval = 0 18 | var karma: Int = 0 19 | 20 | var posts: [Item] = [] 21 | var comments: [Item] = [] 22 | 23 | static var sharedInstance: User = { 24 | return ItemProvider.currentUser() 25 | }() 26 | 27 | // MARK: - Inits 28 | 29 | override init() { 30 | super.init() 31 | 32 | self.setData(withJSON: [:]) 33 | } 34 | 35 | init(withJSON json: [String: AnyObject]) { 36 | super.init() 37 | 38 | self.setData(withJSON: json) 39 | } 40 | 41 | init(username: String) { 42 | super.init() 43 | 44 | self.setData(withJSON: [:]) 45 | 46 | self.id = username 47 | } 48 | 49 | func setData(withJSON json: [String: AnyObject]) { 50 | self.id = json["id"] as? String ?? self.id 51 | self.about = json["about"] as? String ?? self.about 52 | self.created = json["created"] as? TimeInterval ?? self.created 53 | self.karma = json["karma"] as? Int ?? self.karma 54 | 55 | self.about = self.about.stringByDecodingXMLEntities().stringByRemovingHTMLTags() 56 | } 57 | 58 | // MARK: - Coder 59 | 60 | required public init?(coder aDecoder: NSCoder) { 61 | super.init() 62 | 63 | self.id = aDecoder.decodeObject(forKey: "id") as? String ?? self.id 64 | self.about = aDecoder.decodeObject(forKey: "about") as? String ?? self.about 65 | self.created = aDecoder.decodeDouble(forKey: "created") 66 | self.karma = aDecoder.decodeInteger(forKey: "karma") 67 | 68 | self.posts = aDecoder.decodeObject(forKey: "posts") as? [Item] ?? self.posts 69 | self.comments = aDecoder.decodeObject(forKey: "comments") as? [Item] ?? self.comments 70 | } 71 | 72 | public func encode(with aCoder: NSCoder) { 73 | aCoder.encode(self.id, forKey: "id") 74 | aCoder.encode(self.about, forKey: "about") 75 | aCoder.encode(self.created, forKey: "created") 76 | aCoder.encode(self.karma, forKey: "karma") 77 | 78 | aCoder.encode(self.posts, forKey: "posts") 79 | aCoder.encode(self.comments, forKey: "comments") 80 | } 81 | 82 | // MARK: - Helpers 83 | 84 | static func userWebURL(user: String) -> String { 85 | return "https://news.ycombinator.com/user?id=\(user)" 86 | } 87 | 88 | func isLoggedIn() -> Bool { 89 | if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: Constants.kHackerNewsURL)!) { 90 | for cookie in cookies { 91 | if cookie.name == "user" && !cookie.name.isEmpty { 92 | return true 93 | } 94 | } 95 | } 96 | 97 | return false 98 | } 99 | 100 | func logout() { 101 | if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: Constants.kHackerNewsURL)!) { 102 | for cookie in cookies { 103 | HTTPCookieStorage.shared.deleteCookie(cookie) 104 | } 105 | } 106 | User.sharedInstance = User() 107 | ItemProvider.set(currentUser: User.sharedInstance) 108 | ItemProvider.clear(toDate: Date()) 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /HackerNews/OptionsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionsViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/14/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class OptionsViewController: BaseViewController { 12 | 13 | // MARK: - IBOutlets 14 | 15 | var tableView: OptionSelectTableView! 16 | var optionsView = OptionSelectView() 17 | 18 | // MARK: - Properties 19 | 20 | var delegate: OptionSelectViewDelegate? 21 | 22 | // MARK: - Lifecycle 23 | 24 | init() { 25 | super.init(node: self.optionsView) 26 | 27 | self.tableView = self.optionsView.tableView 28 | 29 | self.setupTransition() 30 | } 31 | 32 | init(delegate: OptionSelectViewDelegate) { 33 | super.init(node: self.optionsView) 34 | 35 | self.tableView = self.optionsView.tableView 36 | self.delegate = delegate 37 | 38 | self.setupTransition() 39 | } 40 | 41 | required init?(coder aDecoder: NSCoder) { 42 | super.init(coder: aDecoder) 43 | 44 | self.setupTransition() 45 | } 46 | 47 | override func viewDidLoad() { 48 | super.viewDidLoad() 49 | 50 | self.setup() 51 | } 52 | 53 | // MARK: - Setup Helpers 54 | 55 | func setup() { 56 | self.setupTableView() 57 | self.setupTheme() 58 | } 59 | 60 | func setupTableView() { 61 | self.tableView.dataSource = self 62 | self.tableView.delegate = self 63 | } 64 | 65 | func setupTransition() { 66 | self.modalPresentationStyle = .overCurrentContext 67 | self.modalTransitionStyle = .crossDissolve 68 | } 69 | 70 | // MARK: - Helpers 71 | 72 | func hide(animated: Bool = true, completion: (() -> Void)? = nil) { 73 | self.dismiss(animated: animated, completion: completion) 74 | } 75 | 76 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 77 | self.hide() 78 | } 79 | 80 | } 81 | 82 | // MARK: - ASTableDataSource, ASTableDelegate 83 | 84 | extension OptionsViewController: ASTableDataSource, ASTableDelegate { 85 | 86 | func numberOfSections(in tableView: UITableView) -> Int { 87 | return self.delegate?.numberOfSections(in: self) ?? 0 88 | } 89 | 90 | func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { 91 | return self.delegate?.optionSelectView(optionSelectView: self, viewForHeaderInSection: section) 92 | } 93 | 94 | func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { 95 | return self.delegate?.optionSelectView(optionSelectView: self, heightForHeaderInSection: section) ?? 0 96 | } 97 | 98 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 99 | return (self.delegate?.optionSelectView(optionSelectView: self, numberOfItemsInSection: section) ?? 0) 100 | } 101 | 102 | func tableView(_ tableView: ASTableView, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { 103 | return self.delegate?.optionSelectView(optionSelectView: self, nodeBlockForRowAt: indexPath) ?? { 104 | return ASCellNode() 105 | } 106 | } 107 | 108 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 109 | self.tableView.deselectRow(at: indexPath, animated: true) 110 | 111 | self.hide(animated: true) { [weak self] in 112 | if let weakSelf = self { 113 | weakSelf.delegate?.optionSelectView(optionSelectView: weakSelf, didSelectItemAtIndexPath: indexPath) 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /HackerNews/FirebaseAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FirebaseAPI.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/21/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Firebase 11 | import PromiseKit 12 | 13 | class FirebaseAPI { 14 | 15 | static var ref: DatabaseReference = { 16 | return Database.database().reference(fromURL: Constants.kFirebaseURL) 17 | }() 18 | 19 | static var topstoriesRef: DatabaseReference { 20 | return ref.child("topstories") 21 | } 22 | 23 | static var newstoriesRef: DatabaseReference { 24 | return ref.child("newstories") 25 | } 26 | 27 | static var beststoriesRef: DatabaseReference { 28 | return ref.child("beststories") 29 | } 30 | 31 | static var askstoriesRef: DatabaseReference { 32 | return ref.child("askstories") 33 | } 34 | 35 | static var showstoriesRef: DatabaseReference { 36 | return ref.child("showstories") 37 | } 38 | 39 | static var jobstoriesRef: DatabaseReference { 40 | return ref.child("jobstories") 41 | } 42 | 43 | static var itemRef: DatabaseReference { 44 | return ref.child("item") 45 | } 46 | 47 | static var userRef: DatabaseReference { 48 | return ref.child("user") 49 | } 50 | 51 | static let FirebaseAPIQueue = DispatchQueue(label: "FirebaseAPIQueue") 52 | 53 | static func item(id: Int) -> Promise { 54 | return Promise.init(resolvers: { (fullfill, reject) in 55 | FirebaseAPIQueue.async { 56 | self.itemRef.child("\(id)").observeSingleEvent(of: .value, with: { (snapshot) in 57 | FirebaseAPIQueue.async { 58 | if let json = snapshot.value as? [String: AnyObject] { 59 | fullfill(Item(withFirebaseJSON: json)) 60 | return; 61 | } 62 | reject(NSError.from(snapshot: snapshot)) 63 | } 64 | }) { (error) in 65 | reject(error) 66 | } 67 | } 68 | }) 69 | } 70 | 71 | static func user(id: String) -> Promise { 72 | return Promise.init(resolvers: { (fullfill, reject) in 73 | FirebaseAPIQueue.async { 74 | self.userRef.child("\(id)").observeSingleEvent(of: .value, with: { (snapshot) in 75 | FirebaseAPIQueue.async { 76 | if let json = snapshot.value as? [String: AnyObject] { 77 | fullfill(User(withJSON: json)) 78 | return; 79 | } 80 | reject(NSError.from(snapshot: snapshot)) 81 | } 82 | }) { (error) in 83 | reject(error) 84 | } 85 | } 86 | }) 87 | } 88 | 89 | static func stories(ofType type: StoryType) -> Promise<[Int]> { 90 | return Promise.init(resolvers: { (fullfill, reject) in 91 | FirebaseAPIQueue.async { 92 | self.ref.child(type.value).observeSingleEvent(of: .value, with: { (snapshot) in 93 | FirebaseAPIQueue.async { 94 | if let stories = snapshot.value as? [Int] { 95 | fullfill(stories) 96 | return; 97 | } 98 | reject(NSError.from(snapshot: snapshot)) 99 | } 100 | }) { (error) in 101 | reject(error) 102 | } 103 | } 104 | }) 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /HackerNews/AuthRequiredNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthNeededNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/12/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class AuthRequiredNode: ASDisplayNode { 12 | 13 | // MARK: - Properties 14 | 15 | var titleTextNode = ASTextNode() 16 | var loginButton = FullButtonNode() 17 | var signupButton = FullButtonNode() 18 | 19 | weak var delegate: AuthRequiredNodeDelegate? 20 | 21 | // MARK: - Inits 22 | 23 | override init() { 24 | super.init() 25 | 26 | self.automaticallyManagesSubnodes = true 27 | 28 | // title 29 | self.titleTextNode.attributedText = NSAttributedString( 30 | string: "kAuthRequiredTitle".localized, 31 | attributes: Styles.auth.title 32 | ) 33 | 34 | // login 35 | self.loginButton.set(title: "kLoginButton".localized) 36 | self.loginButton.isUserInteractionEnabled = true 37 | self.loginButton.addTarget(self, action: #selector(self.loginButtonPressed), forControlEvents: .touchUpInside) 38 | 39 | // signup 40 | self.signupButton.set(title: "kSignupButton".localized) 41 | self.signupButton.isUserInteractionEnabled = true 42 | self.signupButton.addTarget(self, action: #selector(self.signupButtonPressed), forControlEvents: .touchUpInside) 43 | 44 | self.setupTheme() 45 | } 46 | 47 | // MARK: - Layout 48 | 49 | override func layout() { 50 | super.layout() 51 | 52 | self.titleTextNode.position = CGPoint(x: self.calculatedSize.width / 2, y: self.calculatedSize.height / 2 - self.loginButton.calculatedSize.height / 2) 53 | } 54 | 55 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 56 | 57 | let bottomStack = ASStackLayoutSpec( 58 | direction: .horizontal, 59 | spacing: HNDimensions.padding / 2, 60 | justifyContent: .spaceBetween, 61 | alignItems: .center, 62 | children: [self.loginButton, self.signupButton]) 63 | bottomStack.style.width = .init(unit: .fraction, value: 1) 64 | 65 | self.loginButton.style.flexGrow = 0.5 66 | self.signupButton.style.flexGrow = 0.5 67 | 68 | self.loginButton.contentEdgeInsets = UIEdgeInsetsMake( 69 | HNDimensions.padding, 0, 70 | HNDimensions.padding, 0) 71 | self.signupButton.contentEdgeInsets = UIEdgeInsetsMake( 72 | HNDimensions.padding, 0, 73 | HNDimensions.padding, 0) 74 | 75 | let titleSpec = ASCenterLayoutSpec( 76 | horizontalPosition: .center, 77 | verticalPosition: .center, 78 | sizingOption: .minimumSize, 79 | child: self.titleTextNode) 80 | 81 | let mainSpec = ASStackLayoutSpec( 82 | direction: .vertical, 83 | spacing: 0, 84 | justifyContent: .end, 85 | alignItems: .center, 86 | children: [titleSpec, bottomStack]) 87 | 88 | let insetSpec = ASInsetLayoutSpec( 89 | insets: UIEdgeInsetsMake( 90 | HNDimensions.padding, 91 | HNDimensions.padding, 92 | HNDimensions.padding, 93 | HNDimensions.padding), 94 | child: mainSpec) 95 | 96 | return insetSpec 97 | } 98 | 99 | // MARK: - IBActions 100 | 101 | func loginButtonPressed() { 102 | self.delegate?.authRequiredNode(didPressLoginButton: self.loginButton) 103 | } 104 | 105 | func signupButtonPressed() { 106 | self.delegate?.authRequiredNode(didPressSignupButton: self.signupButton) 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /HackerNews/ItemProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/10/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import PromiseKit 11 | import PINCache 12 | 13 | class ItemProvider { 14 | 15 | // MARK: - Read items 16 | 17 | static var readItems: [Int] = { 18 | return PINCache.shared.object(forKey: "readItems") as? [Int] ?? [] 19 | }() 20 | 21 | static func read(itemWithId id: Int) { 22 | // if we already read the item 23 | if let index = self.readItems.index(of: id) { 24 | // remove it 25 | self.readItems.remove(at: index) 26 | } 27 | // add the item to the front 28 | self.readItems.insert(id, at: 0) 29 | 30 | // save the list in cache 31 | PINCache.shared.setObject(self.readItems as NSCoding, forKey: "readItems", block: nil) 32 | } 33 | 34 | static func didRead(itemWithId id: Int) -> Bool { 35 | return self.readItems.contains(id) 36 | } 37 | 38 | // MARK: - Stories 39 | 40 | static func stories(ofType type: StoryType) -> [Int] { 41 | return PINCache.shared.object(forKey: type.value) as? [Int] ?? [] 42 | } 43 | 44 | static func set(stories: [Int], forType type: StoryType) { 45 | PINCache.shared.setObject(stories as NSCoding, forKey: type.value) 46 | } 47 | 48 | // MARK: - Items 49 | 50 | static func add(item: Item) -> Promise { 51 | return Promise(resolvers: { (fulfill, reject) in 52 | PINCache.shared.setObject(item, forKey: "\(item.id)") { (cache, key, object) in 53 | fulfill() 54 | } 55 | }) 56 | } 57 | 58 | static func add(items: [Item]) -> Promise { 59 | return Promise(resolvers: { (fulfill, reject) in 60 | var calls: [Promise] = [] 61 | 62 | for i in 0.. Item? { 73 | return PINCache.shared.object(forKey: "\(id)") as? Item 74 | } 75 | 76 | // MARK: - Users 77 | 78 | static func add(user: User) -> Promise { 79 | return Promise(resolvers: { (fulfill, reject) in 80 | PINCache.shared.setObject(user, forKey: "user:\(user.id)") { (cache, key, object) in 81 | fulfill() 82 | } 83 | }) 84 | } 85 | 86 | static func user(withId id: String) -> User? { 87 | return PINCache.shared.object(forKey: "user:\(id)") as? User 88 | } 89 | 90 | // MARK: - Current user 91 | 92 | static func set(currentUser user: User) { 93 | PINCache.shared.setObject(user, forKey: "currentUser") 94 | } 95 | 96 | static func currentUser() -> User { 97 | return PINCache.shared.object(forKey: "currentUser") as? User ?? User() 98 | } 99 | 100 | // MARK: - Voted items 101 | 102 | static func vote(item: Item) { 103 | PINCache.shared.setObject(true as NSCoding, forKey: "v:\(item.id)") 104 | } 105 | 106 | static func didVote(item: Item) -> Bool { 107 | return PINCache.shared.containsObject(forKey: "v:\(item.id)") 108 | } 109 | 110 | static func removeVote(item: Item) { 111 | PINCache.shared.removeObject(forKey: "v:\(item.id)") 112 | } 113 | 114 | // MARK: - Helpers 115 | 116 | static func clear(toDate date: Date) { 117 | PINCache.shared.trim(to: date) 118 | self.readItems.removeAll() 119 | NotificationCenter.default.post(Constants.kClearCacheNotification) 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /HackerNews/UserAboutViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAboutViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/6/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AsyncDisplayKit 11 | import XLPagerTabStrip 12 | 13 | class UserAboutViewController: BaseViewController { 14 | 15 | // MARK: - IBOutlets 16 | 17 | var tableView = HNTableNode() 18 | 19 | // MARK: - Properties 20 | 21 | var refreshControl: UIRefreshControl = UIRefreshControl() 22 | 23 | var userId: String = "" 24 | var user: User = User() 25 | 26 | // MARK: - Lifecycle 27 | 28 | init(withUser user: String) { 29 | self.userId = user 30 | 31 | super.init(node: self.tableView) 32 | } 33 | 34 | required init?(coder aDecoder: NSCoder) { 35 | super.init(coder: aDecoder) 36 | } 37 | 38 | override func viewDidLoad() { 39 | super.viewDidLoad() 40 | 41 | self.setup() 42 | } 43 | 44 | // MARK: - Setup Helpers 45 | 46 | func setup() { 47 | self.user = ItemProvider.user(withId: self.userId) ?? self.user 48 | 49 | self.setupTableView() 50 | self.refresh() 51 | } 52 | 53 | func setupTableView() { 54 | self.tableView.dataSource = self 55 | self.tableView.delegate = self 56 | 57 | self.refreshControl.addTarget(self, action: #selector(self.refresh(refreshControl:)), for: .valueChanged) 58 | self.tableView.view.addSubview(self.refreshControl) 59 | } 60 | 61 | // MARK: - Helpers 62 | 63 | func refresh(refreshControl: UIRefreshControl? = nil) { 64 | DispatchQueue.default.async { 65 | HNAPI.user(id: self.userId) 66 | .then { result -> Void in 67 | // save the item 68 | self.user = result 69 | let _ = ItemProvider.add(user: self.user) 70 | 71 | self.tableView.reloadData() 72 | 73 | }.always { 74 | self.refreshControl.endRefreshing() 75 | }.catch { error in 76 | print(error) // TODO: - Handle this 77 | } 78 | } 79 | } 80 | 81 | } 82 | 83 | // MARK: - IndicatorInfoProvider 84 | 85 | extension UserAboutViewController: IndicatorInfoProvider { 86 | 87 | func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { 88 | return IndicatorInfo(title: "About") 89 | } 90 | 91 | } 92 | 93 | // MARK: - ASTableDataSource, ASTableDelegate 94 | 95 | extension UserAboutViewController: ASTableDataSource, ASTableDelegate { 96 | 97 | func numberOfSections(in tableView: UITableView) -> Int { 98 | return 1 99 | } 100 | 101 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 102 | return self.user.about.isEmpty ? 1 : 2 103 | } 104 | 105 | func tableView(_ tableView: ASTableView, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { 106 | return { 107 | switch indexPath.row { 108 | case 0: 109 | return UserAboutNode(withUser: self.user) 110 | default: 111 | let node = UserAboutBioNode(withUser: self.user) 112 | node.delegate = self 113 | return node 114 | } 115 | } 116 | } 117 | 118 | func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { 119 | return true 120 | } 121 | 122 | } 123 | 124 | // MARK: - UserAboutBioNodeDelegate 125 | 126 | extension UserAboutViewController: UserAboutBioNodeDelegate { 127 | 128 | func userAboutBioNode(_ userAboutBioNode: UserAboutBioNode, didPressLink link: String, withSender sender: ASDisplayNode) { 129 | self.navController.push(viewController: WebViewController(link: link)) 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /HackerNews/SearchStoriesViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchPostsViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/6/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import PromiseKit 11 | import AsyncDisplayKit 12 | import XLPagerTabStrip 13 | 14 | class SearchStoriesViewController: StoriesViewController, HNSearchProtocol { 15 | 16 | // MARK: - Properties 17 | 18 | var query: String = "" 19 | var sortType: SearchSortType = .relevance 20 | var timeRangeType: SearchTimeRangeType = .all 21 | 22 | var state: AlgoliaResponse? 23 | 24 | // MARK: - Lifecycle 25 | 26 | deinit { 27 | NotificationCenter.default.removeObserver(self) 28 | } 29 | 30 | // MARK: - Setup Helpers 31 | 32 | override func setup() { 33 | super.setup() 34 | 35 | self.setupNotificationListener() 36 | } 37 | 38 | func setupNotificationListener() { 39 | NotificationCenter.default.addObserver(self, selector: #selector(self.onRefreshNotificationReceived(notification:)), name: Constants.kSearchNotification.name, object: nil) 40 | } 41 | 42 | // MARK: - Helpers 43 | 44 | func onRefreshNotificationReceived(notification: Notification) { 45 | // sort type 46 | if let sortType = notification.userInfo?["sort"] as? SearchSortType { 47 | self.sortType = sortType 48 | } 49 | 50 | // time range type 51 | if let timeRangeType = notification.userInfo?["timeRange"] as? SearchTimeRangeType { 52 | self.timeRangeType = timeRangeType 53 | } 54 | 55 | // query 56 | if let query = notification.userInfo?["query"] as? String { 57 | self.query = query 58 | 59 | self.refresh(refreshControl: nil) 60 | } 61 | } 62 | 63 | override func refresh(refreshControl: UIRefreshControl? = nil) { 64 | DispatchQueue.default.async { 65 | self.state = nil 66 | 67 | // remove all existing items 68 | self.removeAllRows(completion: { (finished) in 69 | // load new items 70 | self.fetchMoreItems(completion: { (finished) in 71 | self.refreshControl.endRefreshing() 72 | self.isFetching = false 73 | }) 74 | }) 75 | } 76 | } 77 | 78 | override func fetchMoreItems(completion: ((Bool) -> ())? = nil) { 79 | guard !self.isFetching && !self.query.isEmpty else { 80 | completion?(true) 81 | return; 82 | } 83 | 84 | var page = 0 85 | 86 | if let state = self.state { 87 | if state.page + 1 >= state.pageCount { 88 | completion?(true) 89 | return; 90 | } 91 | 92 | page = state.page + 1 93 | } 94 | 95 | self.isFetching = true 96 | 97 | HNAPI.searchStories(query: self.query, timeRange: self.timeRangeType, sortBy: self.sortType, page: page) 98 | .then { result -> Promise in 99 | self.state = result 100 | 101 | return ItemProvider.add(items: result.hits) 102 | .then { Void -> Void in 103 | self.append(items: result.hits, completion: completion) 104 | } 105 | }.catch { error in 106 | print(error) // TODO: - Handle this 107 | completion?(false) 108 | } 109 | } 110 | 111 | } 112 | 113 | // MARK: - IndicatorInfoProvider 114 | 115 | extension SearchStoriesViewController: IndicatorInfoProvider { 116 | 117 | func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { 118 | return IndicatorInfo(title: "Stories") 119 | } 120 | 121 | } 122 | 123 | // MARK: - ASTableView 124 | 125 | extension SearchStoriesViewController { 126 | 127 | override func shouldBatchFetch(for tableView: ASTableView) -> Bool { 128 | return !self.query.isEmpty 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /HackerNews/TabBarController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AsyncDisplayKit 11 | 12 | class TabBarController: ASTabBarController { 13 | 14 | // MARK: - Lifecycle 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | 19 | self.setup() 20 | } 21 | 22 | // MARK: - Setup Helpers 23 | 24 | func setup() { 25 | self.setupTheme() 26 | self.setupViewControllers() 27 | self.setupTabBar() 28 | self.removeTabBarItemsText() 29 | self.setupDelegate() 30 | } 31 | 32 | func setupViewControllers() { 33 | self.viewControllers = [ 34 | NavigationController(rootViewController: HomeViewController()), 35 | NavigationController(rootViewController: SearchViewController()), 36 | NavigationController(rootViewController: ProfileViewController()), 37 | NavigationController(rootViewController: SettingsViewController()) 38 | ] 39 | } 40 | 41 | func setupTabBar() { 42 | if let viewControllers = self.viewControllers { 43 | if viewControllers.count > 0 { 44 | let icon = IonIcon.image(iconType: .ion_social_hackernews, color: Styles.tabBar.icon) 45 | 46 | viewControllers[0].tabBarItem = UITabBarItem( 47 | title: nil, 48 | image: icon, 49 | selectedImage: icon 50 | ) 51 | } 52 | if viewControllers.count > 1 { 53 | let icon = IonIcon.image(iconType: .ion_search, color: Styles.tabBar.icon) 54 | 55 | viewControllers[1].tabBarItem = UITabBarItem( 56 | title: nil, 57 | image: icon, 58 | selectedImage: icon 59 | ) 60 | } 61 | if viewControllers.count > 2 { 62 | let icon = IonIcon.image(iconType: .ion_person, color: Styles.tabBar.icon) 63 | 64 | viewControllers[2].tabBarItem = UITabBarItem( 65 | title: nil, 66 | image: icon, 67 | selectedImage: icon 68 | ) 69 | } 70 | if viewControllers.count > 3 { 71 | let icon = IonIcon.image(iconType: .ion_ios_cog, color: Styles.tabBar.icon) 72 | 73 | viewControllers[3].tabBarItem = UITabBarItem( 74 | title: nil, 75 | image: icon, 76 | selectedImage: icon 77 | ) 78 | } 79 | } 80 | } 81 | 82 | func setupDelegate() { 83 | self.delegate = self 84 | } 85 | 86 | // MARK: - Helpers 87 | 88 | func removeTabBarItemsText() { 89 | if let items = tabBar.items { 90 | for item in items { 91 | item.title = "" 92 | item.imageInsets = UIEdgeInsetsMake(6, 0, -6, 0); 93 | } 94 | } 95 | } 96 | 97 | } 98 | 99 | // MARK: - UITabBarDelegate 100 | 101 | extension TabBarController: UITabBarControllerDelegate { 102 | 103 | func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { 104 | if let viewControllers = self.viewControllers { 105 | // home 106 | if tabBarController.selectedIndex == 0 { 107 | // make sure first view controller is a navigation controller 108 | ((viewControllers[safe: 0] as? NavigationController)? 109 | // make sure the first view controller in the navigation controller is home 110 | .viewControllers[safe: 0] as? HomeViewController)? 111 | // scroll to top 112 | .tableView.view.setContentOffset(CGPoint.zero, animated: true) 113 | } 114 | } 115 | return true 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /HackerNews/HNPostStoryViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HNPostStoryViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 6/5/17. 6 | // Copyright © 2017 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class HNPostStoryViewController: BaseViewController { 12 | 13 | // MARK: - IBOutlets 14 | 15 | var postStoryNode: HNPostStoryNode! 16 | 17 | // MARK: - Lifecycle 18 | 19 | init() { 20 | self.postStoryNode = HNPostStoryNode() 21 | super.init(node: self.postStoryNode) 22 | } 23 | 24 | required init?(coder aDecoder: NSCoder) { 25 | super.init(coder: aDecoder) 26 | } 27 | 28 | override func viewDidLoad() { 29 | super.viewDidLoad() 30 | 31 | // Do any additional setup after loading the view. 32 | self.setup() 33 | } 34 | 35 | // MARK: - Setup Helpers 36 | 37 | func setup() { 38 | self.setupTheme() 39 | self.setupNode() 40 | self.setupNavigationItem() 41 | } 42 | 43 | func setupNode() { 44 | self.postStoryNode.delegate = self 45 | self.postStoryNode.frame = self.view.bounds 46 | self.postStoryNode.autoresizingMask = [.flexibleWidth, .flexibleHeight] 47 | } 48 | 49 | func setupNavigationItem() { 50 | self.navigationItem.set(leftButtonIcon: .ion_close, target: self, action: #selector(self.close)) 51 | } 52 | 53 | // MARK: - Helpers 54 | 55 | func validateData(title: String, url: String, text: String) -> Bool { 56 | 57 | // title must not be empty 58 | if title.characters.count < 1 { 59 | AlertBuilder() 60 | .set(message: "kErrorEmptyTitle".localized) 61 | .add(actionWithTitle: "Ok") 62 | .present(sender: self) 63 | 64 | return false 65 | } 66 | 67 | // we can't have both an url and a post text 68 | if url.characters.count > 0 && text.characters.count > 0 { 69 | AlertBuilder() 70 | .set(message: "kErrorUrlAndText".localized) 71 | .add(actionWithTitle: "Ok") 72 | .present(sender: self) 73 | 74 | return false 75 | } 76 | 77 | // we must have an url or a title 78 | if url.characters.count == 0 && text.characters.count == 0 { 79 | AlertBuilder() 80 | .set(message: "kErrorEmptyUrlAndText".localized) 81 | .add(actionWithTitle: "Ok") 82 | .present(sender: self) 83 | 84 | return false 85 | } 86 | 87 | // validate url 88 | if !url.isValidUrl() { 89 | AlertBuilder() 90 | .set(message: "kErrorInvalidUrl".localized) 91 | .add(actionWithTitle: "Ok") 92 | .present(sender: self) 93 | 94 | return false 95 | } 96 | 97 | return true 98 | } 99 | 100 | func close() { 101 | self.dismiss(animated: true, completion: nil) 102 | } 103 | 104 | } 105 | 106 | // MARK: - HNPostStoryDelegate 107 | 108 | extension HNPostStoryViewController: HNPostStoryDelegate { 109 | 110 | func postStory(didPressSubmitButton button: ASDisplayNode, title: String, url: String, text: String) { 111 | 112 | // make sure the data is valid 113 | if self.validateData(title: title, url: url, text: text) { 114 | 115 | // show the loader 116 | self.showLoader() 117 | 118 | // post the story 119 | HNAPI.postStory(title: title, url: url, text: text) 120 | .then { Void -> Void in 121 | 122 | // TODO: - Redirect to story? Do we get this in the response or headers? 123 | self.close() 124 | 125 | }.always { 126 | self.hideLoader() 127 | }.catch { error in 128 | AlertBuilder().set(title: "Error") 129 | .set(message: error.localizedDescription) 130 | .add(actionWithTitle: "OK") 131 | .present(sender: self) 132 | } 133 | 134 | } 135 | } 136 | 137 | func postStory(didPressCloseButton button: ASDisplayNode) { 138 | self.close() 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /HackerNews/HistoryViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/5/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AsyncDisplayKit 11 | import PromiseKit 12 | 13 | class HistoryViewController: StoriesViewController { 14 | 15 | // MARK: - Lifecycle 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | 20 | self.setup() 21 | } 22 | 23 | override func viewWillAppear(_ animated: Bool) { 24 | super.viewWillAppear(animated) 25 | 26 | self.setupTabBar() 27 | self.navigationController(followScrollView: self.tableView.view, delay: 50) 28 | } 29 | 30 | override func viewWillDisappear(_ animated: Bool) { 31 | super.viewWillDisappear(animated) 32 | 33 | self.showNavbar(animated: false) 34 | } 35 | 36 | override func viewDidDisappear(_ animated: Bool) { 37 | super.viewDidDisappear(animated) 38 | 39 | self.stopFollowingScrollView() 40 | } 41 | 42 | deinit { 43 | NotificationCenter.default.removeObserver(self) 44 | } 45 | 46 | // MARK: - Setup Helpers 47 | 48 | override func setup() { 49 | super.setup() 50 | 51 | self.storyList = ItemProvider.readItems 52 | 53 | self.title = "History" 54 | self.setupNavigationItem() 55 | self.setupNotificationListener() 56 | } 57 | 58 | func setupNavigationItem() { 59 | self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 60 | } 61 | 62 | func setupTabBar() { 63 | self.navController.tabBarItem.title = nil 64 | } 65 | 66 | func setupNotificationListener() { 67 | NotificationCenter.default.addObserver(self, selector: #selector(self.onClearCacheNotificationReceived), name: Constants.kClearCacheNotification.name, object: nil) 68 | } 69 | 70 | // MARK: - Helpers 71 | 72 | func onClearCacheNotificationReceived() { 73 | self.storyList.removeAll() 74 | self.removeAllRows() 75 | } 76 | 77 | override func refresh(refreshControl: UIRefreshControl? = nil) { 78 | DispatchQueue.default.async { 79 | 80 | self.storyList = ItemProvider.readItems 81 | 82 | // remove all existing items 83 | self.removeAllRows(completion: { (finished) in 84 | // load new items 85 | self.fetchMoreItems(completion: { (finished) in 86 | self.isFetching = false 87 | self.refreshControl.endRefreshing() 88 | }) 89 | }) 90 | } 91 | } 92 | 93 | override func fetchMoreItems(completion: ((Bool) -> ())? = nil) { 94 | // make sure we have items in the list 95 | guard self.storyList.count > self.stories.count && !self.isFetching else { 96 | completion?(true) 97 | return; 98 | } 99 | 100 | self.isFetching = true 101 | 102 | // make sure we won't go out of bounds 103 | let maxItem = min(self.stories.count + Constants.kHomePreloadItemCount, self.storyList.count) 104 | 105 | var callStack: [Promise] = [] 106 | 107 | for i in self.stories.count.. Void in 123 | ItemProvider.add(items: result).then { () -> Void in 124 | // add all the items to the table 125 | self.append(items: result, completion: completion) 126 | }.catch { error in 127 | print(error) 128 | completion?(false) 129 | } 130 | }.catch { error in 131 | print(error) // TODO: - Handle this 132 | completion?(false) 133 | } 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /HackerNews/StoryNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/22/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | 12 | /** 13 | * Story cell. 14 | */ 15 | class StoryNode: ASCellNode { 16 | 17 | // MARK: - Properties 18 | 19 | /** 20 | * The poster of the story. 21 | */ 22 | var posterNode = ASButtonNode() 23 | 24 | /** 25 | * The website of the story. 26 | */ 27 | var siteNode = ASTextNode() 28 | 29 | /** 30 | * The title of the story. 31 | */ 32 | var titleNode = ASTextNode() 33 | 34 | /** 35 | * The story bar node. Contains the story controls like share, comments, upvotes. 36 | */ 37 | var storyBarNode: StoryBarNode! 38 | 39 | /** 40 | * Story node delegate. 41 | */ 42 | weak var delegate: StoryNodeDelegate? 43 | 44 | // MARK: - Inits 45 | 46 | init(withItem item: Item, storyNodeDelegate delegate: StoryNodeDelegate? = nil, storyBarDelegate: StoryBarDelegate? = nil) { 47 | super.init() 48 | 49 | // save properties 50 | self.delegate = delegate 51 | 52 | self.selectionStyle = .none 53 | 54 | self.automaticallyManagesSubnodes = true 55 | 56 | // Poster button 57 | self.posterNode.setAttributedTitle( 58 | NSAttributedString( 59 | string: "\(item.by) • \(NSDate.init(timeIntervalSince1970: item.time).timeAgo() ?? "")", 60 | attributes: Styles.story.poster), 61 | for: UIControlState.normal 62 | ) 63 | self.posterNode.isUserInteractionEnabled = true 64 | self.posterNode.addTarget(self, action: #selector(self.onPosterButtonPressed(_:)), forControlEvents: .touchUpInside) 65 | 66 | // Site button 67 | if !item.url.isEmpty { 68 | self.siteNode.attributedText = NSAttributedString( 69 | string: " • \(item.url.domainName())", 70 | attributes: Styles.story.website) 71 | self.siteNode.truncationMode = .byTruncatingTail 72 | self.siteNode.maximumNumberOfLines = 1 73 | self.siteNode.style.flexShrink = 1 74 | } 75 | 76 | // Title text 77 | self.titleNode.attributedText = NSAttributedString( 78 | string: item.title, 79 | attributes: ItemProvider.didRead(itemWithId: item.id) ? Styles.story.seenTitle : Styles.story.title) 80 | 81 | // Story Bar 82 | self.storyBarNode = StoryBarNode(withItem: item) 83 | self.storyBarNode.delegate = storyBarDelegate 84 | 85 | // setup the theme 86 | self.setupTheme() 87 | } 88 | 89 | // MARK: - Layout 90 | 91 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 92 | 93 | // story bar 94 | self.storyBarNode.style.width = .init(unit: .fraction, value: 1) 95 | self.storyBarNode.style.height = .init(unit: .points, value: HNDimensions.itemBar.height) 96 | 97 | // poster + site 98 | let headerStack = ASStackLayoutSpec( 99 | direction: .horizontal, 100 | spacing: 0, 101 | justifyContent: .start, 102 | alignItems: .center, 103 | children: [self.posterNode, self.siteNode]) 104 | 105 | // header stack + title 106 | let contentStack = ASStackLayoutSpec( 107 | direction: .vertical, 108 | spacing: HNDimensions.padding / 4, 109 | justifyContent: .center, 110 | alignItems: .start, 111 | children: [headerStack, self.titleNode]) 112 | 113 | let insetContentStack = ASInsetLayoutSpec( 114 | insets: UIEdgeInsetsMake(HNDimensions.padding / 2, 115 | HNDimensions.padding, 116 | 0, 117 | HNDimensions.padding 118 | ), 119 | child: contentStack) 120 | 121 | let mainStack = ASStackLayoutSpec( 122 | direction: .vertical, 123 | spacing: 0, 124 | justifyContent: .center, 125 | alignItems: .start, 126 | children: [insetContentStack, self.storyBarNode]) 127 | 128 | return mainStack 129 | } 130 | 131 | // MARK: - IBActions 132 | 133 | func onPosterButtonPressed(_ sender: ASDisplayNode) { 134 | self.delegate?.storyNode?(self, didPressOnPosterButton: sender) 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /HackerNews/UserViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 11/5/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import XLPagerTabStrip 11 | import AsyncDisplayKit 12 | 13 | class UserViewController: HNPagerViewController { 14 | 15 | // MARK: - Properties 16 | 17 | var user: String = "" 18 | 19 | // MARK: - Lifecycle 20 | 21 | init(withUser user: String) { 22 | self.user = user 23 | 24 | super.init(nibName: nil, bundle: nil) 25 | } 26 | 27 | required init?(coder aDecoder: NSCoder) { 28 | super.init(coder: aDecoder) 29 | } 30 | 31 | override func viewDidLoad() { 32 | super.viewDidLoad() 33 | 34 | self.setup() 35 | } 36 | 37 | // MARK: - Setup Helpers 38 | 39 | func setup() { 40 | self.title = user 41 | 42 | self.setupNavigationItem() 43 | self.setupTheme() 44 | } 45 | 46 | func setupNavigationItem() { 47 | self.navigationItem.set(rightButtonIcon: .ion_more, target: self, action: #selector(self.onNavigationRightButtonPressed)) 48 | self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) 49 | } 50 | 51 | // MARK: - IBActions 52 | 53 | func onNavigationRightButtonPressed() { 54 | AppDelegate.tabBarController.present(OptionsViewController(delegate: self), animated: true, completion: nil) 55 | } 56 | 57 | // MARK: - PagerTabStripDataSource 58 | 59 | override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] { 60 | return [ 61 | UserAboutViewController(withUser: self.user), 62 | UserPostsViewController(withUser: self.user), 63 | UserCommentsViewController(withUser: self.user) 64 | ] 65 | } 66 | 67 | } 68 | 69 | // MARK: - OptionSelectViewDelegate 70 | 71 | /** 72 | * There are 2 sections. 73 | * Cancel 74 | * Options 75 | */ 76 | enum UserSectionType: Int { 77 | case options = 0 78 | case cancel = 1 79 | 80 | static var count: Int { 81 | return 2 82 | } 83 | } 84 | 85 | extension UserViewController: OptionSelectViewDelegate { 86 | 87 | func numberOfSections(in optionSelectView: OptionsViewController) -> Int { 88 | return UserSectionType.count 89 | } 90 | 91 | func optionSelectView(optionSelectView: OptionsViewController, numberOfItemsInSection section: Int) -> Int { 92 | 93 | guard let sectionType = UserSectionType(rawValue: section) else { 94 | assertionFailure("User section \(section) not found.") 95 | return -1 96 | } 97 | 98 | // check section 99 | switch sectionType { 100 | 101 | // cancel 102 | case .cancel: 103 | return 1 104 | 105 | // options 106 | case .options: 107 | return UserOptionsNavigationRight.values.count 108 | } 109 | 110 | } 111 | 112 | func optionSelectView(optionSelectView: OptionsViewController, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { 113 | return { 114 | 115 | guard let sectionType = UserSectionType(rawValue: indexPath.section) else { 116 | assertionFailure("User section \(indexPath.section) not found.") 117 | return ASCellNode() 118 | } 119 | 120 | // check section 121 | switch sectionType { 122 | 123 | // cancel 124 | case .cancel: 125 | return HNOptionActionNode(optionType: GeneralType.cancel) 126 | 127 | // options 128 | case .options: 129 | return OptionSelectNode( 130 | optionType: UserOptionsNavigationRight.values[indexPath.row], 131 | selected: false 132 | ) 133 | 134 | } 135 | } 136 | } 137 | 138 | func optionSelectView(optionSelectView: OptionsViewController, didSelectItemAtIndexPath indexPath: IndexPath) { 139 | 140 | guard let sectionType = UserSectionType(rawValue: indexPath.section) else { 141 | assertionFailure("User section \(indexPath.section) not found.") 142 | return; 143 | } 144 | 145 | // check section 146 | switch sectionType { 147 | 148 | // cancel 149 | case .cancel: 150 | return; 151 | 152 | // options 153 | case .options: 154 | 155 | let option = UserOptionsNavigationRight.values[indexPath.row] 156 | 157 | switch option { 158 | case .openInBrowser: 159 | Utils.openURL(urlString: User.userWebURL(user: self.user)) 160 | 161 | } 162 | } 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /HackerNews/AuthViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewController.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/29/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AsyncDisplayKit 11 | 12 | class AuthViewController: BaseViewController { 13 | 14 | // MARK: - IBOutlets 15 | 16 | var authNode: AuthNode! 17 | 18 | // MARK: - Properties 19 | 20 | var state: AuthState = .login { 21 | didSet { 22 | self.authNode.set(state: self.state) 23 | self.authNode.usernameTextField.becomeFirstResponder() 24 | 25 | self.title = self.state == .login ? "Login" : "Register" 26 | } 27 | } 28 | 29 | // MARK: - Lifecycle 30 | 31 | init(state: AuthState) { 32 | self.authNode = AuthNode(state: .login) 33 | 34 | self.state = state 35 | 36 | super.init(node: authNode) 37 | 38 | } 39 | 40 | required init?(coder aDecoder: NSCoder) { 41 | super.init(coder: aDecoder) 42 | } 43 | 44 | override func viewDidLoad() { 45 | super.viewDidLoad() 46 | 47 | // Do any additional setup after loading the view. 48 | self.setup() 49 | } 50 | 51 | // MARK: - Setup Helpers 52 | 53 | func setup() { 54 | self.setupTheme() 55 | self.setupAuthNode() 56 | self.setupNavigationItem() 57 | 58 | let state = self.state 59 | self.state = state 60 | } 61 | 62 | func setupAuthNode() { 63 | self.authNode.delegate = self 64 | self.authNode.frame = self.view.bounds 65 | self.authNode.autoresizingMask = [.flexibleWidth, .flexibleHeight] 66 | } 67 | 68 | func setupNavigationItem() { 69 | self.navigationItem.set(leftButtonIcon: .ion_close, target: self, action: #selector(self.close)) 70 | } 71 | 72 | // MARK: - Helpers 73 | 74 | func validateLogin(username: String, password: String) -> Bool { 75 | // username must be between 2 and 15 characters 76 | if username.characters.count < 2 { 77 | return false 78 | } 79 | if username.characters.count > 15 { 80 | return false 81 | } 82 | // username can only contain text, digits, underscore and dashes 83 | 84 | // password must be > 8 characters 85 | if password.characters.count < 8 { 86 | return false 87 | } 88 | return true 89 | } 90 | 91 | func close() { 92 | self.dismiss(animated: true, completion: nil) 93 | } 94 | 95 | } 96 | 97 | // MARK: - AuthNodeDelegate 98 | 99 | extension AuthViewController: AuthNodeDelegate { 100 | 101 | func authNode(didPressSwitchToSignupButton button: ASDisplayNode) { 102 | self.state = .register 103 | } 104 | 105 | func authNode(didPressSwitchToLoginButton button: ASDisplayNode) { 106 | self.state = .login 107 | } 108 | 109 | func authNode(didPressSwitchToForgotPasswordButton button: ASDisplayNode) { 110 | Utils.openURL(urlString: Constants.kForgotPasswordURL) 111 | } 112 | 113 | func authNode(didPressSubmitButton button: ASDisplayNode, username: String, password: String) { 114 | 115 | if self.validateLogin(username: username, password: password) { 116 | self.showLoader() 117 | 118 | switch self.state { 119 | case .login: 120 | 121 | HNAPI.login(username: username, password: password) 122 | .then { Void -> Void in 123 | 124 | User.sharedInstance = User(username: username) 125 | ItemProvider.set(currentUser: User.sharedInstance) 126 | 127 | NotificationCenter.default.post(Constants.kLoginNotification) 128 | 129 | self.close() 130 | 131 | }.always { 132 | self.hideLoader() 133 | }.catch { error in 134 | AlertBuilder().set(title: "Error") 135 | .set(message: error.localizedDescription) 136 | .add(actionWithTitle: "OK") 137 | .present(sender: self) 138 | } 139 | 140 | case .register: 141 | 142 | HNAPI.register(username: username, password: password) 143 | .then { result -> Void in 144 | 145 | if result.matches(for: "captcha").count > 0 { 146 | self.state = .login 147 | 148 | AlertBuilder() 149 | .set(message: "kRegisterCapcha".localized) 150 | .add(actionWithTitle: "OK", handler: { _ in 151 | Utils.openURL(urlString: Constants.kLoginURL) 152 | }) 153 | .present(sender: self) 154 | 155 | return; 156 | } 157 | 158 | User.sharedInstance = User(username: username) 159 | self.close() 160 | 161 | }.always { 162 | self.hideLoader() 163 | }.catch { error in 164 | AlertBuilder().set(title: "Error") 165 | .set(message: error.localizedDescription) 166 | .add(actionWithTitle: "OK") 167 | .present(sender: self) 168 | } 169 | 170 | } 171 | 172 | } else { 173 | AlertBuilder().set(title: "Error") 174 | .set(message: "kInvalidLogin".localized) 175 | .add(actionWithTitle: "OK") 176 | .present(sender: self) 177 | } 178 | } 179 | 180 | func authNode(didPressCloseButton button: ASDisplayNode) { 181 | self.dismiss(animated: true, completion: nil) 182 | } 183 | 184 | } 185 | -------------------------------------------------------------------------------- /HackerNews/SettingsNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsDropdownNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/30/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class SettingsNode: ASCellNode { 12 | 13 | // MARK: - Properties 14 | 15 | var iconNode = ASTextNode() 16 | var titleNode = ASTextNode() 17 | var selectedNode = ASTextNode() 18 | var rightIconNode = ASTextNode() 19 | 20 | var nodeType: SettingsNodeType = .action 21 | 22 | // MARK: - Inits 23 | 24 | init(dropdownNodeWithType type: OptionType, selected: OptionType) { 25 | super.init() 26 | 27 | self.nodeType = .dropdown 28 | 29 | self.commonInit(optionType: type) 30 | 31 | // selected title 32 | self.selectedNode.attributedText = NSAttributedString( 33 | string: selected.title, 34 | attributes: Styles.settings.selectedTitle) 35 | 36 | // right icon 37 | self.rightIconNode.attributedText = NSAttributedString( 38 | string: IonIconType.ion_chevron_down.rawValue, 39 | attributes: Styles.settings.rightIcon) 40 | } 41 | 42 | init(actionNodeWithType type: OptionType) { 43 | super.init() 44 | 45 | self.nodeType = .action 46 | 47 | self.commonInit(optionType: type) 48 | } 49 | 50 | init(openNodeWithType type: OptionType) { 51 | super.init() 52 | 53 | self.nodeType = .open 54 | 55 | self.commonInit(optionType: type) 56 | 57 | // right icon 58 | self.rightIconNode.attributedText = NSAttributedString( 59 | string: IonIconType.ion_chevron_right.rawValue, 60 | attributes: Styles.settings.rightIcon) 61 | } 62 | 63 | func commonInit(optionType type: OptionType) { 64 | self.automaticallyManagesSubnodes = true 65 | 66 | // icon 67 | self.iconNode.attributedText = NSAttributedString( 68 | string: type.icon.rawValue, 69 | attributes: Styles.settings.icon) 70 | 71 | // title 72 | self.titleNode.attributedText = NSAttributedString( 73 | string: type.title, 74 | attributes: Styles.settings.title) 75 | 76 | self.setupTheme() 77 | } 78 | 79 | // MARK: - Layout 80 | 81 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 82 | switch self.nodeType { 83 | case .dropdown: 84 | let leftStack = ASStackLayoutSpec( 85 | direction: .horizontal, 86 | spacing: HNDimensions.padding / 2, 87 | justifyContent: .start, 88 | alignItems: .center, 89 | children: [self.iconNode, self.titleNode]) 90 | 91 | let rightStack = ASStackLayoutSpec( 92 | direction: .horizontal, 93 | spacing: HNDimensions.padding / 2, 94 | justifyContent: .end, 95 | alignItems: .center, 96 | children: [self.selectedNode, self.rightIconNode]) 97 | 98 | let spacer = ASLayoutSpec() 99 | spacer.style.flexGrow = 1 100 | spacer.style.flexShrink = 1 101 | 102 | let mainStack = ASStackLayoutSpec( 103 | direction: .horizontal, 104 | spacing: 0, 105 | justifyContent: .start, 106 | alignItems: .center, children: [leftStack, spacer, rightStack]) 107 | 108 | let insetTitle = ASInsetLayoutSpec( 109 | insets: UIEdgeInsetsMake( 110 | HNDimensions.padding / 1.5, 111 | HNDimensions.padding, 112 | HNDimensions.padding / 1.5, 113 | HNDimensions.padding), 114 | child: mainStack) 115 | 116 | return insetTitle 117 | 118 | case .action: 119 | let leftStack = ASStackLayoutSpec( 120 | direction: .horizontal, 121 | spacing: HNDimensions.padding / 2, 122 | justifyContent: .start, 123 | alignItems: .center, 124 | children: [self.iconNode, self.titleNode]) 125 | 126 | let insetTitle = ASInsetLayoutSpec( 127 | insets: UIEdgeInsetsMake( 128 | HNDimensions.padding / 1.5, 129 | HNDimensions.padding, 130 | HNDimensions.padding / 1.5, 131 | HNDimensions.padding), 132 | child: leftStack) 133 | 134 | return insetTitle 135 | 136 | case .open: 137 | let leftStack = ASStackLayoutSpec( 138 | direction: .horizontal, 139 | spacing: HNDimensions.padding / 2, 140 | justifyContent: .start, 141 | alignItems: .center, 142 | children: [self.iconNode, self.titleNode]) 143 | 144 | let rightStack = ASStackLayoutSpec( 145 | direction: .horizontal, 146 | spacing: HNDimensions.padding / 2, 147 | justifyContent: .end, 148 | alignItems: .center, 149 | children: [self.rightIconNode]) 150 | 151 | let spacer = ASLayoutSpec() 152 | spacer.style.flexGrow = 1 153 | spacer.style.flexShrink = 1 154 | 155 | let mainStack = ASStackLayoutSpec( 156 | direction: .horizontal, 157 | spacing: 0, 158 | justifyContent: .start, 159 | alignItems: .center, children: [leftStack, spacer, rightStack]) 160 | 161 | let insetTitle = ASInsetLayoutSpec( 162 | insets: UIEdgeInsetsMake( 163 | HNDimensions.padding / 1.5, 164 | HNDimensions.padding, 165 | HNDimensions.padding / 1.5, 166 | HNDimensions.padding), 167 | child: mainStack) 168 | 169 | return insetTitle 170 | } 171 | } 172 | 173 | } 174 | -------------------------------------------------------------------------------- /HackerNews/CommentOptionsNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentOptionsNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/23/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class CommentOptionsNode: ASCellNode { 12 | 13 | // MARK: - Properties 14 | 15 | var moreNode = ASTextNode() 16 | var replyIconNode = ASTextNode() 17 | var replyTextNode = ASTextNode() 18 | var voteNode = ASTextNode() 19 | var topDividerNode = ASDisplayNode() 20 | var bottomDividerNode = ASDisplayNode() 21 | 22 | var item = Item() 23 | 24 | weak var delegate: CommentOptionsNodeDelegate? 25 | 26 | // MARK: - Inits 27 | 28 | init(withItem item: Item, commentOptionsDelegate: CommentOptionsNodeDelegate? = nil) { 29 | super.init() 30 | 31 | self.item = item 32 | self.delegate = commentOptionsDelegate 33 | 34 | self.automaticallyManagesSubnodes = true 35 | 36 | // more icon 37 | self.moreNode.attributedText = NSAttributedString( 38 | string: IonIconType.ion_more.rawValue, 39 | attributes: Styles.commentOptions.icon 40 | ) 41 | 42 | // reply icon 43 | self.replyIconNode.attributedText = NSAttributedString( 44 | string: IonIconType.ion_reply.rawValue, 45 | attributes: Styles.commentOptions.icon 46 | ) 47 | 48 | // reply text 49 | self.replyTextNode.attributedText = NSAttributedString( 50 | string: "Reply", 51 | attributes: Styles.commentOptions.text 52 | ) 53 | 54 | // vote icon 55 | self.voteNode.attributedText = NSAttributedString( 56 | string: IonIconType.ion_arrow_up_a.rawValue, 57 | attributes: ItemProvider.didVote(item: item) ? Styles.commentOptions.highlightedIcon : Styles.commentOptions.icon 58 | ) 59 | 60 | // top divider 61 | self.addSubnode(self.topDividerNode) 62 | 63 | // bottom divider 64 | self.addSubnode(self.bottomDividerNode) 65 | 66 | self.setupTheme() 67 | } 68 | 69 | // MARK: - Helpers 70 | 71 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 72 | if let location = touches.first?.location(in: self.view) { 73 | if location.x < self.frame.width / 3 { 74 | self.onMoreButtonPressed(self.moreNode) 75 | } else if location.x < self.frame.width - self.frame.width / 3 { 76 | self.onReplyButtonPressed(self.replyTextNode) 77 | } else { 78 | self.onVoteButtonPressed(self.voteNode) 79 | } 80 | } 81 | } 82 | 83 | func set(upvoted: Bool) { 84 | self.voteNode.attributedText = NSAttributedString( 85 | string: IonIconType.ion_arrow_up_a.rawValue, 86 | attributes: upvoted ? Styles.commentOptions.highlightedIcon : Styles.commentOptions.icon 87 | ) 88 | } 89 | 90 | // MARK: - Layout 91 | 92 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 93 | 94 | // more stack 95 | let moreStack = ASStackLayoutSpec( 96 | direction: .horizontal, 97 | spacing: HNDimensions.padding / 2, 98 | justifyContent: .center, 99 | alignItems: .center, 100 | children: [self.moreNode]) 101 | moreStack.style.width = .init(unit: .fraction, value: 1/2) 102 | 103 | // TODO: - uncomment when replying to post is supported, also change 2 to 3 above 104 | // reply stack 105 | // let replyStack = ASStackLayoutSpec( 106 | // direction: .horizontal, 107 | // spacing: HNDimensions.padding / 2, 108 | // justifyContent: .center, 109 | // alignItems: .center, 110 | // children: [self.replyIconNode, self.replyTextNode]) 111 | // replyStack.style.width = .init(unit: .fraction, value: 1/3) 112 | 113 | // vote stack 114 | let voteStack = ASStackLayoutSpec( 115 | direction: .horizontal, 116 | spacing: HNDimensions.padding / 2, 117 | justifyContent: .center, 118 | alignItems: .center, 119 | children: [self.voteNode]) 120 | voteStack.style.width = .init(unit: .fraction, value: 1/2) 121 | 122 | // main stack 123 | let mainStack = ASStackLayoutSpec( 124 | direction: .horizontal, 125 | spacing: 0, 126 | justifyContent: .spaceAround, 127 | alignItems: .center, 128 | children: [moreStack /*, replyStack */, voteStack]) 129 | 130 | // top/bottom insets 131 | let insetStack = ASInsetLayoutSpec( 132 | insets: UIEdgeInsetsMake( 133 | HNDimensions.padding, 0, 134 | HNDimensions.padding, 0), 135 | child: mainStack) 136 | 137 | return insetStack 138 | } 139 | 140 | override func layout() { 141 | super.layout() 142 | 143 | let width = self.calculatedSize.width 144 | let dividerWidth = width - HNDimensions.padding 145 | 146 | // arrange the dividers 147 | self.topDividerNode.frame = CGRect( 148 | x: HNDimensions.padding / 2, 149 | y: 0, 150 | width: dividerWidth, 151 | height: 1) 152 | self.bottomDividerNode.frame = CGRect( 153 | x: HNDimensions.padding / 2, 154 | y: self.frame.maxY - 1, 155 | width: dividerWidth, 156 | height: 1) 157 | } 158 | 159 | // MARK: - IBActions 160 | 161 | func onMoreButtonPressed(_ sender: ASDisplayNode) { 162 | self.delegate?.commentOptions?(self, didPressMoreButtonWithSender: sender) 163 | } 164 | 165 | func onReplyButtonPressed(_ sender: ASDisplayNode) { 166 | self.delegate?.commentOptions?(self, didPressReplyButtonWithSender: sender) 167 | } 168 | 169 | func onVoteButtonPressed(_ sender: ASDisplayNode) { 170 | self.delegate?.commentOptions?(self, didPressVoteButtonWithSender: sender) 171 | } 172 | 173 | } 174 | -------------------------------------------------------------------------------- /HackerNews/StoryDetailNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryDetailNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/23/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AsyncDisplayKit 11 | import NSDate_TimeAgo 12 | 13 | class StoryDetailNode: ASCellNode { 14 | 15 | // MARK: - Properties 16 | 17 | var posterNode: ASButtonNode! 18 | var siteNode: ASButtonNode! 19 | var titleNode: ASTextNode! 20 | var textNode: ASTextNode! 21 | var dividerNode: ASDisplayNode! 22 | 23 | weak var delegate: StoryDetailNodeDelegate? 24 | 25 | // MARK: - Inits 26 | 27 | init(withItem item: Item, delegate: StoryDetailNodeDelegate? = nil) { 28 | super.init() 29 | 30 | self.delegate = delegate 31 | 32 | // Poster button 33 | self.posterNode = ASButtonNode() 34 | if !item.by.isEmpty && item.time > 0 { 35 | self.posterNode.setAttributedTitle( 36 | NSAttributedString( 37 | string: "\(item.by) • \(NSDate.init(timeIntervalSince1970: item.time).timeAgo() ?? "")", 38 | attributes: Styles.story.poster), 39 | for: .normal 40 | ) 41 | } 42 | self.posterNode.isUserInteractionEnabled = true 43 | self.posterNode.addTarget(self, action: #selector(self.onPosterButtonPressed(_:)), forControlEvents: .touchUpInside) 44 | self.addSubnode(self.posterNode) 45 | 46 | // Site button 47 | self.siteNode = ASButtonNode() 48 | if !item.url.isEmpty { 49 | self.siteNode.setAttributedTitle( 50 | NSAttributedString( 51 | string: " • \(item.url.domainName())", 52 | attributes: Styles.story.website), 53 | for: .normal 54 | ) 55 | } 56 | self.siteNode.isUserInteractionEnabled = true 57 | self.siteNode.addTarget(self, action: #selector(self.onSiteButtonPressed(_:)), forControlEvents: .touchUpInside) 58 | self.addSubnode(self.siteNode) 59 | 60 | // Title text 61 | self.titleNode = ASTextNode() 62 | self.titleNode.attributedText = NSAttributedString( 63 | string: item.title, 64 | attributes: Styles.storyDetails.title) 65 | self.titleNode.isUserInteractionEnabled = true 66 | self.titleNode.addTarget(self, action: #selector(self.onTitlePressed(_:)), forControlEvents: .touchUpInside) 67 | self.addSubnode(self.titleNode) 68 | 69 | // Text 70 | if !item.text.isEmpty { 71 | self.textNode = ASTextNode() 72 | self.textNode.attributedText = NSMutableAttributedString( 73 | string: item.text, 74 | attributes: Styles.storyDetails.text).detectLinks() 75 | self.textNode.isUserInteractionEnabled = true 76 | self.textNode.linkAttributeNames = [Constants.kTextLinkAttributeName] 77 | self.textNode.passthroughNonlinkTouches = true 78 | self.textNode.delegate = self 79 | self.addSubnode(self.textNode) 80 | } 81 | 82 | // Divider 83 | self.dividerNode = ASDisplayNode() 84 | self.addSubnode(self.dividerNode) 85 | 86 | self.setupTheme() 87 | } 88 | 89 | // MARK: - Layout 90 | 91 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 92 | 93 | // poster + site 94 | let headerStack = ASStackLayoutSpec( 95 | direction: .horizontal, 96 | spacing: 0, 97 | justifyContent: .start, 98 | alignItems: .center, 99 | children: [self.posterNode, self.siteNode]) 100 | 101 | var contentChildren: [ASLayoutElement] = [headerStack, self.titleNode] 102 | if self.textNode != nil { 103 | contentChildren.append(self.textNode) 104 | } 105 | 106 | // header stack + title + text 107 | let contentStack = ASStackLayoutSpec( 108 | direction: .vertical, 109 | spacing: HNDimensions.padding, 110 | justifyContent: .center, 111 | alignItems: .start, 112 | children: contentChildren) 113 | 114 | let insetContentStack = ASInsetLayoutSpec( 115 | insets: UIEdgeInsetsMake(HNDimensions.padding / 2, HNDimensions.padding, 116 | 0, HNDimensions.padding), 117 | child: contentStack) 118 | 119 | return insetContentStack 120 | } 121 | 122 | override func layout() { 123 | super.layout() 124 | 125 | // arrange the divider 126 | let distanceBetweenPosterAndTitle = self.titleNode.frame.minY - self.posterNode.frame.maxY 127 | self.dividerNode.frame = CGRect( 128 | x: HNDimensions.padding, 129 | y: self.posterNode.frame.maxY + distanceBetweenPosterAndTitle / 2, 130 | width: self.calculatedSize.width - 2 * HNDimensions.padding, 131 | height: 1) 132 | } 133 | 134 | // MARK: - IBActions 135 | 136 | func onSiteButtonPressed(_ sender: ASDisplayNode) { 137 | self.delegate?.storyDetail?(self, didPressSiteButtonWithSender: sender) 138 | } 139 | 140 | func onPosterButtonPressed(_ sender: ASDisplayNode) { 141 | self.delegate?.storyDetail?(self, didPressPosterButtonWithSender: sender) 142 | } 143 | 144 | func onTitlePressed(_ sender: ASDisplayNode) { 145 | self.delegate?.storyDetail?(self, didPressTitleWithSender: sender) 146 | } 147 | 148 | } 149 | 150 | // MARK: - ASTextNodeDelegate 151 | 152 | extension StoryDetailNode: ASTextNodeDelegate { 153 | 154 | func textNode(_ textNode: ASTextNode, shouldHighlightLinkAttribute attribute: String, value: Any, at point: CGPoint) -> Bool { 155 | return true 156 | } 157 | 158 | func textNode(_ textNode: ASTextNode, tappedLinkAttribute attribute: String, value: Any, at point: CGPoint, textRange: NSRange) { 159 | if let value = value as? URL { 160 | self.delegate?.storyDetail?(self, didPressLink: value.absoluteString, withSender: textNode) 161 | } 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /HackerNews/StoryBarNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemNode.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/22/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import AsyncDisplayKit 10 | 11 | class StoryBarNode: ASDisplayNode { 12 | 13 | // MARK: - Properties 14 | 15 | var shareIconNode = ASTextNode() 16 | var shareTextNode = ASTextNode() 17 | var commentsIconNode = ASTextNode() 18 | var commentsTextNode = ASTextNode() 19 | var votesIconNode = ASTextNode() 20 | var votesTextNode = ASTextNode() 21 | var leftDividerNode = ASDisplayNode() 22 | var rightDividerNode = ASDisplayNode() 23 | 24 | weak var delegate: StoryBarDelegate? 25 | 26 | var itemId: Int = -1 27 | 28 | // MARK: - Inits 29 | 30 | init(withItem item: Item) { 31 | super.init() 32 | 33 | self.itemId = item.id 34 | 35 | self.automaticallyManagesSubnodes = true 36 | 37 | // share icon 38 | self.shareIconNode.attributedText = NSAttributedString( 39 | string: IonIconType.ion_ios_upload.rawValue, 40 | attributes: Styles.storyBar.icon 41 | ) 42 | 43 | // share text 44 | self.shareTextNode.attributedText = NSAttributedString( 45 | string: "Share", 46 | attributes: Styles.storyBar.button 47 | ) 48 | 49 | // comments icon 50 | self.commentsIconNode.attributedText = NSAttributedString( 51 | string: IonIconType.ion_chatbox.rawValue, 52 | attributes: Styles.storyBar.icon 53 | ) 54 | 55 | // comments text 56 | self.commentsTextNode.attributedText = NSAttributedString( 57 | string: "\(item.descendants)", 58 | attributes: Styles.storyBar.button 59 | ) 60 | 61 | // votes icon 62 | self.votesIconNode.attributedText = NSAttributedString( 63 | string: IonIconType.ion_arrow_up_a.rawValue, 64 | attributes: ItemProvider.didVote(item: item) ? Styles.storyBar.highlightedIcon : Styles.storyBar.icon 65 | ) 66 | 67 | // votes text 68 | self.votesTextNode.attributedText = NSAttributedString( 69 | string: "\(item.score)", 70 | attributes: Styles.storyBar.button 71 | ) 72 | 73 | // left divider 74 | self.leftDividerNode = ASDisplayNode() 75 | self.addSubnode(self.leftDividerNode) 76 | 77 | // right divider 78 | self.rightDividerNode = ASDisplayNode() 79 | self.addSubnode(self.rightDividerNode) 80 | 81 | // setup the theme 82 | self.setupTheme() 83 | } 84 | 85 | // MARK: - Helpers 86 | 87 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 88 | if let location = touches.first?.location(in: self.view) { 89 | if location.x < self.frame.width / 3 { 90 | self.onShareButtonPressed(self.shareTextNode) 91 | } else if location.x < self.frame.width - self.frame.width / 3 { 92 | self.onCommentsButtonPressed(self.commentsTextNode) 93 | } else { 94 | self.onVoteButtonPressed(self.votesTextNode) 95 | } 96 | } 97 | } 98 | 99 | func set(upvoted: Bool) { 100 | self.votesIconNode.attributedText = NSAttributedString( 101 | string: IonIconType.ion_arrow_up_a.rawValue, 102 | attributes: upvoted ? Styles.storyBar.highlightedIcon : Styles.storyBar.icon 103 | ) 104 | } 105 | 106 | // MARK: - Layout 107 | 108 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { 109 | 110 | self.style.width = .init(unit: .fraction, value: 1) 111 | self.style.height = .init(unit: .points, value: HNDimensions.itemBar.height) 112 | 113 | let shareStack = ASStackLayoutSpec( 114 | direction: .horizontal, 115 | spacing: HNDimensions.padding / 2, 116 | justifyContent: .center, 117 | alignItems: .center, 118 | children: [self.shareIconNode, self.shareTextNode]) 119 | shareStack.style.width = .init(unit: .fraction, value: 1/3) 120 | 121 | let commentStack = ASStackLayoutSpec( 122 | direction: .horizontal, 123 | spacing: HNDimensions.padding / 2, 124 | justifyContent: .center, 125 | alignItems: .center, 126 | children: [self.commentsIconNode, self.commentsTextNode]) 127 | commentStack.style.width = .init(unit: .fraction, value: 1/3) 128 | 129 | let voteStack = ASStackLayoutSpec( 130 | direction: .horizontal, 131 | spacing: HNDimensions.padding / 2, 132 | justifyContent: .center, 133 | alignItems: .center, 134 | children: [self.votesIconNode, self.votesTextNode]) 135 | voteStack.style.width = .init(unit: .fraction, value: 1/3) 136 | 137 | let mainStack = ASStackLayoutSpec( 138 | direction: .horizontal, 139 | spacing: 0, 140 | justifyContent: .spaceAround, 141 | alignItems: .center, 142 | children: [shareStack, commentStack, voteStack]) 143 | 144 | return mainStack 145 | } 146 | 147 | override func layout() { 148 | super.layout() 149 | 150 | let width = self.calculatedSize.width 151 | let height = self.calculatedSize.height 152 | 153 | // arrange the dividers 154 | self.leftDividerNode.frame = CGRect(x: width / 3, y: height / 2 - HNDimensions.itemBar.dividerHeight / 2, width: 1, height: HNDimensions.itemBar.dividerHeight) 155 | self.rightDividerNode.frame = CGRect(x: 2 * width / 3, y: height / 2 - HNDimensions.itemBar.dividerHeight / 2, width: 1, height: HNDimensions.itemBar.dividerHeight) 156 | } 157 | 158 | // MARK: - IBActions 159 | 160 | func onShareButtonPressed(_ sender: ASDisplayNode) { 161 | self.delegate?.storyBar?(self, didPressShareButtonWithSender: sender) 162 | } 163 | 164 | func onCommentsButtonPressed(_ sender: ASDisplayNode) { 165 | self.delegate?.storyBar?(self, didPressCommentsButtonWithSender: sender) 166 | } 167 | 168 | func onVoteButtonPressed(_ sender: ASDisplayNode) { 169 | self.delegate?.storyBar?(self, didPressVoteButtonWithSender: sender) 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /HackerNews/3DTouchHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3DTouchHelper.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/16/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension AppDelegate { 12 | 13 | // MARK: - Properties 14 | 15 | static func applicationShortcutUserInfoIconKey() -> String { 16 | return "applicationShortcutUserInfoIconKey" 17 | } 18 | 19 | // MARK: - Types 20 | 21 | enum ShortcutIdentifier: String { 22 | case post 23 | case inbox 24 | case search 25 | 26 | // MARK: - Initializers 27 | 28 | init?(fullType: String) { 29 | guard let last = fullType.components(separatedBy: ".").last else { return nil } 30 | 31 | self.init(rawValue: last) 32 | } 33 | 34 | // MARK: - Properties 35 | 36 | var type: String { 37 | return Bundle.main.bundleIdentifier! + ".\(self.rawValue)" 38 | } 39 | } 40 | 41 | // MARK: - Helpers 42 | 43 | @available(iOS 9.0, *) 44 | func handleShortCutItem(shortcutItem: UIApplicationShortcutItem) -> Bool { 45 | var handled = false 46 | 47 | // Verify that the provided `shortcutItem`'s `type` is one handled by the application. 48 | guard ShortcutIdentifier(fullType: shortcutItem.type) != nil else { return false } 49 | 50 | guard let shortCutType = shortcutItem.type as String? else { return false } 51 | 52 | switch (shortCutType) { 53 | case ShortcutIdentifier.post.type: 54 | handled = true 55 | break 56 | case ShortcutIdentifier.inbox.type: 57 | handled = true 58 | break 59 | case ShortcutIdentifier.search.type: 60 | handled = true 61 | break 62 | default: 63 | break 64 | } 65 | 66 | // Construct an alert using the details of the shortcut used to open the application. 67 | let alertController = UIAlertController(title: "Shortcut Handled", message: "\"\(shortcutItem.localizedTitle)\"", preferredStyle: .alert) 68 | let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) 69 | alertController.addAction(okAction) 70 | 71 | // Display an alert indicating the shortcut selected from the home screen. 72 | window!.rootViewController?.present(alertController, animated: true, completion: nil) 73 | 74 | return handled 75 | } 76 | 77 | func app(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 78 | if #available(iOS 9.0, *) { 79 | // Override point for customization after application launch. 80 | var shouldPerformAdditionalDelegateHandling = true 81 | 82 | // If a shortcut was launched, display its information and take the appropriate action 83 | if let shortcutItem = launchOptions?[UIApplicationLaunchOptionsKey.shortcutItem] as? UIApplicationShortcutItem { 84 | 85 | launchedShortcutItem = shortcutItem 86 | 87 | // This will block "performActionForShortcutItem:completionHandler" from being called. 88 | shouldPerformAdditionalDelegateHandling = false 89 | } 90 | 91 | // Install initial versions of our two extra dynamic shortcuts. 92 | if let shortcutItems = application.shortcutItems, shortcutItems.isEmpty { 93 | // Construct the items. 94 | let shortcut1 = UIMutableApplicationShortcutItem(type: ShortcutIdentifier.post.type, localizedTitle: "Post", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .add), userInfo: [ 95 | AppDelegate.applicationShortcutUserInfoIconKey(): UIApplicationShortcutIconType.add.rawValue 96 | ] 97 | ) 98 | 99 | var shortcut2: UIMutableApplicationShortcutItem! 100 | 101 | if #available(iOS 9.1, *) { 102 | shortcut2 = UIMutableApplicationShortcutItem(type: ShortcutIdentifier.inbox.type, localizedTitle: "Inbox", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .mail), userInfo: [ 103 | AppDelegate.applicationShortcutUserInfoIconKey(): UIApplicationShortcutIconType.mail.rawValue 104 | ] 105 | ) 106 | } else { 107 | shortcut2 = UIMutableApplicationShortcutItem(type: ShortcutIdentifier.inbox.type, localizedTitle: "Inbox", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .compose), userInfo: [ 108 | AppDelegate.applicationShortcutUserInfoIconKey(): UIApplicationShortcutIconType.compose.rawValue 109 | ] 110 | ) 111 | } 112 | 113 | let shortcut3 = UIMutableApplicationShortcutItem(type: ShortcutIdentifier.inbox.type, localizedTitle: "Search", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .search), userInfo: [ 114 | AppDelegate.applicationShortcutUserInfoIconKey(): UIApplicationShortcutIconType.search.rawValue 115 | ] 116 | ) 117 | 118 | // Update the application providing the initial 'dynamic' shortcut items. 119 | application.shortcutItems = [shortcut1, shortcut2, shortcut3] 120 | } 121 | 122 | return shouldPerformAdditionalDelegateHandling 123 | } 124 | return true 125 | } 126 | 127 | // MARK: - Application Life Cycle 128 | 129 | func applicationDidBecomeActive(_ application: UIApplication) { 130 | if #available(iOS 9.0, *) { 131 | guard let shortcut = launchedShortcutItem as? UIApplicationShortcutItem else { return } 132 | let _ = handleShortCutItem(shortcutItem: shortcut) 133 | 134 | launchedShortcutItem = nil 135 | } 136 | } 137 | 138 | @objc(application:performActionForShortcutItem:completionHandler:) @available(iOS 9.0, *) 139 | func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { 140 | let handledShortCutItem = handleShortCutItem(shortcutItem: shortcutItem) 141 | 142 | completionHandler(handledShortCutItem) 143 | } 144 | 145 | } 146 | -------------------------------------------------------------------------------- /HackerNews/RestAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RestAPI.swift 3 | // HackerNews 4 | // 5 | // Created by Mihail Cristian Dumitru on 10/21/16. 6 | // Copyright © 2016 Null. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import PromiseKit 11 | import Alamofire 12 | 13 | class RestAPI { 14 | 15 | // MARK: - Properties 16 | 17 | /** 18 | * API urls 19 | */ 20 | open class urls { 21 | open static let baseUrl = "https://hn.algolia.com/api/v1" 22 | 23 | open static let items = "\(baseUrl)/items/" 24 | open static let users = "\(baseUrl)/users/" 25 | open static let searchByRelevance = "\(baseUrl)/search" 26 | open static let searchByDate = "\(baseUrl)/search_by_date" 27 | } 28 | 29 | /** 30 | * Dispatch queue 31 | */ 32 | static let RestAPIQueue = DispatchQueue(label: "RestAPIQueue") 33 | 34 | // MARK: - Helpers 35 | 36 | static func add(timeRange: SearchTimeRangeType, toParams params: [String: Any]) -> [String: Any] { 37 | // check if we need to add a time range 38 | if let startTime = timeRange.startTime { 39 | 40 | var mutableParams = params 41 | mutableParams["numericFilters"] = "created_at_i>=\(startTime.timeIntervalSince1970)" 42 | 43 | return mutableParams 44 | } 45 | 46 | return params 47 | } 48 | 49 | // MARK: - APIs 50 | 51 | static func item(id: Int) -> Promise { 52 | return Promise(resolvers: { (fulfill, reject) in 53 | RestAPIQueue.async { 54 | Alamofire.request("\(urls.items)\(id)", 55 | method: .get, 56 | encoding: JSONEncoding.default) 57 | .validate() 58 | .responseJSON() 59 | .then { response, json -> Void in 60 | RestAPIQueue.async { 61 | let item = Item(withJSON: json) 62 | fulfill(item) 63 | } 64 | }.catch { error in 65 | reject(error) 66 | } 67 | } 68 | }) 69 | } 70 | 71 | static func userPosts(id: String, page: Int = 0) -> Promise { 72 | return Promise(resolvers: { (fulfill, reject) in 73 | RestAPIQueue.async { 74 | let params: [String: Any] = [ 75 | "tags" : "story,author_\(id)", 76 | "page": page 77 | ] 78 | 79 | Alamofire.request(urls.searchByDate, 80 | method: .get, 81 | parameters: params, 82 | encoding: URLEncoding.default) 83 | .validate() 84 | .responseJSON() 85 | .then { response, json -> Void in 86 | RestAPIQueue.async { 87 | let item = AlgoliaResponse(withJSON: json) 88 | fulfill(item) 89 | } 90 | }.catch { error in 91 | reject(error) 92 | } 93 | } 94 | }) 95 | } 96 | 97 | static func userComments(id: String, page: Int = 0) -> Promise { 98 | return Promise(resolvers: { (fulfill, reject) in 99 | RestAPIQueue.async { 100 | let params: [String: Any] = [ 101 | "tags" : "comment,author_\(id)", 102 | "page": page 103 | ] 104 | 105 | Alamofire.request(urls.searchByDate, 106 | method: .get, 107 | parameters: params, 108 | encoding: URLEncoding.default) 109 | .validate() 110 | .responseJSON() 111 | .then { response, json -> Void in 112 | RestAPIQueue.async { 113 | let item = AlgoliaResponse(withJSON: json) 114 | fulfill(item) 115 | } 116 | }.catch { error in 117 | reject(error) 118 | } 119 | } 120 | }) 121 | } 122 | 123 | static func searchStories(query: String, timeRange: SearchTimeRangeType = .all, sortBy: SearchSortType = .relevance, page: Int = 0) -> Promise { 124 | return Promise(resolvers: { (fulfill, reject) in 125 | RestAPIQueue.async { 126 | var params: [String: Any] = [ 127 | "query": query, 128 | "tags" : "story", 129 | "page": page 130 | ] 131 | 132 | params = add(timeRange: timeRange, toParams: params) 133 | 134 | Alamofire.request("\(urls.baseUrl)\(sortBy.url)", 135 | method: .get, 136 | parameters: params, 137 | encoding: URLEncoding.default) 138 | .validate() 139 | .responseJSON() 140 | .then { response, json -> Void in 141 | RestAPIQueue.async { 142 | let item = AlgoliaResponse(withJSON: json) 143 | fulfill(item) 144 | } 145 | }.catch { error in 146 | reject(error) 147 | } 148 | } 149 | }) 150 | } 151 | 152 | static func searchComments(query: String, timeRange: SearchTimeRangeType = .all, sortBy: SearchSortType = .relevance, page: Int = 0) -> Promise { 153 | return Promise(resolvers: { (fulfill, reject) in 154 | RestAPIQueue.async { 155 | var params: [String: Any] = [ 156 | "query": query, 157 | "tags" : "comment", 158 | "page": page, 159 | ] 160 | 161 | params = add(timeRange: timeRange, toParams: params) 162 | 163 | Alamofire.request("\(urls.baseUrl)\(sortBy.url)", 164 | method: .get, 165 | parameters: params, 166 | encoding: URLEncoding.default) 167 | .validate() 168 | .responseJSON() 169 | .then { response, json -> Void in 170 | RestAPIQueue.async { 171 | let item = AlgoliaResponse(withJSON: json) 172 | fulfill(item) 173 | } 174 | }.catch { error in 175 | reject(error) 176 | } 177 | } 178 | }) 179 | } 180 | 181 | } 182 | --------------------------------------------------------------------------------