├── LaunchAtLoginHelper ├── .gitignore ├── LaunchAtLoginHelper │ ├── LaunchAtLoginHelper.entitlements │ ├── LLHAppDelegate.h │ ├── main.m │ ├── LaunchAtLoginHelper-InfoBase.plist │ ├── LaunchAtLoginHelper-Info.plist │ ├── LLHAppDelegate.m │ └── MainMenu.xib ├── LaunchAtLoginHelper.xcodeproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── project.pbxproj ├── setup.py ├── LICENSE └── readme.md ├── Support ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── icon-16x16.png │ │ ├── icon-32x32.png │ │ ├── icon-128x128.png │ │ ├── icon-16x16@2x.png │ │ ├── icon-256x256.png │ │ ├── icon-32x32@2x.png │ │ ├── icon-512x512.png │ │ ├── icon-128x128@2x.png │ │ ├── icon-256x256@2x.png │ │ ├── icon-512x512@2x.png │ │ └── Contents.json │ ├── icon-facebook.imageset │ │ ├── facebook.pdf │ │ └── Contents.json │ ├── icon-upvote.imageset │ │ ├── icon-upvote.pdf │ │ └── Contents.json │ ├── icon-kitty.imageset │ │ ├── icon-kitty@2x.png │ │ └── Contents.json │ ├── comment-icon.imageset │ │ ├── icon-comment@2x.png │ │ └── Contents.json │ ├── icon-reload.imageset │ │ ├── icon-reload@2x.png │ │ └── Contents.json │ ├── icon-settings.imageset │ │ ├── icon-settings.png │ │ └── Contents.json │ ├── icon-twitter.imageset │ │ ├── icon-twitter-1.pdf │ │ └── Contents.json │ ├── placeholder.imageset │ │ ├── placeholder@2x.png │ │ └── Contents.json │ ├── icon-external-link.imageset │ │ ├── External Link.pdf │ │ └── Contents.json │ ├── Icon-product-hunt.imageset │ │ ├── Icon-product-hunt@2x.png │ │ └── Contents.json │ ├── icon-facebook-hovered.imageset │ │ ├── facebook_hover.pdf │ │ └── Contents.json │ ├── icon-twitter-hovered.imageset │ │ ├── icon-twitter-hovered.pdf │ │ └── Contents.json │ └── StatusBarButtonImage.imageset │ │ ├── StatusBarButtonImage@2x.png │ │ └── Contents.json ├── ProductHunt.entitlements ├── ProductHunt-Bridging-Header.h └── Info.plist ├── Source ├── Components │ ├── KPCScaleToFillNSImageView.h │ ├── PHSettings │ │ ├── PHPreferencesWindowControllerProtocol.swift │ │ └── PHPreferencesWindowController.swift │ └── KPCScaleToFillNSImageView.m ├── Tests │ └── UnitTests │ │ ├── Support │ │ ├── UnitTests-Bridging-Header.h │ │ ├── XCTestCase+ProductHunt.swift │ │ ├── PHTestCase.swift │ │ ├── Info.plist │ │ ├── PHFakeFactory.swift │ │ └── PHFakeEndpoint.swift │ │ ├── PHSettingsModuleTests.swift │ │ ├── PHTokenTests.swift │ │ ├── PHSeenPostsModuleTests.swift │ │ ├── PHDateFormatterTests.swift │ │ ├── PHPostsModuleTests.swift │ │ ├── PHPostSorterTests.swift │ │ ├── PHPostViewModelTests.swift │ │ └── PHPostDataSourceTests.swift ├── PHAppState.swift ├── Config │ ├── PHConfiguration.h │ ├── PHConfiguration.m │ ├── ConfigDebug.xcconfig │ ├── ConfigRelease.xcconfig │ └── Keys-Example.xcconfig ├── Models │ ├── PHShareMessage.swift │ ├── Domain Objects │ │ ├── PHSection.swift │ │ ├── PHToken.swift │ │ ├── PHTokenFactory.swift │ │ ├── PHPost.swift │ │ └── PHPostFactory.swift │ ├── PHCallbacks.swift │ ├── PHAnalitycsAPI.swift │ ├── Credentials.swift │ ├── ViewModels │ │ └── PHPostViewModel.swift │ ├── PHStatusBarUpdater.swift │ ├── PHPostSorter.swift │ ├── PHBundle.swift │ ├── PHAnalitycsAPIEndpoint.swift │ ├── API │ │ ├── PHAPI.swift │ │ └── PHAPIEndpoint.swift │ ├── PHPostsDataSource.swift │ ├── PHAnalitycs.swift │ ├── PHDateFormatter.swift │ └── PHDefaults.swift ├── Actions │ ├── PHStartAtLoginAction.swift │ ├── PHFirstLaunchAction.swift │ ├── PHOpenURLAction.swift │ ├── PHOpenSettingsAction.swift │ ├── PHOpenProductHuntAction.swift │ ├── PHScheduleAsSeenAction.swift │ ├── PHPopoverAction.swift │ ├── PHOpenSettingsMenuAction.swift │ └── PHShareAction.swift ├── Categories │ ├── NSButton+ProductHunt.swift │ ├── NSImage+ProductHunt.swift │ ├── NSError+ProductHunt.swift │ └── NSColor+ProductHunt.swift ├── PHAnalitycsModule.swift ├── Views │ ├── PHSeenView.swift │ ├── PHScrollView.swift │ ├── PHSectionCell.swift │ ├── PHLoadingView.swift │ ├── PHButton.swift │ └── PHPostCell.swift ├── PHTokenModule.swift ├── PHAnalitycsOperation.swift ├── PHAppReducer.swift ├── PHMarkAsSeenOperation.swift ├── PHAppStatePosts.swift ├── PHMiddleware.swift ├── PHOpenPostOperation.swift ├── PHSeenPostsModule.swift ├── PHPostsModule.swift ├── PHSettingsModule.swift ├── PHAPIOperation.swift ├── PHLoadPostOperation.swift ├── Controllers │ ├── PHAdvancedSettingsViewController.swift │ ├── PHGeneralSettingsViewController.swift │ ├── PHPostListViewController.swift │ ├── PHAdvancedSettingsViewController.xib │ └── PHGeneralSettingsViewController.xib └── AppDelegate.swift ├── Product Hunt.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcshareddata │ └── xcschemes │ ├── Release.xcscheme │ └── Debug.xcscheme ├── Product Hunt.xcworkspace └── contents.xcworkspacedata ├── .travis.yml ├── .gitignore ├── LaunchAtLogin ├── LLManager.h └── LLManager.m ├── Podfile ├── LICENSE ├── Podfile.lock └── README.md /LaunchAtLoginHelper/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | xcuserdata -------------------------------------------------------------------------------- /Support/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-16x16.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-32x32.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-128x128.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-16x16@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-256x256.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-32x32@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-512x512.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-facebook.imageset/facebook.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-facebook.imageset/facebook.pdf -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-upvote.imageset/icon-upvote.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-upvote.imageset/icon-upvote.pdf -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-128x128@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-256x256@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/icon-512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/AppIcon.appiconset/icon-512x512@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-kitty.imageset/icon-kitty@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-kitty.imageset/icon-kitty@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/comment-icon.imageset/icon-comment@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/comment-icon.imageset/icon-comment@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-reload.imageset/icon-reload@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-reload.imageset/icon-reload@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-settings.imageset/icon-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-settings.imageset/icon-settings.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-twitter.imageset/icon-twitter-1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-twitter.imageset/icon-twitter-1.pdf -------------------------------------------------------------------------------- /Support/Assets.xcassets/placeholder.imageset/placeholder@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/placeholder.imageset/placeholder@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-external-link.imageset/External Link.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-external-link.imageset/External Link.pdf -------------------------------------------------------------------------------- /Support/Assets.xcassets/Icon-product-hunt.imageset/Icon-product-hunt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/Icon-product-hunt.imageset/Icon-product-hunt@2x.png -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-facebook-hovered.imageset/facebook_hover.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-facebook-hovered.imageset/facebook_hover.pdf -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-twitter-hovered.imageset/icon-twitter-hovered.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/icon-twitter-hovered.imageset/icon-twitter-hovered.pdf -------------------------------------------------------------------------------- /Support/Assets.xcassets/StatusBarButtonImage.imageset/StatusBarButtonImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/producthunt/producthunt-osx/HEAD/Support/Assets.xcassets/StatusBarButtonImage.imageset/StatusBarButtonImage@2x.png -------------------------------------------------------------------------------- /Support/ProductHunt.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Support/ProductHunt-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "LLManager.h" 6 | #import "PHConfiguration.h" 7 | #import "KPCScaleToFillNSImageView.h" -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-facebook.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "filename" : "facebook.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper/LaunchAtLoginHelper.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-twitter.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "filename" : "icon-twitter-1.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-external-link.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "filename" : "External Link.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Source/Components/KPCScaleToFillNSImageView.h: -------------------------------------------------------------------------------- 1 | // 2 | // KPCScaleToFillNSImageView.h 3 | // 4 | // Created by onekiloparsec on 4/5/14. 5 | // MIT Licence 6 | // 7 | 8 | #import 9 | 10 | @interface KPCScaleToFillNSImageView : NSImageView 11 | @end 12 | -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-facebook-hovered.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "filename" : "facebook_hover.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-twitter-hovered.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "filename" : "icon-twitter-hovered.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Product Hunt.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Product Hunt.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/Support/UnitTests-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // UnitTests-Bridging-Header.h 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/29/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | #import "LLManager.h" 10 | #import "PHConfiguration.h" 11 | #import "KPCScaleToFillNSImageView.h" -------------------------------------------------------------------------------- /Source/Tests/UnitTests/PHSettingsModuleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHSettingsModuleTests.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/4/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ReSwift 11 | 12 | class PHSettingsModuleTests: PHTestCase { 13 | // TODO: 14 | } 15 | -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-kitty.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "mac", 9 | "filename" : "icon-kitty@2x.png", 10 | "scale" : "2x" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-upvote.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "filename" : "icon-upvote.pdf", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } -------------------------------------------------------------------------------- /Support/Assets.xcassets/comment-icon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "mac", 9 | "filename" : "icon-comment@2x.png", 10 | "scale" : "2x" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-reload.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "mac", 9 | "filename" : "icon-reload@2x.png", 10 | "scale" : "2x" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } -------------------------------------------------------------------------------- /Support/Assets.xcassets/placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "mac", 9 | "filename" : "placeholder@2x.png", 10 | "scale" : "2x" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } -------------------------------------------------------------------------------- /Support/Assets.xcassets/Icon-product-hunt.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "mac", 9 | "filename" : "Icon-product-hunt@2x.png", 10 | "scale" : "2x" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } -------------------------------------------------------------------------------- /Source/PHAppState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAppState.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/27/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import ReSwift 11 | 12 | struct PHAppState: StateType { 13 | var settings: PHSettings 14 | var posts: PHAppStatePosts 15 | var seenPosts: PHSeenPosts 16 | var token: PHToken 17 | } -------------------------------------------------------------------------------- /Source/Config/PHConfiguration.h: -------------------------------------------------------------------------------- 1 | // 2 | // PHConfiguration.h 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/11/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | extern NSString * const kPHAppID; 12 | extern NSString * const kPHAppSecret; 13 | extern NSString * const kPHFeedUrl; 14 | extern NSString * const kSegmentKey; 15 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper/LLHAppDelegate.h: -------------------------------------------------------------------------------- 1 | // 2 | // LLHAppDelegate.h 3 | // LaunchAtLoginHelper 4 | // 5 | // Created by David Keegan on 4/20/12. 6 | // Copyright (c) 2012 David Keegan. 7 | // Some rights reserved: 8 | // 9 | 10 | #import 11 | 12 | @interface LLHAppDelegate : NSObject 13 | 14 | @end 15 | -------------------------------------------------------------------------------- /Source/Models/PHShareMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHShareMessage.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 4/6/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHShareMessage { 12 | 13 | class func message(fromPost post: PHPost) -> String { 14 | return "\(post.title): \(post.tagline) \(post.discussionUrl)" 15 | } 16 | } -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper/main.m: -------------------------------------------------------------------------------- 1 | // 2 | // main.m 3 | // LaunchAtLoginHelper 4 | // 5 | // Created by David Keegan on 4/20/12. 6 | // Copyright (c) 2012 David Keegan. 7 | // Some rights reserved: 8 | // 9 | 10 | #import 11 | 12 | int main(int argc, char *argv[]){ 13 | return NSApplicationMain(argc, (const char **)argv); 14 | } 15 | -------------------------------------------------------------------------------- /Source/Actions/PHStartAtLoginAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHStartAtLoginAction.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/22/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ServiceManagement 11 | 12 | class PHStartAtLoginAction { 13 | 14 | class func perform(_ startAtLogin: Bool) { 15 | LLManager.setLaunchAtLogin(startAtLogin) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Support/Assets.xcassets/icon-settings.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "mac", 9 | "filename" : "icon-settings.png", 10 | "scale" : "2x" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | }, 17 | "properties" : { 18 | "template-rendering-intent" : "template" 19 | } 20 | } -------------------------------------------------------------------------------- /Source/Categories/NSButton+ProductHunt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSButton+ProductHunt.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/18/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | extension NSButton { 12 | 13 | var boolState: Bool { 14 | return state == 1 ? true : false 15 | } 16 | 17 | func setState(forBool bool: Bool) { 18 | state = bool ? 1 : 0 19 | } 20 | } -------------------------------------------------------------------------------- /Source/Config/PHConfiguration.m: -------------------------------------------------------------------------------- 1 | // 2 | // PHConfiguration.m 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/11/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | #import "PHConfiguration.h" 10 | 11 | NSString *const kPHAppID = PRODUCT_HUNT_APP_ID; 12 | NSString *const kPHAppSecret = PRODUCT_HUNT_APP_SECRET; 13 | NSString *const kPHFeedUrl = PRODUCT_HUNT_FEED_URL; 14 | NSString *const kSegmentKey = SEGMENT_WRITE_KEY; 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: Swift 2 | 3 | xcode_project: Product\ Hunt.xcodeproj 4 | xcode_scheme: Debug 5 | osx_image: xcode7.3 6 | os: osx 7 | 8 | before_install: 9 | 10 | - export LANG=en_US.UTF-8 11 | - gem install cocoapods --quiet 12 | - cp Source/Config/Keys-Example.xcconfig Source/Config/Keys.xcconfig 13 | 14 | script: 15 | 16 | - xctool -workspace Product\ Hunt.xcworkspace -scheme Debug -sdk macosx clean test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO 17 | -------------------------------------------------------------------------------- /Source/PHAnalitycsModule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAnalitycsModule.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import ReSwift 10 | 11 | struct PHTrackPostAction: Action { 12 | var post: PHPost 13 | } 14 | 15 | struct PHTrackPostShare: Action { 16 | var post: PHPost 17 | var medium: String 18 | } 19 | 20 | struct PHTrackVisit: Action { 21 | var page: String 22 | } -------------------------------------------------------------------------------- /Source/Models/Domain Objects/PHSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHSectionNew.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/29/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct PHSection { 12 | 13 | static func section(_ posts: [PHPost]) -> PHSection { 14 | return PHSection(day: posts.first!.day, posts: posts) 15 | } 16 | 17 | var day: String 18 | var posts: [PHPost] 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Source/Config/ConfigDebug.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigDebug.xcconfig 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | #include "Keys.xcconfig" 10 | 11 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) PRODUCT_HUNT_APP_ID='$(PRODUCT_HUNT_APP_ID)' PRODUCT_HUNT_APP_SECRET='$(PRODUCT_HUNT_APP_SECRET)' PRODUCT_HUNT_FEED_URL='$(PRODUCT_HUNT_FEED_URL)' SEGMENT_WRITE_KEY='$(SEGMENT_DEVELOPMENT_WRITE_KEY)' 12 | -------------------------------------------------------------------------------- /Source/Config/ConfigRelease.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigRelease.xcconfig 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | #include "Keys.xcconfig" 10 | 11 | GCC_PREPROCESSOR_DEFINITIONS = $(inherited) PRODUCT_HUNT_APP_ID='$(PRODUCT_HUNT_APP_ID)' PRODUCT_HUNT_APP_SECRET='$(PRODUCT_HUNT_APP_SECRET)' PRODUCT_HUNT_FEED_URL='$(PRODUCT_HUNT_FEED_URL)' SEGMENT_WRITE_KEY='$(SEGMENT_PRODUCTION_WRITE_KEY)' 12 | -------------------------------------------------------------------------------- /Source/Models/Domain Objects/PHToken.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHToken.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/14/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct PHToken { 12 | var accessToken: String 13 | 14 | var isValid: Bool { 15 | return !accessToken.isEmpty 16 | } 17 | 18 | func description() -> [String: AnyObject] { 19 | return ["access_token" : accessToken as AnyObject] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Support/Assets.xcassets/StatusBarButtonImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac" 5 | }, 6 | { 7 | "idiom" : "mac", 8 | "scale" : "1x" 9 | }, 10 | { 11 | "idiom" : "mac", 12 | "filename" : "StatusBarButtonImage@2x.png", 13 | "scale" : "2x" 14 | } 15 | ], 16 | "info" : { 17 | "version" : 1, 18 | "author" : "xcode" 19 | }, 20 | "properties" : { 21 | "template-rendering-intent" : "template" 22 | } 23 | } -------------------------------------------------------------------------------- /Source/Config/Keys-Example.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Keys-Example.xcconfig 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/11/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | // API Keys generated from https://www.producthunt.com/v1/oauth/applications 10 | 11 | PRODUCT_HUNT_APP_ID = @"APP_ID_KEY"; 12 | PRODUCT_HUNT_APP_SECRET = @"APP_SECRET_KEY"; 13 | 14 | PRODUCT_HUNT_FEED_URL = @"PRODUCT_HUNT_FEED_URL"; 15 | 16 | SEGMENT_PRODUCTION_WRITE_KEY = @"SEGMENT_PRODUCTION_WRITE_KEY"; 17 | SEGMENT_DEVELOPMENT_WRITE_KEY = @"SEGMENT_DEVELOPMENT_WRITE_KEY"; 18 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/Support/XCTestCase+ProductHunt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductHunt.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/14/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension XCTestCase { 12 | 13 | func ph_expectation(_ description: String, fulfill: (_ expectation: XCTestExpectation) -> Void) { 14 | let expectation = self.expectation(description: description) 15 | 16 | fulfill(expectation) 17 | 18 | waitForExpectations(timeout: 5, handler: nil) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Source/Models/Domain Objects/PHTokenFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHTokenFactory.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/14/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension PHToken { 12 | 13 | static func token(fromDictionary dictionary: [String: Any]?) -> PHToken? { 14 | guard let dictionary = dictionary, let accessToken = dictionary["access_token"] as? String else { 15 | return nil 16 | } 17 | 18 | return PHToken(accessToken: accessToken) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Source/Models/PHCallbacks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHCallbacks.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/30/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | typealias PHVoidCallback = (Void) -> Void 12 | typealias PHAPIErrorClosure = (_ error: NSError) -> () 13 | typealias PHAPIOperationClosure = (_ api: PHAPI,_ errorClosure: @escaping PHAPIErrorClosure) -> () 14 | typealias PHAPITokenCompletion = ((_ token: PHToken?, _ error: NSError?) -> ()) 15 | typealias PHAPIPostCompletion = ((_ posts: [PHPost], _ error: NSError?) -> ()) 16 | -------------------------------------------------------------------------------- /Source/Views/PHSeenView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHSeenView.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/17/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHSeenView: NSView { 12 | 13 | override func awakeFromNib() { 14 | super.awakeFromNib() 15 | 16 | wantsLayer = true 17 | layer?.backgroundColor = NSColor.ph_orangeColor().cgColor 18 | } 19 | 20 | override func draw(_ dirtyRect: NSRect) { 21 | super.draw(dirtyRect) 22 | 23 | layer?.cornerRadius = dirtyRect.size.height/2 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Source/Actions/PHFirstLaunchAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHFirstLaunchAction.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/22/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHFirstLaunchAction { 12 | 13 | class func perform(_ completion: PHVoidCallback) { 14 | let defaults = UserDefaults.standard 15 | 16 | if !defaults.bool(forKey: "alreadyLaunched") { 17 | 18 | completion() 19 | 20 | defaults.set(true, forKey: "alreadyLaunched") 21 | defaults.synchronize() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Source/Components/PHSettings/PHPreferencesWindowControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHTestSettingsWindowControllerProtocol.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/22/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | @objc protocol PHPreferencesWindowControllerProtocol { 12 | 13 | func preferencesIdentifier() -> String 14 | 15 | func preferencesTitle() -> String 16 | 17 | func preferencesIcon() -> NSImage 18 | 19 | @objc optional func firstResponder() -> NSResponder 20 | 21 | @objc optional func preferencesToolTip() -> String 22 | } 23 | -------------------------------------------------------------------------------- /Source/PHTokenModule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHTokenModule.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/3/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | struct PHTokenGetAction: Action { 13 | var token: PHToken 14 | } 15 | 16 | func tokenReducer(_ action: Action, state: PHToken?) -> PHToken { 17 | let state = state ?? PHToken(accessToken: "") 18 | 19 | switch action { 20 | case let action as PHTokenGetAction: 21 | return action.token 22 | 23 | default: 24 | return state 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### Objective-C ### 4 | # Xcode 5 | # 6 | build/ 7 | *.pbxuser 8 | !default.pbxuser 9 | *.mode1v3 10 | !default.mode1v3 11 | *.mode2v3 12 | !default.mode2v3 13 | *.perspectivev3 14 | !default.perspectivev3 15 | xcuserdata 16 | *.xccheckout 17 | *.moved-aside 18 | DerivedData 19 | *.hmap 20 | *.ipa 21 | *.xcuserstate 22 | 23 | Pods/* 24 | 25 | # fastlane 26 | fastlane/report.xml 27 | fastlane/screenshots 28 | fastlane/test_output 29 | ProductHunt.app.dSYM.zip 30 | /*.mobileprovision 31 | 32 | # FileSystem 33 | .DS_Store 34 | 35 | UITests/.DS_Store 36 | 37 | Source/Config/Keys.xcconfig 38 | -------------------------------------------------------------------------------- /Source/Views/PHScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHTableView.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/17/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHScrollView: NSScrollView { 12 | 13 | override func draw(_ dirtyRect: NSRect) { 14 | super.draw(dirtyRect) 15 | 16 | let layer = CALayer() 17 | layer.borderColor = NSColor.windowBackgroundColor.cgColor 18 | layer.borderWidth = 1 19 | layer.frame = NSRect(x: 0, y: dirtyRect.size.height - 1, width: dirtyRect.size.width, height: 1) 20 | 21 | self.layer?.addSublayer(layer) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Source/Categories/NSImage+ProductHunt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSImage+ProductHunt.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 4/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | extension NSImage { 12 | 13 | func tintedImageWithColor(_ color: NSColor) -> NSImage { 14 | let tinted = self.copy() as! NSImage 15 | tinted.lockFocus() 16 | color.set() 17 | 18 | let imageRect = NSRect(origin: NSZeroPoint, size: self.size) 19 | NSRectFillUsingOperation(imageRect, NSCompositingOperation.sourceAtop) 20 | 21 | tinted.unlockFocus() 22 | return tinted 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Source/PHAnalitycsOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAnalitycsOperation.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHAnalitycsOperation { 12 | 13 | class func performTrack(_ post: PHPost) { 14 | store.dispatch( PHTrackPostAction(post: post) ) 15 | } 16 | 17 | class func performTrackShare(_ post: PHPost, medium: String) { 18 | store.dispatch( PHTrackPostShare(post: post, medium: medium) ) 19 | } 20 | 21 | class func performTrackVisit(_ page: String) { 22 | store.dispatch( PHTrackVisit(page: page) ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/Support/PHTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHtestCase.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/18/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import DateTools 11 | import SwiftyTimer 12 | 13 | class PHTestCase: XCTestCase { 14 | 15 | var fake = PHFakeFactory.sharedInstance 16 | 17 | var endpoint = PHAPIFakeEndpoint(token: PHFakeFactory.sharedInstance.token()) 18 | 19 | override func setUp() { 20 | super.setUp() 21 | 22 | PHAPI.sharedInstance.endpoint = endpoint 23 | } 24 | 25 | override func tearDown() { 26 | super.tearDown() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Source/Actions/PHOpenURLAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHOpenURLAction.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/16/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHOpenURLAction { 12 | 13 | class func perform(withPath path: String, closeAfterLaunch: Bool = false) { 14 | perform(withUrl: URL(string: path)!, closeAfterLaunch: closeAfterLaunch) 15 | } 16 | 17 | class func perform(withUrl url: URL, closeAfterLaunch: Bool = false) { 18 | let handle = NSWorkspace.shared().open(url) 19 | 20 | if handle && closeAfterLaunch { 21 | PHPopoverAction.close() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Source/Categories/NSError+ProductHunt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSError+ProductHunt.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/29/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSError { 12 | 13 | class func parseError(_ error: NSError?) -> NSError? { 14 | if let error = error, (error.userInfo[NSLocalizedDescriptionKey] as? String)?.contains("401") != nil { 15 | return unauthorizedError() 16 | } 17 | 18 | return error 19 | } 20 | 21 | class func unauthorizedError() -> NSError { 22 | return NSError(domain: "com.producthunt", code: 401, userInfo: nil) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Source/PHAppReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAppReducer.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/27/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | struct PHAppReducer: Reducer { 13 | 14 | func handleAction(action: Action, state: PHAppState?) -> PHAppState { 15 | return PHAppState( 16 | settings: settingsReducer(action, state: state?.settings), 17 | posts: postsReducer(action, state: state?.posts), 18 | seenPosts: seenPostsReducer(action, state: state?.seenPosts), 19 | token: tokenReducer(action, state: state?.token) 20 | ) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /LaunchAtLogin/LLManager.h: -------------------------------------------------------------------------------- 1 | // 2 | // LLManager.h 3 | // LaunchAtLogin 4 | // 5 | // Created by David Keegan on 4/20/12. 6 | // Copyright (c) 2012 David Keegan. 7 | // Copyright (c) 2014 Jan Weiß. 8 | // Some rights reserved: 9 | // 10 | 11 | #import 12 | 13 | extern NSString * const LLManagerSetLaunchAtLoginFailedNotification; 14 | 15 | @interface LLManager : NSObject 16 | 17 | + (BOOL)launchAtLogin; 18 | + (void)setLaunchAtLogin:(BOOL)value; 19 | + (void)setLaunchAtLogin:(BOOL)value notifyOnFailure:(BOOL)wantFailureNotification; 20 | 21 | @property (assign) BOOL launchAtLogin; 22 | @property (assign) BOOL notifyIfSetLaunchAtLoginFailed; 23 | 24 | @end 25 | -------------------------------------------------------------------------------- /Source/Actions/PHOpenSettingsAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHShowSettingsAction.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/21/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHOpenSettingsAction { 12 | 13 | class func perform() { 14 | let delegate = NSApplication.shared().delegate as! AppDelegate 15 | 16 | delegate.settingsWindow = PHPreferencesWindowController() 17 | delegate.settingsWindow.viewControllers = [ PHGeneralSettingsViewController(nibName: "PHGeneralSettingsViewController", bundle: nil)!, PHAdvancedSettingsViewController(nibName: "PHAdvancedSettingsViewController", bundle: nil)! ] 18 | 19 | delegate.settingsWindow.showPreferencesWindow() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Source/Actions/PHOpenProductHuntAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHOpenProductHuntAction.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/15/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHOpenProductHuntAction: NSObject { 12 | 13 | class func performWithFAQLink() { 14 | PHOpenURLAction.perform(withPath: "https://www.producthunt.com/faq", closeAfterLaunch: true) 15 | } 16 | 17 | class func performWithAboutLink() { 18 | PHOpenURLAction.perform(withPath: "https://www.producthunt.com/about", closeAfterLaunch: true) 19 | } 20 | 21 | class func performWithAppsLink() { 22 | PHOpenURLAction.perform(withPath: "https://www.producthunt.com/apps", closeAfterLaunch: true) 23 | } 24 | } -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | inhibit_all_warnings! 3 | 4 | target 'ProductHunt' do 5 | pod 'Kingfisher', '~> 3.0' 6 | pod 'AFNetworking', '~> 3.0' 7 | pod 'ISO8601', '~> 0.5' 8 | pod 'DateTools' 9 | pod 'SwiftyTimer' 10 | pod 'Sparkle' 11 | pod 'ReSwift' 12 | 13 | target 'ProductHuntTests' do 14 | 15 | end 16 | 17 | end 18 | 19 | post_install do |installer| 20 | installer.pods_project.targets.each do |target| 21 | target.build_configurations.each do |config| 22 | config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" 23 | config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" 24 | config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" 25 | config.build_settings['SWIFT_VERSION'] = '3.0' 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /Source/Models/PHAnalitycsAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAnalitycsAPI.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ISO8601 11 | 12 | class PHAnalitycsAPI { 13 | 14 | var endpoint = PHAnalitycsAPIEndpoint(key: kSegmentKey) 15 | 16 | func track(_ properties: PHAnalitycsProperties) { 17 | endpoint.post("track", parameters: properties) 18 | } 19 | 20 | // Use `page` instead of `screen` 21 | func visit(_ properties: PHAnalitycsProperties) { 22 | endpoint.post("page", parameters: properties) 23 | } 24 | 25 | func identify(_ properties: PHAnalitycsProperties) { 26 | endpoint.post("identify", parameters: properties) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Source/PHMarkAsSeenOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHMarkAsSeenOperation.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/3/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | class PHMarkAsSeenOperation { 13 | 14 | class func perform(_ post: PHPost) { 15 | perform(store, posts: [post]) 16 | } 17 | 18 | class func perform(_ posts: [PHPost]) { 19 | perform(store, posts: posts) 20 | } 21 | 22 | class func perform(_ store: Store, posts: [PHPost]) { 23 | let filters: [PHPostFilter] = [.seen(false), .votes(store.state.settings.filterCount)] 24 | store.dispatch( PHMarkPostsAsSeenAction(posts: PHPostSorter.filter(store, posts: posts, by: filters) ) ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/PHTokenTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHTokenTests.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/14/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class PHTokenTests: PHTestCase { 12 | 13 | func testTokenFactoryRetunsNilIfInvalidAccessToken() { 14 | let data = ["access_token": NSNull()] 15 | XCTAssertNil(PHToken.token(fromDictionary: data)) 16 | } 17 | 18 | func testThatReturnsFalseIfTokenIsExpired() { 19 | let token = PHToken(accessToken: "") 20 | 21 | XCTAssertFalse(token.isValid) 22 | } 23 | 24 | func testThatReturnsTrueIfTokenIsExpired() { 25 | let token = PHToken(accessToken: "3asdsfgdsv" ) 26 | 27 | XCTAssertTrue(token.isValid) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Source/PHAppStatePosts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAppStatePosts.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/3/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | struct PHAppStatePosts { 13 | 14 | var sections: [PHSection] 15 | 16 | var today: Int { 17 | return 0 18 | } 19 | 20 | var nextDay: Int { 21 | guard let section = sections.last else { 22 | return 0 23 | } 24 | 25 | return PHDateFormatter.daysAgo(section.day) + 1 26 | } 27 | 28 | var todayPosts: [PHPost]? { 29 | guard let section = sections.first else { 30 | return nil 31 | } 32 | 33 | return section.posts 34 | } 35 | 36 | var lastUpdated: Date 37 | } 38 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/setup.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | import plistlib 3 | 4 | urlScheme = sys.argv[1] 5 | bundleIdentifier = sys.argv[2] 6 | 7 | directory = os.path.dirname(os.path.abspath(__file__)) 8 | 9 | stringsOutput = os.path.join(directory, 'LLStrings.h') 10 | infoPlistOutput = os.path.join(directory, 'LaunchAtLoginHelper/LaunchAtLoginHelper-Info.plist') 11 | infoPlist = plistlib.readPlist(os.path.join(directory, 'LaunchAtLoginHelper/LaunchAtLoginHelper-InfoBase.plist')) 12 | 13 | with open(stringsOutput, 'w') as strings: 14 | strings.write("""// strings used by LLManager and LaunchAtLoginHelper 15 | // 16 | 17 | #define LLURLScheme @"%(urlScheme)s" 18 | #define LLHelperBundleIdentifier @"%(bundleIdentifier)s" 19 | """%locals()) 20 | 21 | infoPlist['CFBundleIdentifier'] = bundleIdentifier 22 | plistlib.writePlist(infoPlist, infoPlistOutput) 23 | -------------------------------------------------------------------------------- /Source/Views/PHSectionCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHSectionCell.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/16/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHSectionCell: NSTableCellView { 12 | 13 | class func view(_ tableView: NSTableView, owner: AnyObject?, subject: AnyObject?) -> NSView? { 14 | guard let section = subject as? String else { 15 | return nil 16 | } 17 | 18 | let view = tableView.make(withIdentifier: "sectionCellIdentifier", owner: owner) as! PHSectionCell 19 | view.textField?.stringValue = section 20 | return view 21 | } 22 | 23 | override func awakeFromNib() { 24 | super.awakeFromNib() 25 | 26 | wantsLayer = true 27 | layer?.backgroundColor = NSColor.white.cgColor 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Source/Models/Credentials.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Credentials.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | open class Credentials { 12 | 13 | /** Returns this string encoded as Base64. */ 14 | static func base64(_ string: String) -> String { 15 | let utf8str = string.data(using: String.Encoding.utf8, allowLossyConversion: false)! 16 | return utf8str.base64EncodedString(options: NSData.Base64EncodingOptions.lineLength64Characters) 17 | } 18 | 19 | /** Returns an auth credential for the Basic scheme. Exposed for testing. */ 20 | static func basic(_ username: String, password: String) -> String { 21 | return String(format: "Basic %@", base64(String(format: "%@:%@", username, password))) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/Support/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 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Source/Actions/PHScheduleAsSeenAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHSheduleAsSeenAction.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/15/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHScheduleAsSeenAction { 12 | 13 | fileprivate class var delegate: AppDelegate { 14 | return NSApplication.shared().delegate as! AppDelegate 15 | } 16 | 17 | class func performSchedule() { 18 | performCancel() 19 | 20 | delegate.countdownToMarkAsSeen = Timer.after(30.seconds, { 21 | guard let posts = store.state.posts.todayPosts else { 22 | return 23 | } 24 | 25 | PHMarkAsSeenOperation.perform( posts ) 26 | }) 27 | } 28 | 29 | class func performCancel() { 30 | delegate.countdownToMarkAsSeen?.invalidate() 31 | delegate.countdownToMarkAsSeen = nil 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Source/PHMiddleware.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAPICallMiddleware.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/4/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | let PHTrackingMiddleware: Middleware = { dispatch, getState in 13 | return { next in 14 | return { action in 15 | 16 | if let action = action as? PHTrackPostAction { 17 | PHAnalitycs.sharedInstance.trackClickPost(action.post.id) 18 | } 19 | 20 | if let action = action as? PHTrackPostShare { 21 | PHAnalitycs.sharedInstance.trackShare("post", subjectId: action.post.id, medium: action.medium) 22 | } 23 | 24 | if let action = action as? PHTrackVisit { 25 | PHAnalitycs.sharedInstance.trackVisit(action.page) 26 | } 27 | 28 | return next(action) 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /Source/PHOpenPostOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHOpenPostOperation.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/4/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHOpenPostOperation { 12 | 13 | fileprivate class var openExternalLink: Bool { 14 | var openExternalLink = false 15 | 16 | if let event = NSApp.currentEvent, event.modifierFlags.contains(.command) && event.modifierFlags.contains(.option) { 17 | openExternalLink = true 18 | } 19 | 20 | return openExternalLink 21 | } 22 | 23 | class func perform(withPost post: PHPost) { 24 | let url = openExternalLink ? post.redirectUrl : post.discussionUrl 25 | 26 | PHOpenURLAction.perform(withUrl: url, closeAfterLaunch: true) 27 | 28 | PHMarkAsSeenOperation.perform(post) 29 | 30 | PHAnalitycsOperation.performTrack(post) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Source/Categories/NSColor+ProductHunt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSColor+ProductHunt.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/24/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | extension NSColor { 12 | 13 | class func ph_whiteColor() -> NSColor { 14 | return NSColor.white 15 | } 16 | 17 | class func ph_highlightColor() -> NSColor { 18 | return NSColor(calibratedRed: 249/255, green: 249/255, blue: 249/255, alpha: 1) 19 | } 20 | 21 | class func ph_orangeColor() -> NSColor { 22 | return NSColor(calibratedRed: 228/255, green: 81/255, blue: 39/255, alpha: 1) 23 | } 24 | 25 | class func ph_grayColor() -> NSColor { 26 | return NSColor(calibratedRed: 153/255, green: 153/255, blue: 153/255, alpha: 1) 27 | } 28 | 29 | class func ph_lightGrayColor() -> NSColor { 30 | return NSColor(calibratedRed: 204/255, green: 204/255, blue: 208/255, alpha: 1) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 David Keegan (http://davidkeegan.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Source/Actions/PHPopoverAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPopoverAction.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/28/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHPopoverAction { 12 | 13 | fileprivate class var appDelegate: AppDelegate { 14 | return NSApplication.shared().delegate as! AppDelegate 15 | } 16 | 17 | class func toggle() { 18 | if appDelegate.popover.isShown { 19 | close() 20 | } else { 21 | show() 22 | } 23 | } 24 | 25 | class func close() { 26 | if !appDelegate.popover.isShown { 27 | return 28 | } 29 | 30 | appDelegate.popover.close() 31 | } 32 | 33 | class func show() { 34 | NSRunningApplication.current().activate(options: NSApplicationActivationOptions.activateIgnoringOtherApps) 35 | 36 | guard let button = appDelegate.statusItem.button else { 37 | return 38 | } 39 | 40 | appDelegate.popover.show(relativeTo: button.frame, of: button, preferredEdge: .minY) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Product Hunt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Source/Models/ViewModels/PHPostViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostViewModel.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/18/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import ReSwift 10 | 11 | class PHPostViewModel { 12 | 13 | var isSeen: Bool { 14 | return store.state.seenPosts.isSeen(post) 15 | } 16 | 17 | var title: String { 18 | return post.title 19 | } 20 | 21 | var tagline: String { 22 | return post.tagline 23 | } 24 | 25 | var thumbnailUrl: URL { 26 | return post.thumbnailUrl as URL 27 | } 28 | 29 | var votesCount: String { 30 | return "\(post.votesCount)" 31 | } 32 | 33 | var commentsCount: String { 34 | return "\(post.commentsCount)" 35 | } 36 | 37 | var createdAt: String { 38 | return formatter.timeAgo(fromDateAsString: post.createdAt) 39 | } 40 | 41 | fileprivate var post: PHPost 42 | fileprivate var store: Store 43 | fileprivate var formatter = PHDateFormatter() 44 | 45 | init(withPost post: PHPost, store: Store) { 46 | self.post = post 47 | self.store = store 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Source/Actions/PHOpenSettingsMenuAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHShowSettingsMenuAction.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/17/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHOpenSettingsMenuAction { 12 | 13 | class func perform(_ sender: NSView) { 14 | let delegate = NSApplication.shared().delegate as! AppDelegate 15 | 16 | let menu = NSMenu() 17 | 18 | menu.addItem(NSMenuItem(title: "Apps", action: #selector(delegate.openAppsLink), keyEquivalent: "")) 19 | menu.addItem(NSMenuItem(title: "FAQ", action: #selector(delegate.openFAQLink), keyEquivalent: "")) 20 | menu.addItem(NSMenuItem(title: "About", action: #selector(delegate.openAboutLink), keyEquivalent: "")) 21 | menu.addItem(NSMenuItem.separator()) 22 | menu.addItem(NSMenuItem(title: "Check for Updates", action: #selector(delegate.checkForUpdates), keyEquivalent: "u")) 23 | menu.addItem(NSMenuItem(title: "Settings", action: #selector(delegate.showSettings), keyEquivalent: "s")) 24 | menu.addItem(NSMenuItem.separator()) 25 | menu.addItem(NSMenuItem(title: "Quit", action: #selector(delegate.quit), keyEquivalent: "q")) 26 | 27 | NSMenu.popUpContextMenu(menu, with: NSApp.currentEvent!, for: sender) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper/LaunchAtLoginHelper-InfoBase.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | ${EXECUTABLE_NAME} 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | com.InScopeApps.ShellTo.${PRODUCT_NAME:rfc1034identifier} 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | ${PRODUCT_NAME} 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | 1 25 | LSMinimumSystemVersion 26 | ${MACOSX_DEPLOYMENT_TARGET} 27 | LSUIElement 28 | 29 | NSHumanReadableCopyright 30 | Copyright © 2012 David Keegan. All rights reserved. 31 | NSMainNibFile 32 | MainMenu 33 | NSPrincipalClass 34 | NSApplication 35 | 36 | 37 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper/LaunchAtLoginHelper-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSBackgroundOnly 6 | 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | ${EXECUTABLE_NAME} 11 | CFBundleIconFile 12 | 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | ${PRODUCT_NAME} 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 1.0 23 | CFBundleSignature 24 | ???? 25 | CFBundleVersion 26 | 1 27 | LSMinimumSystemVersion 28 | ${MACOSX_DEPLOYMENT_TARGET} 29 | LSUIElement 30 | 31 | NSHumanReadableCopyright 32 | Copyright © 2012 David Keegan. All rights reserved. 33 | NSMainNibFile 34 | MainMenu 35 | NSPrincipalClass 36 | NSApplication 37 | 38 | 39 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper/LLHAppDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // LLHAppDelegate.m 3 | // LaunchAtLoginHelper 4 | // 5 | // Created by David Keegan on 4/20/12. 6 | // Copyright (c) 2012 David Keegan. 7 | // Some rights reserved: 8 | // 9 | 10 | #import "LLHAppDelegate.h" 11 | 12 | NSString * const LLURLScheme = @"producthunt"; 13 | 14 | @implementation LLHAppDelegate 15 | 16 | - (void)applicationDidFinishLaunching:(NSNotification *)notification { 17 | 18 | // The scheme to launch the app 19 | NSString *scheme = [NSString stringWithFormat:@"%@://", LLURLScheme]; 20 | NSURL *schemeURL = [NSURL URLWithString:scheme]; 21 | 22 | // Get URL for app that responds to scheme 23 | NSURL *appURL = [[NSWorkspace sharedWorkspace] URLForApplicationToOpenURL:schemeURL]; 24 | 25 | // Check if app exists 26 | if(appURL) { 27 | // App exists, run it 28 | [[NSWorkspace sharedWorkspace] openURL:schemeURL]; 29 | 30 | // Call the app again this time with `launchedAtLogin` so it knows how it was launched 31 | NSString *schemeLaunchedAtLogin = [NSString stringWithFormat:@"%@://launchedAtLogin", LLURLScheme]; 32 | [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:schemeLaunchedAtLogin]]; 33 | } 34 | 35 | [NSApp terminate:self]; 36 | } 37 | 38 | @end 39 | -------------------------------------------------------------------------------- /Source/Models/PHStatusBarUpdater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHStatusBarUpdater.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/30/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import ReSwift 11 | 12 | class PHStatusBarUpdater: StoreSubscriber { 13 | 14 | fileprivate var button: NSStatusBarButton? 15 | fileprivate var store: Store 16 | 17 | init(button: NSStatusBarButton?, store: Store) { 18 | self.button = button 19 | self.store = store 20 | 21 | store.subscribe(self) 22 | } 23 | 24 | deinit { 25 | store.unsubscribe(self) 26 | } 27 | 28 | func newState(state: PHAppState) { 29 | updateTitle() 30 | } 31 | 32 | fileprivate func updateTitle() { 33 | guard let button = button, let posts = store.state.posts.todayPosts else { 34 | return 35 | } 36 | 37 | let sortedPosts = PHPostSorter.filter(store, posts: posts, by: [.seen(false), .votes(store.state.settings.filterCount)]) 38 | 39 | button.title = title(fromCount: sortedPosts.count) 40 | } 41 | 42 | fileprivate func title(fromCount count: Int) -> String { 43 | if !store.state.settings.showsCount { 44 | return "" 45 | } 46 | 47 | return count > 9 ? "9+" : (count == 0 ? "" : "\(count)") 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - AFNetworking (3.1.0): 3 | - AFNetworking/NSURLSession (= 3.1.0) 4 | - AFNetworking/Reachability (= 3.1.0) 5 | - AFNetworking/Security (= 3.1.0) 6 | - AFNetworking/Serialization (= 3.1.0) 7 | - AFNetworking/UIKit (= 3.1.0) 8 | - AFNetworking/NSURLSession (3.1.0): 9 | - AFNetworking/Reachability 10 | - AFNetworking/Security 11 | - AFNetworking/Serialization 12 | - AFNetworking/Reachability (3.1.0) 13 | - AFNetworking/Security (3.1.0) 14 | - AFNetworking/Serialization (3.1.0) 15 | - DateTools (1.7.0) 16 | - ISO8601 (0.6.0) 17 | - Kingfisher (3.2.2) 18 | - ReSwift (3.0.0) 19 | - Sparkle (1.14.0) 20 | - SwiftyTimer (2.0.0) 21 | 22 | DEPENDENCIES: 23 | - AFNetworking (~> 3.0) 24 | - DateTools 25 | - ISO8601 (~> 0.5) 26 | - Kingfisher (~> 3.0) 27 | - ReSwift 28 | - Sparkle 29 | - SwiftyTimer 30 | 31 | SPEC CHECKSUMS: 32 | AFNetworking: 5e0e199f73d8626b11e79750991f5d173d1f8b67 33 | DateTools: 53288ee8b905fdc75897a1e6b5cc0144b14cba60 34 | ISO8601: d3ea3ba9b752820cf92c6b47a9ee327e9f0e13fc 35 | Kingfisher: 821466474ad7ffc375a4aecadfa05068cfed67b3 36 | ReSwift: f0255f4b4f2dacd12c5d97d34d13fc5dad6d6371 37 | Sparkle: ccd95233b12a3e3d4eeb55ff01dd4c8bb8188b07 38 | SwiftyTimer: 2efd74b060d69ad4f1496baf5bbedbe132125fcf 39 | 40 | PODFILE CHECKSUM: 4a2d06f24ccf54d50b6032a7d9b83025c887d7ad 41 | 42 | COCOAPODS: 1.1.1 43 | -------------------------------------------------------------------------------- /Source/Models/PHPostSorter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostSorter.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/19/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | enum PHPostFilter { 13 | case seen(Bool), votes(Int), sortByVotes, none 14 | } 15 | 16 | class PHPostSorter { 17 | 18 | class func filter(_ store: Store, posts:[PHPost], by:[PHPostFilter]) -> [PHPost] { 19 | return by.reduce(posts, { (posts, filter) -> [PHPost] in 20 | switch(filter) { 21 | 22 | case .seen(let seen): 23 | let seenIds = store.state.seenPosts.postIds 24 | return posts.filter { (seen ? seenIds.contains($0.id) : !seenIds.contains($0.id) ) } 25 | 26 | case .votes(let votesCount): 27 | return posts.filter { $0.votesCount >= votesCount } 28 | 29 | case .sortByVotes: 30 | return posts.sorted(by: { $0.votesCount > $1.votesCount }) 31 | 32 | case .none: 33 | return posts 34 | } 35 | }) 36 | } 37 | 38 | class func sort(_ store: Store ,posts: [PHPost], votes: Int) -> [PHPost] { 39 | return filter(store, posts: posts, by: [.seen(false), .votes(votes), .sortByVotes]) + filter(store, posts: posts, by: [.seen(true), .sortByVotes]) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Source/PHSeenPostsModule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHSeenPostsModule.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/3/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | struct PHSeenPosts { 13 | var date: Date 14 | var postIds: Set 15 | 16 | func isSeen(_ post: PHPost) -> Bool { 17 | if PHDateFormatter.daysAgo(post.day) > 0 { 18 | return true 19 | } 20 | 21 | return postIds.contains(post.id) 22 | } 23 | } 24 | 25 | struct PHSeenPostsSetAction: Action { 26 | var seenPost: PHSeenPosts 27 | } 28 | 29 | struct PHMarkPostsAsSeenAction: Action { 30 | var posts: [PHPost] 31 | } 32 | 33 | func seenPostsReducer(_ action: Action, state: PHSeenPosts?) -> PHSeenPosts { 34 | let state = state ?? PHSeenPosts(date: Date(), postIds: Set()) 35 | 36 | switch action { 37 | case let action as PHSeenPostsSetAction: 38 | return action.seenPost 39 | 40 | case let action as PHMarkPostsAsSeenAction: 41 | let date = (state.date as NSDate).isToday() ? state.date : Date() 42 | let postIds = (state.date as NSDate).isToday() ? state.postIds : Set() 43 | 44 | let ids = action.posts.map{ $0.id } 45 | 46 | return PHSeenPosts(date: date, postIds: postIds.union(ids)) 47 | 48 | default: 49 | return state 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Source/PHPostsModule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostsModule.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/28/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | struct PHPostsLoadAction: Action { 13 | var posts: [PHPost] 14 | } 15 | 16 | func postsReducer(_ action: Action, state: PHAppStatePosts?) -> PHAppStatePosts { 17 | let state = state ?? PHAppStatePosts(sections: [], lastUpdated: Date()) 18 | 19 | switch action { 20 | case let action as PHPostsLoadAction: 21 | if action.posts.isEmpty { 22 | return state 23 | } 24 | 25 | let section = PHSection.section(action.posts) 26 | 27 | var newSections = state.sections 28 | var newLastUpdated = state.lastUpdated 29 | 30 | if PHDateFormatter.daysAgo(section.day) == 0 { 31 | if let firstSection = newSections.first, firstSection.day == section.day { 32 | newSections[0] = section 33 | } else { 34 | newSections.insert(section, at: 0) 35 | } 36 | 37 | newLastUpdated = Date() 38 | } else { 39 | newSections.append(PHSection.section(action.posts)) 40 | } 41 | 42 | return PHAppStatePosts(sections: newSections, lastUpdated: newLastUpdated) 43 | 44 | default: 45 | return state 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Source/PHSettingsModule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHSettingsReducer.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/27/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import ReSwift 10 | 11 | struct PHSettings { 12 | var autologinEnabled: Bool 13 | var showsCount: Bool 14 | var filterCount: Int 15 | } 16 | 17 | struct PHSettingsSetAction: Action { 18 | var settings: PHSettings 19 | } 20 | 21 | struct PHSettingsActionAutoLogin: Action { 22 | var autologin: Bool 23 | } 24 | 25 | struct PHSettingsActionShowsCount: Action { 26 | var showsCount: Bool 27 | } 28 | 29 | struct PHSettngsActionFilterCount: Action { 30 | var filterCount: Int 31 | } 32 | 33 | func settingsReducer(_ action: Action, state: PHSettings?) -> PHSettings { 34 | var state = state ?? PHSettings(autologinEnabled: true, showsCount: true, filterCount: 10) 35 | 36 | switch action { 37 | 38 | case let action as PHSettingsSetAction: 39 | return action.settings 40 | 41 | case let action as PHSettingsActionAutoLogin: 42 | state.autologinEnabled = action.autologin 43 | 44 | return state 45 | 46 | case let action as PHSettingsActionShowsCount: 47 | state.showsCount = action.showsCount 48 | 49 | return state 50 | 51 | case let action as PHSettngsActionFilterCount: 52 | state.filterCount = action.filterCount 53 | 54 | return state 55 | 56 | default: 57 | return state 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Source/PHAPIOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/4/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | class PHAPIOperation { 13 | 14 | class func perform(_ store: Store, api: PHAPI, operation: @escaping PHAPIOperationClosure) { 15 | withToken(store, api: api) { (token, error) in 16 | operation(api, { (error) in 17 | if NSError.parseError(error) == NSError.unauthorizedError() { 18 | let token = PHToken(accessToken: "") 19 | store.dispatch( PHTokenGetAction(token: token) ) 20 | } 21 | 22 | withToken(store, api: api, callback: { (token, error) in 23 | operation(api, { (error) in 24 | //TODO : Dispatch errror 25 | }) 26 | }) 27 | }) 28 | } 29 | } 30 | 31 | fileprivate class func withToken(_ store: Store, api: PHAPI, callback: @escaping PHAPITokenCompletion) { 32 | if store.state.token.isValid { 33 | callback(store.state.token, nil) 34 | return 35 | } 36 | 37 | api.getToken { (token, error) in 38 | if let token = token { 39 | // TODO: Other mechanism to update token 40 | 41 | api.endpoint = PHAPIEndpoint(token: token) 42 | store.dispatch( PHTokenGetAction(token: token) ) 43 | } 44 | 45 | callback(token, error) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Support/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "icon-16x16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "icon-16x16@2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "icon-32x32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "icon-32x32@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "icon-128x128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "icon-128x128@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "icon-256x256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "icon-256x256@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "icon-512x512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "icon-512x512@2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /Source/PHLoadPostOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHLoadPostOperation.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/3/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | class PHLoadPostOperation { 13 | 14 | class func performNewer(_ store: Store? = store) { 15 | guard let store = store else { 16 | return 17 | } 18 | 19 | perform(store, api: PHAPI.sharedInstance, daysAgo: store.state.posts.today) 20 | } 21 | 22 | class func performOlder(_ store: Store? = store) { 23 | guard let store = store else { 24 | return 25 | } 26 | 27 | perform(store, api: PHAPI.sharedInstance, daysAgo: store.state.posts.nextDay) 28 | } 29 | 30 | class func perform(_ app: Store, api: PHAPI, daysAgo: Int) { 31 | if api.isThereOngoingRequest { 32 | return 33 | } 34 | 35 | let operation = createOperation(daysAgo) { (posts) in 36 | app.dispatch( PHPostsLoadAction(posts: posts) ) 37 | } 38 | 39 | PHAPIOperation.perform(app, api: api, operation: operation) 40 | } 41 | 42 | class func createOperation(_ daysAgo: Int, complete: @escaping ([PHPost]) -> ()) -> PHAPIOperationClosure { 43 | return { (_ api: PHAPI,_ errorClosure: @escaping PHAPIErrorClosure) in 44 | api.getPosts(daysAgo, completion: { (posts, error) in 45 | if let error = error { 46 | errorClosure(error) 47 | return 48 | } 49 | 50 | complete(posts) 51 | }) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Source/Models/PHBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHHardware.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/28/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHBundle { 12 | 13 | static var shortVersion: String? { 14 | return getValueFromInfoDictionary(for: "CFBundleShortVersionString", subjectType: String()) 15 | } 16 | 17 | static var version: String? { 18 | return getValueFromInfoDictionary(for: "CFBundleVersion", subjectType: String()) 19 | } 20 | 21 | // Referenced from https://developer.apple.com/library/mac/technotes/tn1103/_index.html 22 | class func systemUUID() -> String { 23 | let dev = IOServiceMatching("IOPlatformExpertDevice") 24 | 25 | let platformExpert: io_service_t = IOServiceGetMatchingService(kIOMasterPortDefault, dev) 26 | 27 | let serialNumberAsCFString = IORegistryEntryCreateCFProperty(platformExpert, kIOPlatformUUIDKey as CFString!, kCFAllocatorDefault, 0) 28 | 29 | IOObjectRelease(platformExpert) 30 | 31 | let serialNumber: CFTypeRef = serialNumberAsCFString!.takeUnretainedValue() 32 | 33 | return serialNumber as? String ?? "" 34 | } 35 | 36 | class func ipAddress() -> String { 37 | let address = Host.current().addresses.filter{ $0.contains(".") && ($0 != "127.0.0.1") } 38 | return address.isEmpty ? "" : address.first! 39 | } 40 | 41 | fileprivate class func getValueFromInfoDictionary(for key: String, subjectType: T) -> T? { 42 | guard let infoDictionary = Bundle.main.infoDictionary else { 43 | return nil 44 | } 45 | 46 | return infoDictionary[key] as? T 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Source/Controllers/PHAdvancedSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHUpdateSettingsViewController.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/18/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Sparkle 11 | import DateTools 12 | 13 | class PHAdvancedSettingsViewController: NSViewController, PHPreferencesWindowControllerProtocol { 14 | 15 | @IBOutlet weak var automaticallyCheckForUpdatesButton: NSButton! 16 | @IBOutlet weak var automaticallyDownloadUpdatesButton: NSButton! 17 | 18 | fileprivate let formatter = PHDateFormatter() 19 | fileprivate var updater: SUUpdater { 20 | return SUUpdater.shared() 21 | } 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | automaticallyCheckForUpdatesButton.setState(forBool: updater.automaticallyChecksForUpdates) 27 | automaticallyDownloadUpdatesButton.setState(forBool: updater.automaticallyDownloadsUpdates) 28 | } 29 | 30 | // MARK: Actions 31 | 32 | @IBAction func automaticallyCheckForUpdatesAction(_ sender: NSButton) { 33 | updater.automaticallyChecksForUpdates = sender.boolState 34 | } 35 | 36 | @IBAction func automaticallyDownloadUpdatesAction(_ sender: NSButton) { 37 | updater.automaticallyDownloadsUpdates = sender.boolState 38 | } 39 | 40 | // MARK: PHPreferencesWindowControllerProtocol 41 | 42 | func preferencesIdentifier() -> String { 43 | return "PHAdvancedSettingsViewController" 44 | } 45 | 46 | func preferencesTitle() -> String { 47 | return "Advanced" 48 | } 49 | 50 | func preferencesIcon() -> NSImage { 51 | return NSImage(named: NSImageNameAdvanced)! 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Support/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | Product Hunt 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIconFile 12 | 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 1.0.3 23 | CFBundleSignature 24 | ???? 25 | CFBundleURLTypes 26 | 27 | 28 | CFBundleTypeRole 29 | Viewer 30 | CFBundleURLName 31 | com.producthunt.producthuntosx 32 | CFBundleURLSchemes 33 | 34 | producthunt 35 | 36 | 37 | 38 | CFBundleVersion 39 | 1.0.3 40 | LSApplicationCategoryType 41 | public.app-category.social-networking 42 | LSMinimumSystemVersion 43 | $(MACOSX_DEPLOYMENT_TARGET) 44 | LSUIElement 45 | 46 | NSHumanReadableCopyright 47 | Copyright © 2016 ProductHunt. All rights reserved. 48 | NSMainNibFile 49 | MainMenu 50 | NSPrincipalClass 51 | NSApplication 52 | 53 | 54 | -------------------------------------------------------------------------------- /Source/Models/Domain Objects/PHPost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPost.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/15/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHPost { 12 | 13 | var id: Int 14 | var title: String 15 | var tagline: String 16 | var thumbnailUrl: URL 17 | var discussionUrl: URL 18 | var day: String 19 | var votesCount: Int 20 | var commentsCount: Int 21 | var createdAt: String 22 | var redirectUrl: URL 23 | 24 | init(id: Int, title: String, tagline: String, thumbnailUrl: URL, discussionUrl: URL, day: String, votesCount: Int, commentsCount: Int, createdAt: String, redirectUrl: URL) { 25 | self.id = id 26 | self.title = title 27 | self.tagline = tagline 28 | self.thumbnailUrl = thumbnailUrl 29 | self.discussionUrl = discussionUrl 30 | self.day = day 31 | self.votesCount = votesCount 32 | self.commentsCount = commentsCount 33 | self.createdAt = createdAt 34 | self.redirectUrl = redirectUrl 35 | } 36 | 37 | func description() -> [String: Any] { 38 | return [ 39 | "id" : id, 40 | "name" : title, 41 | "tagline" : tagline, 42 | "day" : day, 43 | "discussion_url" : discussionUrl.absoluteString, 44 | "thumbnail" : ["image_url" : thumbnailUrl.absoluteString], 45 | "votes_count" : votesCount, 46 | "comments_count" : commentsCount, 47 | "created_at" : createdAt, 48 | "redirect_url" : redirectUrl.absoluteString 49 | ] 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Source/Models/Domain Objects/PHPostFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostFactory.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/15/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension PHPost { 12 | 13 | class func post(fromDictionary dictionary: [String: Any]) -> PHPost? { 14 | guard 15 | let id = dictionary["id"] as? Int, 16 | let title = dictionary["name"] as? String, 17 | let tagline = dictionary["tagline"] as? String, 18 | let day = dictionary["day"] as? String, 19 | let discussionPath = dictionary["discussion_url"] as? String, 20 | let thumbnail = dictionary["thumbnail"] as? [String: AnyObject], 21 | let thumbnailPath = thumbnail["image_url"] as? String, 22 | let votesCount = dictionary["votes_count"] as? Int, 23 | let commentsCount = dictionary["comments_count"] as? Int, 24 | let createdAt = dictionary["created_at"] as? String, 25 | let redirectUrl = dictionary["redirect_url"] as? String 26 | else { 27 | return nil 28 | } 29 | 30 | let thumbnailURL = URL(string: thumbnailPath)! 31 | let discussionURL = URL(string: discussionPath)! 32 | let redirectURL = URL(string: redirectUrl)! 33 | 34 | return PHPost(id: id,title: title, tagline: tagline, thumbnailUrl: thumbnailURL, discussionUrl: discussionURL, day: day, votesCount: votesCount, commentsCount: commentsCount, createdAt: createdAt, redirectUrl: redirectURL) 35 | } 36 | 37 | class func posts(fromArray array: [[String: Any]]) -> [PHPost] { 38 | return array.flatMap{ PHPost.post(fromDictionary: $0)! } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/Support/PHFakeFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHFakeFactory.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/31/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHFakeFactory { 12 | 13 | static let sharedInstance = PHFakeFactory() 14 | 15 | fileprivate var fakePostId = 0 16 | 17 | func token() -> PHToken { 18 | return PHToken(accessToken: "FakeToken") 19 | } 20 | 21 | func post(_ daysAgo: TimeInterval = 0.days, votes: Int = 20, commentsCount: Int = 0) -> PHPost { 22 | let id = fakePostId 23 | let title = "Title \(fakePostId)" 24 | let tagline = "Tagline \(fakePostId)" 25 | let url = URL(string: "https//example.com/\(fakePostId)")! 26 | let day = daysAgoAsString(daysAgo) 27 | let redirectUrl = URL(string: "https//example.com/\(fakePostId)")! 28 | 29 | defer { 30 | fakePostId += 1 31 | } 32 | 33 | return PHPost(id: id,title: title, tagline: tagline, thumbnailUrl: url, discussionUrl: url, day: day, votesCount: votes, commentsCount: commentsCount, createdAt: createdAt(), redirectUrl: redirectUrl) 34 | } 35 | 36 | fileprivate func daysAgoAsString(_ days: TimeInterval) -> String { 37 | let date = Date(timeIntervalSinceNow: -days) 38 | 39 | let dateformatter = DateFormatter() 40 | dateformatter.dateFormat = "yyyy-MM-dd" 41 | 42 | return dateformatter.string(from: date) 43 | } 44 | 45 | fileprivate func createdAt() -> String { 46 | let formatter = DateFormatter() 47 | 48 | formatter.timeZone = TimeZone(abbreviation: "PSD") 49 | 50 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 51 | 52 | return formatter.string(from: Date()) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Product Hunt for Mac 2 | 3 | > This is the official [Product Hunt](http://www.producthunt.com) for Mac. 4 | 5 | ![producthunt-osx-app](https://cloud.githubusercontent.com/assets/2778007/14168594/d522c7fe-f72b-11e5-80f6-b21d9a3f3ecd.jpg) 6 | 7 | Product Hunt is the place to discover your next favorite thing, surfacing the latest in tech, books, games, and podcasts. 8 | 9 | Also check out our iOS app and Chrome extension at [http://producthunt.com/apps](http://producthunt.com/apps). 10 | 11 | ## Download 12 | 13 | You can also download Product Hunt for Mac [here](https://s3.amazonaws.com/producthunt/mac/ProductHunt.dmg) 14 | 15 | ## Development 16 | 17 | * Clone the repository: 18 | 19 | ``` 20 | $ git clone git@github.com:producthunt/producthunt-osx.git 21 | $ cd producthunt-osx 22 | ``` 23 | 24 | * Copy configuration templates: 25 | 26 | ``` 27 | cp Source/Config/Keys-Example.xcconfig Source/Config/Keys.xcconfig 28 | ``` 29 | 30 | API Keys generated from https://www.producthunt.com/v1/oauth/applications 31 | 32 | * Install CocoaPods Dependencies 33 | 34 | ``` 35 | $ pod install 36 | ``` 37 | 38 | * Open `Product\ Hunt.xcworkspace` 39 | 40 | ## Contributing 41 | 42 | 1. Fork it 43 | 2. Create your feature branch (`git checkout -b my-new-feature`) 44 | 3. Commit your changes (`git commit -am 'Add some feature'`) 45 | 4. Push to the branch (`git push origin my-new-feature`) 46 | 5. Run the tests 47 | 6. Create new Pull Request 48 | 49 | ## License 50 | 51 | [![Product Hunt](http://i.imgur.com/dtAr7wC.png)](https://www.producthunt.com) 52 | 53 | ``` 54 | _________________ 55 | < The MIT License > 56 | ----------------- 57 | \ ^__^ 58 | \ (oo)\_______ 59 | (__)\ )\/\ 60 | ||----w | 61 | || || 62 | ``` 63 | 64 | **[MIT License](https://github.com/producthunt/PHImageKit/blob/master/LICENSE)** 65 | -------------------------------------------------------------------------------- /Source/Models/PHAnalitycsAPIEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAnalitycsAPIEndpoint.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AFNetworking 11 | 12 | class PHAnalitycsAPIEndpoint { 13 | 14 | static let kPHAnalitycsEndpointHost = "https://api.segment.io" 15 | 16 | fileprivate let manager = AFHTTPSessionManager(baseURL: URL(string: "\(kPHAnalitycsEndpointHost)/v1")) 17 | 18 | init(key: String) { 19 | manager.responseSerializer = AFJSONResponseSerializer() 20 | manager.requestSerializer = AFJSONRequestSerializer() 21 | 22 | manager.requestSerializer.setValue("application/json", forHTTPHeaderField: "Accept") 23 | manager.requestSerializer.setValue("application/json", forHTTPHeaderField: "Content-Type") 24 | manager.requestSerializer.setValue("gzip", forHTTPHeaderField: "Accept-Encoding") 25 | manager.requestSerializer.setValue(Credentials.basic(key, password: ""), forHTTPHeaderField: "Authorization") 26 | } 27 | 28 | func get(_ url: String, parameters: [String: Any]?, completion: PHAPIEndpointCompletion? = nil) { 29 | manager.get(url, parameters: parameters, progress: nil, success: { (task, response) in 30 | completion?((response as? [String: AnyObject]), nil) 31 | }) { (task, error) in 32 | print(error) 33 | completion?(nil, error as NSError?) 34 | } 35 | } 36 | 37 | func post(_ url: String, parameters: [String: Any]?, completion: PHAPIEndpointCompletion? = nil) { 38 | manager.post(url, parameters: parameters, progress: nil, success: { (task, response) in 39 | completion?((response as? [String: AnyObject]), nil) 40 | }) { (task, error) in 41 | print(error) 42 | completion?(nil, error as NSError?) 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Source/Models/API/PHAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAPI.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/14/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PHAPI { 12 | 13 | static let sharedInstance = PHAPI() 14 | 15 | var endpoint = PHAPIEndpoint(token: store.state.token) 16 | 17 | fileprivate(set) var isThereOngoingRequest = false 18 | 19 | func getToken(_ completion: @escaping PHAPITokenCompletion) { 20 | let params = ["client_id" : kPHAppID, "client_secret": kPHAppSecret, "grant_type": "client_credentials"] 21 | 22 | endpoint.post("oauth/token", parameters: params) { (response, error) -> Void in 23 | completion(PHToken.token(fromDictionary: response), error) 24 | } 25 | } 26 | 27 | func getPosts(_ daysAgo: Int, completion: @escaping PHAPIPostCompletion) { 28 | getPostsPosted(daysAgo, retries: 20, completion: completion) 29 | } 30 | 31 | fileprivate func getPostsPosted(_ daysAgo: Int, retries: Int, completion: @escaping PHAPIPostCompletion) { 32 | if retries == 0 { 33 | isThereOngoingRequest = false 34 | completion([], NSError.unauthorizedError()) 35 | return 36 | } 37 | 38 | isThereOngoingRequest = true 39 | 40 | self.endpoint.get("posts", parameters: ["days_ago": daysAgo, "search[category]": "all"]) { (response, error) -> Void in 41 | self.isThereOngoingRequest = false 42 | 43 | if let response = response, let rawPosts = response["posts"] as? [[String : AnyObject]] { 44 | completion(PHPost.posts(fromArray: rawPosts), nil) 45 | } else if let error = error { 46 | completion([PHPost](), error) 47 | } else { 48 | self.getPostsPosted(daysAgo + 1, retries: retries - 1, completion: completion) 49 | } 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /Source/Models/PHPostsDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostsDataSource.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/15/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ReSwift 11 | 12 | protocol PHDataSourceDelegate { 13 | func contentChanged() 14 | } 15 | 16 | class PHPostsDataSource: StoreSubscriber { 17 | 18 | var delegate: PHDataSourceDelegate? 19 | 20 | fileprivate var store: Store 21 | fileprivate var content = [AnyObject]() 22 | 23 | init(store: Store) { 24 | self.store = store 25 | 26 | store.subscribe(self) 27 | } 28 | 29 | deinit { 30 | store.unsubscribe(self) 31 | } 32 | 33 | func newState(state: PHAppState) { 34 | content = flatContent(from: state.posts.sections) 35 | delegate?.contentChanged() 36 | } 37 | 38 | func numberOfRows() -> Int { 39 | return content.count 40 | } 41 | 42 | func data(atIndex index: Int) -> AnyObject? { 43 | return content[index] 44 | } 45 | 46 | func isGroup(atIndex index: Int) -> Bool { 47 | return (content[index] as? String) != nil 48 | } 49 | 50 | func loadNewer() { 51 | PHLoadPostOperation.performNewer(store) 52 | } 53 | 54 | func loadOlder() { 55 | PHLoadPostOperation.performOlder(store) 56 | } 57 | 58 | fileprivate func flatContent(from content: [PHSection]) -> [AnyObject] { 59 | let formatter = PHDateFormatter() 60 | var output = [AnyObject]() 61 | 62 | content.forEach { (section) in 63 | output.append(formatter.format(withDateString: section.day) as AnyObject? ?? "" as AnyObject) 64 | 65 | PHPostSorter.sort(store, posts: section.posts, votes: store.state.settings.filterCount).forEach({ (post) in 66 | output.append(post) 67 | }) 68 | } 69 | 70 | return output 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Source/Models/API/PHAPIEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAPIEndpoint.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/14/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import AFNetworking 11 | 12 | typealias PHAPIEndpointCompletion = (_ response: [String: Any]?, _ error: NSError?) -> Void 13 | 14 | class PHAPIEndpoint { 15 | 16 | static let kPHEndpointHost = "https://api.producthunt.com" 17 | 18 | fileprivate let manager = AFHTTPSessionManager(baseURL: URL(string: "\(kPHEndpointHost)/v1")) 19 | 20 | init(token: PHToken?) { 21 | manager.responseSerializer = AFJSONResponseSerializer() 22 | manager.requestSerializer = AFJSONRequestSerializer() 23 | 24 | manager.requestSerializer.setValue("application/json", forHTTPHeaderField: "Accept") 25 | manager.requestSerializer.setValue("application/json", forHTTPHeaderField: "Content-Type") 26 | 27 | if let token = token { 28 | manager.requestSerializer.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") 29 | } 30 | } 31 | 32 | func get(_ url: String, parameters: [String: Any]?, completion: @escaping PHAPIEndpointCompletion) { 33 | manager.get(url, parameters: parameters, progress: nil, success: { (task, response) in 34 | completion((response as? [String: AnyObject]), nil) 35 | }) { (task, error) in 36 | print(error) 37 | completion(nil, error as NSError?) 38 | } 39 | } 40 | 41 | func post(_ url: String, parameters: [String: Any]?, completion: @escaping PHAPIEndpointCompletion) { 42 | manager.post(url, parameters: parameters, progress: nil, success: { (task, response) in 43 | completion((response as? [String: AnyObject]), nil) 44 | }) { (task, error) in 45 | print(error) 46 | completion(nil, error as NSError?) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Source/Views/PHLoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHLoadingView.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/17/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | enum LoadingState { 12 | case loading, error, empty, idle 13 | } 14 | 15 | class PHLoadingView: NSView { 16 | 17 | @IBOutlet weak var loadingIndicator: NSProgressIndicator! 18 | @IBOutlet weak var loadingLabel: NSTextField! 19 | @IBOutlet weak var reload: PHButton! 20 | 21 | var state: LoadingState = .idle { 22 | didSet { 23 | switch state { 24 | case .loading: 25 | isHidden = false 26 | reload.isHidden = true 27 | loadingIndicator.isHidden = false 28 | loadingIndicator.startAnimation(nil) 29 | loadingLabel.stringValue = "Hunting down new posts..." 30 | 31 | case .error: 32 | isHidden = false 33 | reload.isHidden = false 34 | loadingIndicator.isHidden = true 35 | loadingIndicator.stopAnimation(nil) 36 | loadingLabel.stringValue = "Something went wrong 😿" 37 | 38 | case .empty: 39 | isHidden = false 40 | reload.isHidden = false 41 | loadingIndicator.isHidden = true 42 | loadingIndicator.stopAnimation(nil) 43 | loadingLabel.stringValue = "Nothing to show 😿" 44 | 45 | case .idle: 46 | isHidden = true 47 | loadingIndicator.stopAnimation(nil) 48 | } 49 | } 50 | } 51 | 52 | override func awakeFromNib() { 53 | super.awakeFromNib() 54 | 55 | wantsLayer = true 56 | layer?.backgroundColor = NSColor.white.cgColor 57 | } 58 | 59 | @IBAction func toggleReloadButton(_ sender: NSView) { 60 | state = .loading 61 | PHLoadPostOperation.performNewer() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Source/Actions/PHShareAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHShareAction.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 4/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Kingfisher 11 | import ReSwift 12 | 13 | class PHShareAction: NSObject, NSSharingServiceDelegate { 14 | 15 | static let sharedInstance = PHShareAction() 16 | 17 | func performTwitter(_ post: PHPost?) { 18 | guard let post = post else { 19 | return 20 | } 21 | 22 | store.dispatch( PHTrackPostShare(post: post, medium: "twitter") ) 23 | 24 | KingfisherManager.shared.retrieveImage(with: post.thumbnailUrl, options: nil, progressBlock: nil, completionHandler: { (image, error, cacheType, imageURL) in 25 | self.perform(NSSharingServiceNamePostOnTwitter, title: PHShareMessage.message(fromPost: post), url: post.discussionUrl, image: image) 26 | }) 27 | } 28 | 29 | func performFacebook(_ post: PHPost?) { 30 | guard let post = post else { 31 | return 32 | } 33 | 34 | store.dispatch( PHTrackPostShare(post: post, medium: "facebook") ) 35 | 36 | KingfisherManager.shared.retrieveImage(with: post.thumbnailUrl, options: nil, progressBlock: nil, completionHandler: { (image, error, cacheType, imageURL) in 37 | self.perform(NSSharingServiceNamePostOnFacebook, title: PHShareMessage.message(fromPost: post), url: post.discussionUrl, image: image) 38 | }) 39 | } 40 | 41 | fileprivate func perform(_ service: String, title: String, url: URL, image: NSImage?) { 42 | if let service = NSSharingService(named: service) { 43 | service.delegate = self 44 | 45 | var items = [AnyObject]() 46 | 47 | items.append(title as AnyObject) 48 | 49 | if let image = image { 50 | items.append(image) 51 | } 52 | 53 | service.perform(withItems: items) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/PHSeenPostsModuleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHSeenPostsModuleTests.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/4/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ReSwift 11 | 12 | class PHSeenPostsModuleTests: PHTestCase { 13 | 14 | struct BootStrapAction: Action {} 15 | 16 | func testThatReturnsState() { 17 | let reducer = seenPostsReducer(BootStrapAction(), state: nil) 18 | 19 | XCTAssertNotNil(reducer) 20 | } 21 | 22 | func testThatmarksPostAsSeen() { 23 | let post = fake.post() 24 | 25 | let seenPosts = PHSeenPosts(date: Date(timeIntervalSinceNow: 0), postIds: []) 26 | 27 | let action = PHMarkPostsAsSeenAction(posts: [post]) 28 | 29 | let reducer = seenPostsReducer(action, state: seenPosts) 30 | 31 | XCTAssertTrue( reducer.postIds.contains(post.id) ) 32 | } 33 | 34 | func testThatItResetsSeenPostsState() { 35 | let daysAgo = 1.days 36 | 37 | let yesterdayPost = fake.post(daysAgo, votes: 0, commentsCount: 0) 38 | 39 | let post = fake.post() 40 | 41 | let seenPosts = PHSeenPosts(date: Date(timeIntervalSinceNow: daysAgo), postIds: Set([yesterdayPost.id])) 42 | 43 | let action = PHMarkPostsAsSeenAction(posts: [post]) 44 | 45 | let reducer = seenPostsReducer(action, state: seenPosts) 46 | 47 | XCTAssertTrue(reducer.date.isToday()) 48 | XCTAssertEqual(reducer.postIds.count, 1) 49 | XCTAssertTrue(reducer.isSeen(yesterdayPost)) 50 | XCTAssertTrue(reducer.isSeen(post)) 51 | } 52 | 53 | func testThatAddsOnlyUnqueIds() { 54 | let post = fake.post() 55 | 56 | let seenPosts = PHSeenPosts(date: Date(), postIds: Set([post.id])) 57 | 58 | let action = PHMarkPostsAsSeenAction(posts: [post]) 59 | 60 | let reducer = seenPostsReducer(action, state: seenPosts) 61 | 62 | XCTAssertTrue(reducer.isSeen(post)) 63 | XCTAssertEqual(reducer.postIds.count, 1) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/PHDateFormatterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHDateFormatter.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/15/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class PHDateFormatterTests: PHTestCase { 12 | 13 | let formatter = PHDateFormatter() 14 | 15 | func testThatItFormatsToday() { 16 | let dateformatter = DateFormatter() 17 | dateformatter.dateFormat = "yyyy-MM-dd" 18 | 19 | let dateAsString = dateformatter.string(from: Date()) 20 | 21 | let formattedString = formatter.format(withDateString: dateAsString)! 22 | 23 | XCTAssertTrue(formattedString.range(of: "Today") != nil) 24 | } 25 | 26 | func testThatItFormatsYesterday() { 27 | let date = Date(timeIntervalSinceNow: -(60*60*24 + 1)) 28 | 29 | let dateformatter = DateFormatter() 30 | dateformatter.dateFormat = "yyyy-MM-dd" 31 | 32 | let dateAsString = dateformatter.string(from: date) 33 | 34 | let formattedString = formatter.format(withDateString: dateAsString)! 35 | 36 | XCTAssertTrue(formattedString.range(of: "Yesterday") != nil) 37 | } 38 | 39 | func testThatAddsStSuffux() { 40 | let dateString = "2016-01-31" 41 | 42 | let formattedString = formatter.format(withDateString: dateString)! 43 | 44 | XCTAssertTrue(formattedString.range(of: "31st") != nil) 45 | } 46 | 47 | func testThatAddsRdSuffix() { 48 | let dateString = "2016-01-03" 49 | 50 | let formattedString = formatter.format(withDateString: dateString)! 51 | 52 | XCTAssertTrue(formattedString.range(of: "3rd") != nil) 53 | } 54 | 55 | func testThatHandlesInvalidDate() { 56 | let date = "wrong date" 57 | 58 | XCTAssertNil(formatter.format(withDateString: date)) 59 | } 60 | 61 | func testThatItReturnsDaysAgo() { 62 | let date = Date(timeIntervalSinceNow: -1.day) 63 | 64 | let dateformatter = DateFormatter() 65 | dateformatter.dateFormat = "yyyy-MM-dd" 66 | 67 | let dateAsString = dateformatter.string(from: date) 68 | 69 | XCTAssertEqual(formatter.daysAgo(fromDateAsString: dateAsString), 1) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/Support/PHFakeEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHFakeEndpoint.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/30/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct PHTestFakeAPIEndpointStub { 12 | 13 | var method: String 14 | var url: String 15 | var parameters: [String : AnyObject] 16 | var response: [String : AnyObject]? 17 | var error: NSError? 18 | 19 | func match(_ method: String, url: String, parameters: [String : AnyObject]) -> Bool { 20 | return self.method == method && self.url == url && self.parameters.description == parameters.description 21 | } 22 | } 23 | 24 | class PHAPIFakeEndpoint: PHAPIEndpoint { 25 | 26 | fileprivate var fakes = [PHTestFakeAPIEndpointStub]() 27 | 28 | override init(token: PHToken?) { 29 | super.init(token: token) 30 | } 31 | 32 | func addFake(_ method: String, url: String, parameters: [String: AnyObject], response: [String: AnyObject]?, error: NSError?) { 33 | let stub = PHTestFakeAPIEndpointStub(method: method, url: url, parameters: parameters, response: response, error: error) 34 | fakes.append(stub) 35 | } 36 | 37 | override func get(_ url: String, parameters: [String: AnyObject]?, completion: PHAPIEndpointCompletion) { 38 | handleFakeMethod("GET", url: url, parameters: parameters ?? [String: AnyObject](), completion: completion) 39 | } 40 | 41 | override func post(_ url: String, parameters: [String: AnyObject]?, completion: PHAPIEndpointCompletion) { 42 | handleFakeMethod("POST", url: url, parameters: parameters ?? [String: AnyObject](), completion: completion) 43 | } 44 | 45 | fileprivate func handleFakeMethod(_ method: String, url: String, parameters: [String : AnyObject], completion: PHAPIEndpointCompletion) { 46 | guard let stub = fakes.filter({ $0.match(method, url: url, parameters: parameters) }).first else { 47 | NSException(name: NSExceptionName(rawValue: "PHTestFakeAPIEndpoingFailure"), reason: "No response found for \(method) \(url) (\(parameters.description))", userInfo: nil).raise() 48 | return 49 | } 50 | 51 | completion(stub.response, stub.error) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /LaunchAtLogin/LLManager.m: -------------------------------------------------------------------------------- 1 | // 2 | // LLManager.m 3 | // LaunchAtLogin 4 | // 5 | // Created by David Keegan on 4/20/12. 6 | // Copyright (c) 2012 David Keegan. 7 | // Copyright (c) 2014 Jan Weiß. 8 | // Some rights reserved: 9 | // 10 | 11 | #import "LLManager.h" 12 | #import 13 | 14 | NSString * const LLManagerSetLaunchAtLoginFailedNotification = @"LLManagerSetLaunchAtLoginFailedNotification"; 15 | NSString * const LLHelperBundleIdentifier = @"com.producthunt.producthuntosx.LaunchAtLoginHelper"; 16 | 17 | @implementation LLManager 18 | 19 | + (BOOL)launchAtLogin{ 20 | BOOL launch = false; 21 | 22 | CFArrayRef cfJobs = SMCopyAllJobDictionaries(kSMDomainUserLaunchd); 23 | 24 | if(cfJobs == NULL) { 25 | return false; 26 | } 27 | 28 | NSArray *jobs = CFBridgingRelease(cfJobs); 29 | 30 | if([jobs count]){ 31 | for(NSDictionary *job in jobs) { 32 | if([job[@"Label"] isEqualToString:LLHelperBundleIdentifier]){ 33 | launch = [job[@"OnDemand"] boolValue]; 34 | break; 35 | } 36 | } 37 | } 38 | 39 | return launch; 40 | } 41 | 42 | + (void)setLaunchAtLogin:(BOOL)value { 43 | [self setLaunchAtLogin:value notifyOnFailure:false]; 44 | } 45 | 46 | + (void)setLaunchAtLogin:(BOOL)value notifyOnFailure:(BOOL)wantFailureNotification { 47 | CFStringRef LLHelperBundleIdentifierCF = (__bridge CFStringRef)LLHelperBundleIdentifier; 48 | 49 | if(!SMLoginItemSetEnabled(LLHelperBundleIdentifierCF, value)){ 50 | if(wantFailureNotification){ 51 | [[NSNotificationCenter defaultCenter] postNotificationName:LLManagerSetLaunchAtLoginFailedNotification object:self]; 52 | } else { 53 | NSLog(@"SMLoginItemSetEnabled failed!"); 54 | } 55 | } 56 | } 57 | 58 | #pragma mark - Bindings support 59 | 60 | - (BOOL)launchAtLogin { 61 | return [[self class] launchAtLogin]; 62 | } 63 | 64 | - (void)setLaunchAtLogin:(BOOL)launchAtLogin { 65 | [self willChangeValueForKey:@"launchAtLogin"]; 66 | [[self class] setLaunchAtLogin:launchAtLogin notifyOnFailure:self.notifyIfSetLaunchAtLoginFailed]; 67 | [self didChangeValueForKey:@"launchAtLogin"]; 68 | } 69 | 70 | @end 71 | -------------------------------------------------------------------------------- /Source/Views/PHButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHButton.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/24/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class PHButton: NSButton { 12 | 13 | fileprivate let cursor = NSCursor.pointingHand() 14 | fileprivate var normalStateImage: NSImage? 15 | fileprivate var highlightedStateImage: NSImage? 16 | fileprivate var trackingArea: NSTrackingArea? 17 | 18 | override func resetCursorRects() { 19 | addCursorRect(bounds, cursor: cursor) 20 | cursor.set() 21 | } 22 | 23 | override init(frame frameRect: NSRect) { 24 | super.init(frame: frameRect) 25 | commonInit() 26 | } 27 | 28 | required init?(coder: NSCoder) { 29 | super.init(coder: coder) 30 | commonInit() 31 | } 32 | 33 | override func awakeFromNib() { 34 | super.awakeFromNib() 35 | commonInit() 36 | } 37 | 38 | func commonInit() { 39 | } 40 | 41 | func setImages(_ normalImage: String, highlitedImage: String) { 42 | self.setButtonType(.momentaryChange) 43 | 44 | normalStateImage = NSImage(named: normalImage) 45 | highlightedStateImage = NSImage(named: highlitedImage) 46 | } 47 | 48 | func resetTrackingArea() { 49 | trackingArea = nil 50 | 51 | if let normalStateImage = normalStateImage { 52 | image = normalStateImage 53 | } 54 | } 55 | 56 | fileprivate func createTrackingAreaIfNeeded() { 57 | if trackingArea == nil { 58 | trackingArea = NSTrackingArea(rect: CGRect.zero, options: [.inVisibleRect, .mouseEnteredAndExited, .activeAlways], owner: self, userInfo: nil) 59 | } 60 | } 61 | 62 | override func updateTrackingAreas() { 63 | super.updateTrackingAreas() 64 | 65 | createTrackingAreaIfNeeded() 66 | 67 | if !trackingAreas.contains(trackingArea!) { 68 | addTrackingArea(trackingArea!) 69 | } 70 | } 71 | 72 | override func mouseEntered(with theEvent: NSEvent) { 73 | if let highlightedImage = highlightedStateImage { 74 | image = highlightedImage 75 | } 76 | } 77 | 78 | override func mouseExited(with theEvent: NSEvent) { 79 | if let normalStateImage = normalStateImage { 80 | image = normalStateImage 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/PHPostsModuleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostsModuleTests.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/4/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ReSwift 11 | 12 | class PHPostsModuleTests: PHTestCase { 13 | 14 | struct BootStrapAction: Action {} 15 | 16 | func testThatReturnsState() { 17 | let posts = postsReducer(BootStrapAction(), state: nil) 18 | 19 | XCTAssertNotNil(posts) 20 | } 21 | 22 | func testThatAcceptsEmptyArray() { 23 | let state = PHAppStatePosts(sections: [], lastUpdated: Date()) 24 | 25 | let reducer = postsReducer(PHPostsLoadAction(posts: []) , state: state) 26 | 27 | XCTAssertNotNil(reducer) 28 | } 29 | 30 | func testThatOldPostsAppendsSectionAtTheEnd() { 31 | let section = PHSection.section( [fake.post(-1.day, votes: 10, commentsCount: 10)] ) 32 | 33 | let state = PHAppStatePosts(sections: [section], lastUpdated: Date()) 34 | 35 | let post = fake.post(-2.day, votes: 10, commentsCount: 10) 36 | 37 | let action = PHPostsLoadAction(posts: [post]) 38 | 39 | let reducer = postsReducer(action, state: state) 40 | 41 | XCTAssertEqual(reducer.sections.count, 2) 42 | XCTAssertEqual(reducer.sections.last!.posts.first!.id, post.id) 43 | } 44 | 45 | func testThatNewPostsAppendsSectionAtFront() { 46 | let section = PHSection.section( [fake.post(-1.day, votes: 10, commentsCount: 10)] ) 47 | 48 | let state = PHAppStatePosts(sections: [section], lastUpdated: Date()) 49 | 50 | let post = fake.post() 51 | 52 | let action = PHPostsLoadAction(posts: [post]) 53 | 54 | let reducer = postsReducer(action, state: state) 55 | 56 | XCTAssertEqual(reducer.sections.count, 2) 57 | XCTAssertEqual(reducer.sections.first!.posts.first!.id, post.id) 58 | } 59 | 60 | func testThatReplacesTodaySection() { 61 | let section = PHSection.section( [fake.post()] ) 62 | 63 | let state = PHAppStatePosts(sections: [section], lastUpdated: Date()) 64 | 65 | let post = fake.post() 66 | 67 | let action = PHPostsLoadAction(posts: [post]) 68 | 69 | let reducer = postsReducer(action, state: state) 70 | 71 | XCTAssertEqual(reducer.sections.count, 1) 72 | XCTAssertEqual(reducer.sections.first!.posts.first!.id, post.id) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Source/Controllers/PHGeneralSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHGeneralSettingsViewController.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/22/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import ServiceManagement 11 | import ReSwift 12 | 13 | class PHGeneralSettingsViewController: NSViewController, PHPreferencesWindowControllerProtocol, StoreSubscriber { 14 | 15 | @IBOutlet weak var startAtLoginButton: NSButton! 16 | @IBOutlet weak var showsCountButton: NSButton! 17 | @IBOutlet weak var versionLabel: NSTextField! 18 | @IBOutlet weak var filterVotesLabel: NSTextField! 19 | @IBOutlet weak var filterVotesSlider: NSSlider! 20 | 21 | var currenSliderValue: Int { 22 | return Int(5 * Int(round(filterVotesSlider.floatValue / 5.0))) 23 | } 24 | 25 | override func viewWillAppear() { 26 | super.viewWillAppear() 27 | 28 | filterVotesSlider.minValue = 0 29 | filterVotesSlider.maxValue = 100 30 | 31 | if let version = PHBundle.version { 32 | versionLabel.stringValue = "Product Hunt for OSX \(version)" 33 | } 34 | 35 | store.subscribe(self) 36 | 37 | newState(state: store.state) 38 | } 39 | 40 | override func viewWillDisappear() { 41 | super.viewWillDisappear() 42 | store.unsubscribe(self) 43 | } 44 | 45 | @IBAction func startAtLoginAction(_ sender: NSButton) { 46 | store.dispatch( PHSettingsActionAutoLogin(autologin: sender.boolState) ) 47 | } 48 | 49 | @IBAction func showPostsCountAction(_ sender: NSButton) { 50 | store.dispatch( PHSettingsActionShowsCount(showsCount: sender.boolState) ) 51 | } 52 | 53 | @IBAction func filterSliderValueChanged(_ sender: NSSlider) { 54 | store.dispatch( PHSettngsActionFilterCount(filterCount: currenSliderValue) ) 55 | } 56 | 57 | func newState(state: PHAppState) { 58 | startAtLoginButton.setState(forBool: state.settings.autologinEnabled) 59 | showsCountButton.setState(forBool: state.settings.showsCount) 60 | 61 | filterVotesSlider.integerValue = state.settings.filterCount 62 | 63 | filterVotesLabel.stringValue = currenSliderValue == 0 ? "Don't filter out posts by votes" : "Filter out posts with less than \(currenSliderValue) votes" 64 | } 65 | 66 | // MARK: PHPreferencesWindowControllerProtocol 67 | 68 | func preferencesIdentifier() -> String { 69 | return "PHGeneralSettingsViewControllerIdentifier" 70 | } 71 | 72 | func preferencesTitle() -> String { 73 | return "General" 74 | } 75 | 76 | func preferencesIcon() -> NSImage { 77 | return NSImage(named: NSImageNamePreferencesGeneral)! 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Source/Models/PHAnalitycs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHAnalitycs.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 5/5/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | typealias PHAnalitycsProperties = [String: Any] 12 | 13 | class PHAnalitycs { 14 | 15 | static let sharedInstance = PHAnalitycs() 16 | 17 | fileprivate let kTrackEventVisit = "visit" 18 | fileprivate let kTrackEventClick = "click" 19 | fileprivate let kTrackEventShare = "share" 20 | fileprivate let kTrackPlatform = "osx" 21 | 22 | fileprivate var api = PHAnalitycsAPI() 23 | 24 | fileprivate var context: PHAnalitycsProperties { 25 | return [ "uuid" : PHBundle.systemUUID() as AnyObject ] 26 | } 27 | 28 | fileprivate var timestamp: String { 29 | return (Date() as NSDate).iso8601String() ?? "" 30 | } 31 | 32 | func trackClickPost(_ id: Int) { 33 | trackClick("post", subjectId: id) 34 | } 35 | 36 | func trackClick(_ subjectType: String, subjectId: Int) { 37 | let properties: PHAnalitycsProperties = [ 38 | "event" : kTrackEventClick, 39 | "properties": [ 40 | "subject_type" : subjectType, 41 | "subject_id" : subjectId, 42 | "platform" : kTrackPlatform, 43 | "page" : "home", 44 | "deeplink_uri_pattern" : "/home", 45 | "deeplink_uri_full" : "/home", 46 | ], 47 | "context" : context, 48 | "timestamp" : timestamp 49 | ] 50 | 51 | api.track(properties) 52 | } 53 | 54 | func trackShare(_ subjectType: String, subjectId: Int, medium: String) { 55 | let properties: PHAnalitycsProperties = [ 56 | "event" : kTrackEventShare, 57 | "properties": [ 58 | "subject_type" : subjectType, 59 | "subject_id" : subjectId, 60 | "platform" : kTrackPlatform, 61 | "medium" : medium, 62 | "page" : "home", 63 | "deeplink_uri_pattern" : "/home", 64 | "deeplink_uri_full" : "/home", 65 | ], 66 | "context" : context, 67 | "timestamp" : timestamp 68 | ] 69 | 70 | api.track(properties) 71 | } 72 | 73 | func trackVisit(_ page: String) { 74 | let properties: PHAnalitycsProperties = [ 75 | "event" : kTrackEventVisit as AnyObject, 76 | "page" : page as AnyObject, 77 | "timestamp" : timestamp as AnyObject 78 | ] 79 | 80 | api.visit(properties) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Source/Models/PHDateFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHDateFormatter.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/15/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import DateTools 11 | import ISO8601 12 | 13 | class PHDateFormatter { 14 | 15 | class func daysAgo(_ dateString: String) -> Int { 16 | return PHDateFormatter().daysAgo(fromDateAsString: dateString) 17 | } 18 | 19 | fileprivate let formatter = DateFormatter() 20 | fileprivate var units: [Int : String] { 21 | return [0: "th", 1: "st", 21: "st", 31: "st", 2: "nd", 22: "nd", 3: "rd", 23: "rd"] 22 | } 23 | 24 | func format(withDateString dateString: String) -> String? { 25 | guard let date = parseDate(dateString) else { 26 | return nil 27 | } 28 | 29 | return format(date) 30 | } 31 | 32 | func format(_ date: Date) -> String { 33 | let day = getDayName(date) 34 | let month = getMonthName(date) 35 | let suffix = getDaySuffix(date) 36 | 37 | return "\(day), \(month) \(suffix)" 38 | } 39 | 40 | func daysAgo(fromDateAsString dateString: String) -> Int { 41 | guard let date = parseDate(dateString) else { 42 | return 0 43 | } 44 | 45 | return daysAgo(date) 46 | } 47 | 48 | func timeAgo(fromDateAsString dateString: String) -> String { 49 | guard var timeZone = NSTimeZone(abbreviation: "PST"), let date = NSDate(iso8601String: dateString, timeZone: &timeZone, using: nil) else { 50 | return "" 51 | } 52 | 53 | return date.shortTimeAgoSinceNow() 54 | } 55 | 56 | fileprivate func parseDate(_ dateAsString: String) -> Date? { 57 | formatter.dateFormat = "yyyy-MM-dd" 58 | 59 | guard let date = formatter.date(from: dateAsString) else { 60 | return nil 61 | } 62 | 63 | return date 64 | } 65 | 66 | fileprivate func daysAgo(_ date: Date) -> Int { 67 | return (Calendar.current as NSCalendar).components(NSCalendar.Unit.day, from: date, to: Date(), options: []).day! 68 | } 69 | 70 | fileprivate func getDayName(_ date: Date) -> String { 71 | formatter.dateFormat = "EEEE" 72 | 73 | switch daysAgo(date) { 74 | case 0 : return "Today" 75 | case 1 : return "Yesterday" 76 | default : return self.formatter.string(from: date) 77 | } 78 | } 79 | 80 | fileprivate func getMonthName(_ date: Date) -> String { 81 | formatter.dateFormat = "MMMM" 82 | return self.formatter.string(from: date) 83 | } 84 | 85 | fileprivate func getDaySuffix(_ date: Date) -> String { 86 | let day = (Calendar.current as NSCalendar).component(NSCalendar.Unit.day, from: date) 87 | let unit = units[day] ?? units[0] 88 | 89 | return "\(day)\(unit!)" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/PHPostSorterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostSorterTests.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/19/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ReSwift 11 | 12 | class PHPostSorterTests: PHTestCase { 13 | 14 | func testThatFiltersSeenPosts() { 15 | let seenPost = fake.post() 16 | let unseenPost = fake.post() 17 | 18 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 19 | 20 | store.dispatch( PHMarkPostsAsSeenAction(posts: [seenPost])) 21 | 22 | let posts = PHPostSorter.filter(store, posts: [seenPost, unseenPost], by: [.Seen(true)]) 23 | 24 | XCTAssertTrue(posts.count == 1) 25 | } 26 | 27 | func testThatFiltersUnseenPosts() { 28 | let seenPost = fake.post() 29 | let unseenPost = fake.post() 30 | 31 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 32 | 33 | store.dispatch( PHMarkPostsAsSeenAction(posts: [seenPost])) 34 | 35 | let posts = PHPostSorter.filter(store, posts: [seenPost, unseenPost], by: [.Seen(false)]) 36 | 37 | XCTAssertTrue(posts.count == 1) 38 | } 39 | 40 | func testThatFiltersVotes() { 41 | let seenPost = fake.post(0, votes: 9, commentsCount: 0) 42 | let unseenPost = fake.post(0, votes: 15, commentsCount: 0) 43 | 44 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 45 | 46 | let posts = PHPostSorter.filter(store, posts: [seenPost, unseenPost], by: [PHPostFilter.Votes(10)]) 47 | 48 | XCTAssertTrue(posts.count == 1) 49 | } 50 | 51 | func testThatSortsByVotes() { 52 | let seenPost = fake.post(0, votes: 10, commentsCount: 0) 53 | let unseenPost = fake.post(0, votes: 15, commentsCount: 0) 54 | 55 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 56 | 57 | let posts = PHPostSorter.filter(store, posts: [seenPost, unseenPost], by: [PHPostFilter.SortByVotes]) 58 | 59 | XCTAssertTrue(posts.first!.votesCount == 15) 60 | } 61 | 62 | func testThatSortsPostsByUnseenAndGivenVoteCount() { 63 | let seenPost = fake.post(0, votes: 100, commentsCount: 0) 64 | let unseenPost = fake.post(0, votes: 9, commentsCount: 0) 65 | let otherUnseenPost = fake.post(0, votes: 10, commentsCount: 0) 66 | 67 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 68 | 69 | store.dispatch( PHMarkPostsAsSeenAction(posts: [seenPost])) 70 | 71 | let posts = PHPostSorter.sort(store, posts: [seenPost, unseenPost, otherUnseenPost], votes: 0) 72 | 73 | XCTAssertTrue(posts.first!.votesCount == 10) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Source/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 3/14/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import SwiftyTimer 11 | import Sparkle 12 | import ReSwift 13 | 14 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 15 | 16 | @NSApplicationMain 17 | class AppDelegate: NSObject, NSApplicationDelegate { 18 | 19 | @IBOutlet weak var window: NSWindow! 20 | 21 | let statusItem = NSStatusBar.system().statusItem(withLength: NSVariableStatusItemLength) 22 | let popover = NSPopover() 23 | 24 | var settingsWindow = PHPreferencesWindowController() 25 | var countdownToMarkAsSeen: Timer? 26 | 27 | fileprivate var statusBarUpdater: PHStatusBarUpdater! 28 | fileprivate var updatePostTimer: Timer? 29 | fileprivate let defaults = PHDefaults(store: store) 30 | 31 | func applicationDidFinishLaunching(_ aNotification: Notification) { 32 | if let button = statusItem.button { 33 | button.image = NSImage(named: "StatusBarButtonImage") 34 | button.imagePosition = .imageLeft 35 | button.action = #selector(togglePopover) 36 | } 37 | 38 | popover.contentViewController = PHPostListViewController(nibName: "PHPostListViewController", bundle: nil) 39 | popover.appearance = NSAppearance(named: NSAppearanceNameAqua) 40 | popover.animates = false 41 | popover.behavior = .transient 42 | 43 | PHFirstLaunchAction.perform { 44 | PHStartAtLoginAction.perform(true) 45 | 46 | SUUpdater.shared().automaticallyChecksForUpdates = true 47 | SUUpdater.shared().automaticallyDownloadsUpdates = false 48 | } 49 | 50 | statusBarUpdater = PHStatusBarUpdater(button: statusItem.button, store: store) 51 | 52 | PHLoadPostOperation.performNewer() 53 | 54 | updatePostTimer = Timer.every(10.minutes) { 55 | PHLoadPostOperation.performNewer() 56 | } 57 | 58 | SUUpdater.shared().feedURL = URL(string: kPHFeedUrl)! 59 | SUUpdater.shared().updateCheckInterval = 1.day 60 | } 61 | 62 | func applicationWillTerminate(_ aNotification: Notification) { 63 | updatePostTimer?.invalidate() 64 | updatePostTimer = nil 65 | } 66 | 67 | // MARK: Actions 68 | 69 | func togglePopover() { 70 | PHPopoverAction.toggle() 71 | } 72 | 73 | func openAppsLink() { 74 | PHOpenProductHuntAction.performWithAppsLink() 75 | } 76 | 77 | func openFAQLink() { 78 | PHOpenProductHuntAction.performWithFAQLink() 79 | } 80 | 81 | func openAboutLink() { 82 | PHOpenProductHuntAction.performWithAboutLink() 83 | } 84 | 85 | func showSettings() { 86 | PHOpenSettingsAction.perform() 87 | PHPopoverAction.close() 88 | } 89 | 90 | func quit() { 91 | NSApplication.shared().terminate(self) 92 | } 93 | 94 | func checkForUpdates() { 95 | SUUpdater.shared().checkForUpdates(nil) 96 | } 97 | } 98 | 99 | -------------------------------------------------------------------------------- /Source/Tests/UnitTests/PHPostViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostViewModelTests.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/18/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ReSwift 11 | 12 | class PHPostViewModelTests: PHTestCase { 13 | 14 | func testThatRerurnsTitle() { 15 | let post = fake.post() 16 | 17 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 18 | 19 | let model = PHPostViewModel(withPost: post, store: store) 20 | 21 | XCTAssertTrue(model.title == post.title) 22 | } 23 | 24 | func testThatReturnsThumbnailUrl() { 25 | let post = fake.post() 26 | 27 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 28 | 29 | let model = PHPostViewModel(withPost: post, store: store) 30 | 31 | XCTAssertNotNil(model.thumbnailUrl) 32 | } 33 | 34 | func testThatRerurnsTagline() { 35 | let post = fake.post() 36 | 37 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 38 | 39 | let model = PHPostViewModel(withPost: post, store: store) 40 | 41 | XCTAssertTrue(model.tagline == post.tagline) 42 | } 43 | 44 | func testThatSeenIsTrueIfPostIsOlderThanOneDay() { 45 | let post = fake.post(1.days) 46 | 47 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 48 | 49 | let model = PHPostViewModel(withPost: post, store: store) 50 | 51 | store.dispatch( PHMarkPostsAsSeenAction(posts: [post]) ) 52 | 53 | XCTAssertTrue(model.isSeen) 54 | } 55 | 56 | func testThatSeenIsFalseIfNotInInteractions() { 57 | let post = fake.post() 58 | 59 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 60 | 61 | let model = PHPostViewModel(withPost: post, store: store) 62 | 63 | XCTAssertFalse(model.isSeen) 64 | } 65 | 66 | func testThatSeenIsTrueIfPostIsInInteractions() { 67 | let post = fake.post() 68 | 69 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 70 | 71 | let model = PHPostViewModel(withPost: post, store: store) 72 | 73 | store.dispatch( PHMarkPostsAsSeenAction(posts: [post]) ) 74 | 75 | XCTAssertTrue(model.isSeen) 76 | } 77 | 78 | func testThatReturnsVotesCount() { 79 | let post = fake.post(0.seconds, votes: 10, commentsCount: 10) 80 | 81 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 82 | 83 | let model = PHPostViewModel(withPost: post, store: store) 84 | 85 | XCTAssertTrue(model.votesCount == "10") 86 | } 87 | 88 | func testThatReturnsCommentsCount() { 89 | let post = fake.post(0.seconds, votes: 10, commentsCount: 10) 90 | 91 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 92 | 93 | let model = PHPostViewModel(withPost: post, store: store) 94 | 95 | XCTAssertTrue(model.commentsCount == "10") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Source/Components/KPCScaleToFillNSImageView.m: -------------------------------------------------------------------------------- 1 | // 2 | // KPCScaleToFillNSImageView.m 3 | // 4 | // Created by onekiloparsec on 4/5/14. 5 | // MIT Licence 6 | // 7 | 8 | #import "KPCScaleToFillNSImageView.h" 9 | 10 | @implementation KPCScaleToFillNSImageView 11 | 12 | - (id)init 13 | { 14 | self = [super init]; 15 | if (self) { 16 | [super setImageScaling:NSImageScaleAxesIndependently]; 17 | } 18 | return self; 19 | } 20 | 21 | - (id)initWithCoder:(NSCoder *)aDecoder 22 | { 23 | self = [super initWithCoder:aDecoder]; 24 | if (self) { 25 | [super setImageScaling:NSImageScaleAxesIndependently]; 26 | } 27 | return self; 28 | } 29 | 30 | - (id)initWithFrame:(NSRect)frameRect 31 | { 32 | self = [super initWithFrame:frameRect]; 33 | if (self) { 34 | [super setImageScaling:NSImageScaleAxesIndependently]; 35 | } 36 | return self; 37 | } 38 | 39 | - (void)setImageScaling:(NSImageScaling)newScaling 40 | { 41 | // That's necessary to use nothing but NSImageScaleAxesIndependently 42 | [super setImageScaling:NSImageScaleAxesIndependently]; 43 | } 44 | 45 | - (void)setImage:(NSImage *)image 46 | { 47 | if (image == nil) { 48 | [super setImage:image]; 49 | return; 50 | } 51 | 52 | NSImage *scaleToFillImage = [NSImage imageWithSize:self.bounds.size 53 | flipped:NO 54 | drawingHandler:^BOOL(NSRect dstRect) { 55 | 56 | NSSize imageSize = [image size]; 57 | NSSize imageViewSize = self.bounds.size; // Yes, do not use dstRect. 58 | 59 | NSSize newImageSize = imageSize; 60 | 61 | CGFloat imageAspectRatio = imageSize.height/imageSize.width; 62 | CGFloat imageViewAspectRatio = imageViewSize.height/imageViewSize.width; 63 | 64 | if (imageAspectRatio < imageViewAspectRatio) { 65 | // Image is more horizontal than the view. Image left and right borders need to be cropped. 66 | newImageSize.width = imageSize.height / imageViewAspectRatio; 67 | } 68 | else { 69 | // Image is more vertical than the view. Image top and bottom borders need to be cropped. 70 | newImageSize.height = imageSize.width * imageViewAspectRatio; 71 | } 72 | 73 | NSRect srcRect = NSMakeRect(imageSize.width/2.0-newImageSize.width/2.0, 74 | imageSize.height/2.0-newImageSize.height/2.0, 75 | newImageSize.width, 76 | newImageSize.height); 77 | 78 | [[NSGraphicsContext currentContext] setImageInterpolation:NSImageInterpolationHigh]; 79 | 80 | [image drawInRect:dstRect // Interestingly, here needs to be dstRect and not self.bounds 81 | fromRect:srcRect 82 | operation:NSCompositeCopy 83 | fraction:1.0 84 | respectFlipped:YES 85 | hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}]; 86 | 87 | return YES; 88 | }]; 89 | 90 | [scaleToFillImage setCacheMode:NSImageCacheNever]; // Hence it will automatically redraw with new frame size of the image view. 91 | 92 | [super setImage:scaleToFillImage]; 93 | } 94 | 95 | @end 96 | -------------------------------------------------------------------------------- /Source/Controllers/PHPostListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostListViewController.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/14/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import DateTools 11 | import SwiftyTimer 12 | 13 | class PHPostListViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, PHDataSourceDelegate { 14 | 15 | @IBOutlet weak var productTableView: NSTableView! 16 | @IBOutlet weak var loadingView: PHLoadingView! 17 | @IBOutlet weak var lastUpdatedLabel: NSTextField! 18 | @IBOutlet weak var homeButton: PHButton! 19 | @IBOutlet weak var settingsButton: PHButton! 20 | 21 | fileprivate var source = PHPostsDataSource(store: store) 22 | fileprivate var updateUITimer: Timer? 23 | 24 | override func viewDidLoad() { 25 | super.viewDidLoad() 26 | 27 | view.wantsLayer = true 28 | 29 | view.layer?.backgroundColor = NSColor.ph_whiteColor().cgColor 30 | 31 | productTableView.intercellSpacing = NSSize.zero 32 | 33 | source.delegate = self 34 | 35 | loadingView.state = .loading 36 | 37 | homeButton.setImages("Icon-product-hunt", highlitedImage: "icon-kitty") 38 | } 39 | 40 | override func viewWillAppear() { 41 | super.viewWillAppear() 42 | 43 | self.source.loadNewer() 44 | 45 | PHScheduleAsSeenAction.performCancel() 46 | 47 | updateUITimer = Timer.every(15.seconds) { [weak self] in self?.updateUI() } 48 | 49 | updateUI() 50 | 51 | PHAnalitycsOperation.performTrackVisit("home") 52 | } 53 | 54 | override func viewWillDisappear() { 55 | super.viewWillDisappear() 56 | 57 | PHScheduleAsSeenAction.performSchedule() 58 | 59 | if let timer = updateUITimer { 60 | timer.invalidate() 61 | } 62 | 63 | updateUITimer = nil 64 | } 65 | 66 | func numberOfRows(in tableView: NSTableView) -> Int { 67 | return source.numberOfRows() 68 | } 69 | 70 | func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { 71 | loadOlderIfNeeded(row) 72 | return PHSectionCell.view(tableView, owner: self, subject: source.data(atIndex: row)) ?? PHPostCell.view(tableView, owner: self, subject: source.data(atIndex: row)) 73 | } 74 | 75 | func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { 76 | return source.isGroup(atIndex: row) 77 | } 78 | 79 | func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { 80 | return source.isGroup(atIndex: row) ? 45 : tableView.rowHeight 81 | } 82 | 83 | func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { 84 | if let post = source.data(atIndex: row) as? PHPost { 85 | PHOpenPostOperation.perform(withPost: post) 86 | } 87 | 88 | return true 89 | } 90 | 91 | fileprivate func loadOlderIfNeeded(_ row: Int) { 92 | if source.numberOfRows()-row < 15 { 93 | source.loadOlder() 94 | } 95 | } 96 | 97 | // MARK: PHDataSourceDelegate 98 | 99 | func contentChanged() { 100 | loadingView.state = source.numberOfRows() > 0 ? .idle : .empty 101 | 102 | productTableView.reloadData() 103 | 104 | updateUI() 105 | } 106 | 107 | // MARK: Actions 108 | 109 | @IBAction func toggleProductHuntButton(_ sender: AnyObject) { 110 | PHOpenURLAction.perform(withPath: "https://producthunt.com", closeAfterLaunch: true) 111 | } 112 | 113 | @IBAction func toggleSettingsButton(_ sender: NSView) { 114 | PHOpenSettingsMenuAction.perform(sender) 115 | } 116 | 117 | func updateUI() { 118 | if let lastUpdated = (store.state.posts.lastUpdated as NSDate).timeAgoSinceNow() { 119 | lastUpdatedLabel.stringValue = "Last Updated: \(lastUpdated)" 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Product Hunt.xcodeproj/xcshareddata/xcschemes/Release.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /Product Hunt.xcodeproj/xcshareddata/xcschemes/Debug.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 44 | 45 | 46 | 47 | 48 | 54 | 55 | 56 | 57 | 58 | 59 | 69 | 71 | 77 | 78 | 79 | 80 | 81 | 82 | 88 | 90 | 96 | 97 | 98 | 99 | 101 | 102 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /Source/Controllers/PHAdvancedSettingsViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 33 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper/MainMenu.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1070 5 | 11D50 6 | 2182 7 | 1138.32 8 | 568.00 9 | 10 | com.apple.InterfaceBuilder.CocoaPlugin 11 | 2182 12 | 13 | 14 | NSCustomObject 15 | 16 | 17 | com.apple.InterfaceBuilder.CocoaPlugin 18 | 19 | 20 | PluginDependencyRecalculationVersion 21 | 22 | 23 | 24 | 25 | NSApplication 26 | 27 | 28 | FirstResponder 29 | 30 | 31 | NSApplication 32 | 33 | 34 | LLHAppDelegate 35 | 36 | 37 | 38 | 39 | 40 | 41 | delegate 42 | 43 | 44 | 45 | 495 46 | 47 | 48 | 49 | 50 | 51 | 0 52 | 53 | 54 | 55 | 56 | 57 | -2 58 | 59 | 60 | File's Owner 61 | 62 | 63 | -1 64 | 65 | 66 | First Responder 67 | 68 | 69 | -3 70 | 71 | 72 | Application 73 | 74 | 75 | 494 76 | 77 | 78 | 79 | 80 | 81 | 82 | com.apple.InterfaceBuilder.CocoaPlugin 83 | com.apple.InterfaceBuilder.CocoaPlugin 84 | com.apple.InterfaceBuilder.CocoaPlugin 85 | com.apple.InterfaceBuilder.CocoaPlugin 86 | 87 | 88 | 89 | 90 | 91 | 535 92 | 93 | 94 | 0 95 | IBCocoaFramework 96 | 97 | com.apple.InterfaceBuilder.CocoaPlugin.macosx 98 | 99 | 100 | YES 101 | 3 102 | YES 103 | 104 | 105 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/readme.md: -------------------------------------------------------------------------------- 1 | When creating a sandboxed app `LSSharedFileListInsertItemURL` can no longer be used to launch the app at startup. Instead `SMLoginItemSetEnabled` should be used with a helper app inside the main app's bundle that launches the main app. **LaunchAtLoginHelper** is that helper app, it is designed to be as easy as possible to integrate into the main app to allow it to launch at login when sandboxed. 2 | 3 | A lot of research was put into this helper app. For example [Apple's docs](http://developer.apple.com/library/mac/#documentation/Security/Conceptual/AppSandboxDesignGuide/DesigningYourSandbox/DesigningYourSandbox.html#//apple_ref/doc/uid/TP40011183-CH4-SW3) state that `LSRegisterURL` should be used to register the helper app, however this never seemed to work and after further digging it turns out this is a [typo in the docs](https://devforums.apple.com/message/647212#647212). Many examples I found online used `NSWorkspace launchApplication:` to launch the main app, however this was blocked by sandboxing so a url scheme is used instead. 4 | 5 | **LaunchAtLoginHelper** calls the main app's scheme twice, once to launchg the app and then again with `launchedAtLogin` so the main app can know if it has been launched at login. For example [Play by Play](http://playbyplayapp.com) uses this to hide the app if it was launched at login. 6 | 7 | This project contains a [sample app](https://github.com/kgn/LaunchAtLoginHelper/tree/master/LaunchAtLoginSample) to demonstrate how the main app should be configured and how to setup a checkbox to enable and disable launching at login. 8 | 9 | # How to use 10 | 11 | First download or even better submodule **LaunchAtLoginHelper**. To clone the repository as a submodule use the following commands: 12 | 13 | ``` 14 | $ cd 15 | $ git clone --recursive https://github.com/kgn/LaunchAtLoginHelper.git 16 | ``` 17 | 18 | **LaunchAtLoginHelper** uses a url scheme to launch the main app, if the main app doesn't have a url scheme yet add one. 19 | 20 | ![](http://kgn.github.com/content/launchatlogin/url_scheme.png) 21 | 22 | There are two files missing from this repo that are specific to your instance of the helper app. To generate these files run the `setup.py` python script and pass in the url scheme to launch the main app and the bundle identifier of the helper app, this is usually based of of the bundle identifier of the main app but with *Helper* added onto the end. 23 | 24 | ``` 25 | $ cd LaunchAtLoginHelper 26 | $ python setup.py 27 | ``` 28 | 29 | For the sample code the above will look like this: 30 | 31 | ``` 32 | $ cd LaunchAtLoginHelper 33 | $ python setup.py launchatloginsample com.InScopeApps.ShellTo.LaunchAtLoginHelper 34 | ``` 35 | 36 | This will create `LLStrings.h` which is used in both the helper app and the main app and contains `#define`'s for the url scheme and the helper app's bundle identifier. `LaunchAtLoginHelper-Info.plist` is also created for the helper app with it's custom bundle identifier filled in. 37 | 38 | Once these two files are generated it's time to add **LaunchAtLoginHelper** to the main app. Drag `LaunchAtLoginHelper.xcodeproj`, `LLStrings.h`, `LLManager.h`, and `LLManager.m` to the main app's project. 39 | 40 | ![](http://kgn.github.com/content/launchatlogin/drag_drop_file.png) 41 | 42 | Next go to the *Build Phases* for the main app and add **LaunchAtLoginHelper** as a *Target Dependency* and create a new *Copy Files Build Phase*. Set the *Destination* of this build phase to `Wrapper` and the *Subpath* to `Contents/Library/LoginItems`. 43 | 44 | ![](http://kgn.github.com/content/launchatlogin/build_phases.png) 45 | 46 | Lastly add `ServiceManagement.framework` to the main app. 47 | 48 | Once this is done use `LLManager` to enable and disable launching at login! [**LaunchAtLoginSample**](https://github.com/kgn/LaunchAtLoginHelper/blob/master/LaunchAtLoginSample/LLAppDelegate.m) shows how to hook this up to a checkbox. 49 | 50 | ``` obj-c 51 | #import "LLManager.h" 52 | 53 | [LLManager launchAtLogin] // will the app launch at login? 54 | [LLManager setLaunchAtLogin:YES] // set the app to launch at login 55 | ``` 56 | 57 | # Bindings 58 | 59 | The `LLManager` class supports KVO and Cocoa Bindings. This allows for a completely code-free implementaion of this class. To get started, open the Interface Builder document in which you plan to create a login toggle. Drag a generic `NSObject` from the Utilities pane, and drop it onto your canvas. Select the newly created object, and open the Identity inspector tab in the Utilities pane. Change the class from `NSObject` to `LLManager`. Now, select to your login toggle (checkbox) and open the Bindings inspector in the Utilities pane. Expand `Value`, check the "Bind to", and select the name of the `LLManager` object you created earlier. Set the key path to `self.launchAtLogin`. You're done! 60 | 61 | --- 62 | 63 | Special thanks to [Curtis Hard](http://www.geekygoodness.com) for offering some much needed advice on this project. -------------------------------------------------------------------------------- /Source/Tests/UnitTests/PHPostDataSourceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostDataSourceTests.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/31/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import ReSwift 11 | 12 | class PHPostDataSourceTests: PHTestCase { 13 | 14 | override func tearDown() { 15 | super.tearDown() 16 | } 17 | 18 | func testReturnsCountOfRows() { 19 | endpoint.addFake("GET", url: "posts", parameters: ["days_ago": 0, "search[category]": "all"], response: ["posts" : [fake.post().description()]], error: nil) 20 | 21 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 22 | 23 | store.dispatch( PHTokenGetAction(token: fake.token() )) 24 | 25 | let source = PHPostsDataSource(store: store) 26 | 27 | source.loadNewer() 28 | 29 | XCTAssertTrue(source.numberOfRows() == 2) 30 | } 31 | 32 | func testThatReturnsSectionAtIndex() { 33 | endpoint.addFake("GET", url: "posts", parameters: ["days_ago": 0, "search[category]": "all"], response: ["posts" : [fake.post().description()]], error: nil) 34 | 35 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 36 | 37 | store.dispatch( PHTokenGetAction(token: fake.token() )) 38 | 39 | let source = PHPostsDataSource(store: store) 40 | 41 | source.loadNewer() 42 | 43 | XCTAssertNotNil(source.data(atIndex: 0) as? String) 44 | } 45 | 46 | func testThatReturnsPostAtIndex() { 47 | endpoint.addFake("GET", url: "posts", parameters: ["days_ago": 0, "search[category]": "all"], response: ["posts" : [fake.post().description()]], error: nil) 48 | 49 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 50 | 51 | store.dispatch( PHTokenGetAction(token: fake.token() )) 52 | 53 | let source = PHPostsDataSource(store: store) 54 | 55 | source.loadNewer() 56 | 57 | XCTAssertNotNil(source.data(atIndex: 1) as? PHPost) 58 | } 59 | 60 | func testThatRerurnsTrueIfSectionIsGroup() { 61 | endpoint.addFake("GET", url: "posts", parameters: ["days_ago": 0, "search[category]": "all"], response: ["posts" : [fake.post().description()]], error: nil) 62 | 63 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 64 | 65 | store.dispatch( PHTokenGetAction(token: fake.token() )) 66 | 67 | let source = PHPostsDataSource(store: store) 68 | 69 | source.loadNewer() 70 | 71 | XCTAssertTrue(source.isGroup(atIndex: 0)) 72 | XCTAssertFalse(source.isGroup(atIndex: 1)) 73 | } 74 | 75 | func testLoadsNewer() { 76 | endpoint.addFake("GET", url: "posts", parameters: ["days_ago": 0, "search[category]": "all"], response: ["posts" : [fake.post().description()]], error: nil) 77 | 78 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 79 | 80 | store.dispatch( PHTokenGetAction(token: fake.token() )) 81 | 82 | let source = PHPostsDataSource(store: store) 83 | 84 | source.loadNewer() 85 | 86 | XCTAssertEqual(source.numberOfRows(), 2) 87 | } 88 | 89 | func testLoadsOlder() { 90 | endpoint.addFake("GET", url: "posts", parameters: ["days_ago": 0, "search[category]": "all"], response: ["posts" : [fake.post().description()]], error: nil) 91 | endpoint.addFake("GET", url: "posts", parameters: ["days_ago": 1, "search[category]": "all"], response: ["posts" : [fake.post(-1.day, votes: 10, commentsCount: 0).description()]], error: nil) 92 | 93 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 94 | 95 | store.dispatch( PHTokenGetAction(token: fake.token() )) 96 | 97 | let source = PHPostsDataSource(store: store) 98 | 99 | source.loadNewer() 100 | source.loadOlder() 101 | 102 | XCTAssertEqual(source.numberOfRows(), 4) 103 | } 104 | 105 | func testThatFiltersPostsWithGivenVoteCount() { 106 | let firstPost = fake.post(0, votes: 51, commentsCount: 0) 107 | let secondPosts = fake.post(0, votes: 25, commentsCount: 0) 108 | 109 | endpoint.addFake("GET", url: "posts", parameters: ["days_ago": 0, "search[category]": "all"], response: ["posts" : [firstPost.description(), secondPosts.description()]], error: nil) 110 | 111 | let store = Store(reducer: PHAppReducer(), state: nil, middleware: [PHTrackingMiddleware]) 112 | 113 | store.dispatch( PHTokenGetAction(token: fake.token() )) 114 | 115 | store.dispatch( PHSettngsActionFilterCount(filterCount: 50) ) 116 | 117 | let source = PHPostsDataSource(store: store) 118 | 119 | source.loadNewer() 120 | 121 | XCTAssertEqual(source.numberOfRows(), 2) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Source/Views/PHPostCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHPostCell.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/15/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import Kingfisher 11 | 12 | class PHPostCell: NSTableCellView { 13 | 14 | @IBOutlet weak var thumbnailImageView: KPCScaleToFillNSImageView! 15 | @IBOutlet weak var titleLabel: NSTextField! 16 | @IBOutlet weak var taglineLabel: NSTextField! 17 | @IBOutlet weak var seenView: PHSeenView! 18 | @IBOutlet weak var voteImageView: NSImageView! 19 | @IBOutlet weak var voteCountLabel: NSTextField! 20 | @IBOutlet weak var commentImageView: NSImageView! 21 | @IBOutlet weak var commentsCountLabel: NSTextField! 22 | @IBOutlet weak var timeAgoLabel: NSTextField! 23 | @IBOutlet weak var twitterButton: PHButton! 24 | @IBOutlet weak var facebookButton: PHButton! 25 | 26 | fileprivate let cursor = NSCursor.pointingHand() 27 | fileprivate var model: PHPostViewModel? 28 | fileprivate var post: PHPost? 29 | fileprivate var trackingArea: NSTrackingArea? 30 | fileprivate var mouseInside = false { 31 | didSet { 32 | updateUI() 33 | } 34 | } 35 | 36 | class func view(_ tableView: NSTableView, owner: AnyObject?, subject: AnyObject?) -> NSView { 37 | let view = tableView.make(withIdentifier: "postCellIdentifier", owner: owner) as! PHPostCell 38 | 39 | if let post = subject as? PHPost { 40 | view.setPost(post) 41 | } 42 | 43 | return view 44 | } 45 | 46 | override func awakeFromNib() { 47 | super.awakeFromNib() 48 | commonInit() 49 | } 50 | 51 | override func prepareForReuse() { 52 | super.prepareForReuse() 53 | trackingArea = nil 54 | mouseInside = false 55 | twitterButton.resetTrackingArea() 56 | } 57 | 58 | override func resetCursorRects() { 59 | addCursorRect(bounds, cursor: cursor) 60 | cursor.set() 61 | } 62 | 63 | fileprivate func commonInit() { 64 | wantsLayer = true 65 | 66 | thumbnailImageView.wantsLayer = true 67 | thumbnailImageView.layer?.masksToBounds = true 68 | thumbnailImageView.layer?.cornerRadius = 3 69 | 70 | twitterButton.setImages("icon-twitter", highlitedImage: "icon-twitter-hovered") 71 | facebookButton.setImages("icon-facebook", highlitedImage: "icon-facebook-hovered") 72 | 73 | taglineLabel.textColor = NSColor.ph_grayColor() 74 | voteCountLabel.textColor = NSColor.ph_grayColor() 75 | commentsCountLabel.textColor = NSColor.ph_grayColor() 76 | timeAgoLabel.textColor = NSColor.ph_grayColor() 77 | 78 | voteImageView.image = NSImage(named: "icon-upvote")!.tintedImageWithColor(NSColor.ph_grayColor()) 79 | commentImageView.image = NSImage(named: "comment-icon")!.tintedImageWithColor(NSColor.ph_grayColor()) 80 | } 81 | 82 | fileprivate func setPost(_ post: PHPost?) { 83 | self.post = post 84 | 85 | guard let post = post else { 86 | return 87 | } 88 | 89 | model = PHPostViewModel(withPost: post, store: store) 90 | updateUI() 91 | } 92 | 93 | fileprivate func updateUI() { 94 | guard let model = model else { 95 | return 96 | } 97 | 98 | layer?.backgroundColor = mouseInside ? NSColor.ph_highlightColor().cgColor : NSColor.ph_whiteColor().cgColor 99 | 100 | seenView.isHidden = model.isSeen 101 | 102 | titleLabel.stringValue = model.title 103 | taglineLabel.stringValue = model.tagline 104 | voteCountLabel.stringValue = model.votesCount 105 | commentsCountLabel.stringValue = model.commentsCount 106 | timeAgoLabel.stringValue = model.createdAt 107 | 108 | thumbnailImageView.kf.setImage(with: model.thumbnailUrl, placeholder: NSImage(named: "placeholder"), options: nil, progressBlock: nil, completionHandler: nil) 109 | 110 | twitterButton.isHidden = !mouseInside 111 | facebookButton.isHidden = !mouseInside 112 | } 113 | 114 | fileprivate func createTrackingAreaIfNeeded() { 115 | if trackingArea == nil { 116 | trackingArea = NSTrackingArea(rect: CGRect.zero, options: [NSTrackingAreaOptions.inVisibleRect, NSTrackingAreaOptions.mouseEnteredAndExited, NSTrackingAreaOptions.activeAlways], owner: self, userInfo: nil) 117 | } 118 | } 119 | 120 | override func updateTrackingAreas() { 121 | super.updateTrackingAreas() 122 | 123 | createTrackingAreaIfNeeded() 124 | 125 | if !trackingAreas.contains(trackingArea!) { 126 | addTrackingArea(trackingArea!) 127 | } 128 | } 129 | 130 | override func mouseEntered(with theEvent: NSEvent) { 131 | mouseInside = true 132 | } 133 | 134 | override func mouseExited(with theEvent: NSEvent) { 135 | mouseInside = false 136 | } 137 | 138 | @IBAction func toggleTwitterShare(_ sender: AnyObject) { 139 | PHShareAction.sharedInstance.performTwitter(post) 140 | } 141 | 142 | @IBAction func toggleFacebookShare(_ sender: AnyObject) { 143 | PHShareAction.sharedInstance.performFacebook(post) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Source/Models/PHDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHDefaults.swift 3 | // Product Hunt 4 | // 5 | // Created by Vlado on 4/28/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ISO8601 11 | import ReSwift 12 | 13 | class PHDefaults: StoreSubscriber { 14 | 15 | fileprivate let kAutoLoginKey = "autoLoginKey" 16 | fileprivate let kShowCountKey = "showBadgeCount" 17 | fileprivate let kFilterCountKey = "filterPostsCount" 18 | 19 | fileprivate let kSeenPostsKey = "seenPostsKey" 20 | fileprivate let kSeenPostsKeyDate = "seenPostsDate" 21 | fileprivate let kSeenPostsKeyIds = "seenPostsDateIds" 22 | 23 | fileprivate let kTokenKey = "oauthToken" 24 | fileprivate let kTokenKeyAccess = "accessToken" 25 | 26 | fileprivate var store: Store 27 | 28 | init(store: Store) { 29 | self.store = store 30 | 31 | migrate() 32 | 33 | store.dispatch( PHSettingsSetAction(settings: readSettings()) ) 34 | store.dispatch( PHSeenPostsSetAction(seenPost: readSeenPosts()) ) 35 | store.dispatch( PHTokenGetAction(token: readToken()) ) 36 | 37 | store.subscribe(self) 38 | } 39 | 40 | deinit { 41 | store.unsubscribe(self) 42 | } 43 | 44 | func newState(state: PHAppState) { 45 | set(state.settings.autologinEnabled, key: kAutoLoginKey) 46 | set(state.settings.showsCount, key: kShowCountKey) 47 | set(state.settings.filterCount, key: kFilterCountKey) 48 | 49 | set([ kSeenPostsKeyDate: state.seenPosts.date, kSeenPostsKeyIds: Array(state.seenPosts.postIds) ], key: kSeenPostsKey) 50 | 51 | set([kTokenKeyAccess: state.token.accessToken], key: kTokenKey) 52 | } 53 | 54 | fileprivate func readSettings() -> PHSettings { 55 | let autologinEnabled = get(kAutoLoginKey, objectType: Bool()) ?? true 56 | let showCountKey = get(kShowCountKey, objectType: Bool()) ?? true 57 | let filterCount = get(kFilterCountKey, objectType: Int()) ?? 10 58 | 59 | return PHSettings(autologinEnabled: autologinEnabled, showsCount: showCountKey, filterCount: filterCount) 60 | } 61 | 62 | fileprivate func readSeenPosts() -> PHSeenPosts { 63 | let data = get( kSeenPostsKey, objectType: [String : AnyObject]() ) ?? [String : AnyObject]() 64 | 65 | var day = Date() 66 | var ids = Set() 67 | 68 | if let date = data[kSeenPostsKeyDate] as? Date { 69 | day = date 70 | } 71 | 72 | if let postIds = data[kSeenPostsKeyIds] as? [Int] { 73 | ids = Set(postIds) 74 | } 75 | 76 | return PHSeenPosts(date: day, postIds: ids) 77 | } 78 | 79 | fileprivate func readToken() -> PHToken { 80 | let data = get(kTokenKey, objectType: [String : AnyObject]()) ?? [String : AnyObject]() 81 | 82 | var access = "" 83 | 84 | if let accessToken = data[kTokenKeyAccess] as? String { 85 | access = accessToken 86 | } 87 | 88 | return PHToken(accessToken: access) 89 | } 90 | 91 | // TODO: Remove migrate in V1.1 92 | fileprivate func migrate() { 93 | let deprecatedTokenKey = "authToken" 94 | let deprecatedShowsCountKey = "showsCount" 95 | let deprecatedFilterCountKey = "filterCount" 96 | let deprecatedAutoLoginKey = "autoLogin" 97 | let deprecatedSeenPostsKey = "seenPosts" 98 | let deprecatedLastUpdatedKey = "lastUpdatedDate" 99 | 100 | if let tokenData = get(deprecatedTokenKey, objectType: [String : Any]()), let token = PHToken.token(fromDictionary: tokenData) { 101 | set([kTokenKeyAccess: token.accessToken], key: kTokenKey) 102 | } 103 | 104 | if let showsCount = get(deprecatedShowsCountKey, objectType: Bool()) { 105 | set(showsCount as AnyObject, key: kShowCountKey) 106 | } 107 | 108 | if let filterCount = get(deprecatedFilterCountKey, objectType: Int()) { 109 | set(filterCount as AnyObject, key: kFilterCountKey) 110 | } 111 | 112 | if let autologin = get(deprecatedAutoLoginKey, objectType: Bool()) { 113 | set(autologin as AnyObject, key: kAutoLoginKey) 114 | } 115 | 116 | if let seenPosts = get(deprecatedSeenPostsKey, objectType: [String : [Int]]()), let key = seenPosts.keys.first, let date = NSDate(iso8601String: key), let ids = seenPosts[key] { 117 | set([ kSeenPostsKeyDate: date, kSeenPostsKeyIds: ids ], key: kSeenPostsKey) 118 | } 119 | 120 | remove(deprecatedTokenKey) 121 | remove(deprecatedShowsCountKey) 122 | remove(deprecatedFilterCountKey) 123 | remove(deprecatedAutoLoginKey) 124 | remove(deprecatedSeenPostsKey) 125 | remove(deprecatedLastUpdatedKey) 126 | } 127 | 128 | fileprivate func set(_ object: Any, key: String) { 129 | let defaults = UserDefaults.standard 130 | defaults.set(object, forKey: key) 131 | defaults.synchronize() 132 | } 133 | 134 | fileprivate func get(_ key: String, objectType: T) -> T? { 135 | guard let object = UserDefaults.standard.object(forKey: key) as? T else { 136 | return nil 137 | } 138 | 139 | return object 140 | } 141 | 142 | fileprivate func remove(_ key: String) { 143 | let defaults = UserDefaults.standard 144 | defaults.removeObject(forKey: key) 145 | defaults.synchronize() 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Source/Components/PHSettings/PHPreferencesWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PHTestSettingsViewController.swift 3 | // ProductHunt 4 | // 5 | // Created by Vlado on 3/22/16. 6 | // Copyright © 2016 ProductHunt. All rights reserved. 7 | // 8 | // Taken from https://github.com/phranck/CCNPreferencesWindowController 9 | 10 | import AppKit 11 | 12 | let PHPreferencesToolbarIdentifier = "PreferencesMainToolbar" 13 | let PHPreferencesWindowFrameAutoSaveName = "PreferencesWindowFrameAutoSaveName" 14 | let PHPreferencesDefaultWindowRect = NSMakeRect(0, 0, 420, 230) 15 | let escapeKey = 53 16 | 17 | class PHPreferencesWindowController : NSWindowController, NSToolbarDelegate, NSWindowDelegate { 18 | 19 | fileprivate var toolbar: NSToolbar? 20 | fileprivate var toolbarDefaultItemIdentifiers = [String]() 21 | fileprivate var activeViewController: PHPreferencesWindowControllerProtocol? 22 | 23 | var viewControllers = [PHPreferencesWindowControllerProtocol]() { 24 | didSet { 25 | setupToolbar() 26 | } 27 | } 28 | 29 | init() { 30 | let masks: NSWindowStyleMask = [.closable, .miniaturizable, .resizable, .fullScreen, .fullScreen] 31 | let window = PHPreferencesWindow(contentRect: PHPreferencesDefaultWindowRect, styleMask: masks, backing: .buffered, defer: true) 32 | 33 | window.isMovableByWindowBackground = true 34 | 35 | super.init(window: window) 36 | } 37 | 38 | required init?(coder: NSCoder) { 39 | super.init(coder: coder) 40 | } 41 | 42 | func showPreferencesWindow() { 43 | guard let window = window, !window.isVisible else { 44 | return 45 | } 46 | 47 | showWindow(self) 48 | window.makeKeyAndOrderFront(self) 49 | NSApplication.shared().activate(ignoringOtherApps: true) 50 | 51 | if activeViewController == nil { 52 | activateViewController(viewControllers[0], animate:false) 53 | window.center() 54 | } 55 | 56 | if let toolbar = window.toolbar, toolbarDefaultItemIdentifiers.count > 0 { 57 | toolbar.selectedItemIdentifier = toolbarDefaultItemIdentifiers.first 58 | } 59 | } 60 | 61 | func dismissPreferencesWindow() { 62 | close() 63 | } 64 | 65 | fileprivate func setupToolbar() { 66 | guard let window = window else { 67 | return 68 | } 69 | 70 | window.toolbar = nil 71 | toolbar = nil 72 | toolbarDefaultItemIdentifiers.removeAll() 73 | 74 | if viewControllers.count > 0 { 75 | toolbar = NSToolbar(identifier: PHPreferencesToolbarIdentifier) 76 | 77 | toolbar?.allowsUserCustomization = true 78 | toolbar?.autosavesConfiguration = true 79 | 80 | toolbar?.delegate = self 81 | window.toolbar = toolbar 82 | } 83 | } 84 | 85 | fileprivate func activateViewController(_ viewController: PHPreferencesWindowControllerProtocol, animate: Bool) { 86 | if let preferencesViewController = viewController as? NSViewController { 87 | 88 | let viewControllerFrame = preferencesViewController.view.frame 89 | 90 | if let currentWindowFrame = window?.frame, 91 | let frameRectForContentRect = window?.frameRect(forContentRect: viewControllerFrame) { 92 | 93 | let deltaY = NSHeight(currentWindowFrame) - NSHeight(frameRectForContentRect) 94 | let newWindowFrame = NSMakeRect(NSMinX(currentWindowFrame), NSMinY(currentWindowFrame) + deltaY, NSWidth(frameRectForContentRect), NSHeight(frameRectForContentRect)) 95 | 96 | window?.title = viewController.preferencesTitle() as String 97 | 98 | let newView = preferencesViewController.view 99 | newView.frame.origin = NSMakePoint(0, 0) 100 | newView.alphaValue = 0.0 101 | newView.autoresizingMask = NSAutoresizingMaskOptions() 102 | 103 | if let previousViewController = activeViewController as? NSViewController { 104 | previousViewController.view.removeFromSuperview() 105 | } 106 | 107 | window?.contentView!.addSubview(newView) 108 | 109 | if let firstResponder = viewController.firstResponder?() { 110 | window?.makeFirstResponder(firstResponder) 111 | } 112 | 113 | NSAnimationContext.runAnimationGroup({ 114 | (context: NSAnimationContext) -> Void in 115 | context.duration = (animate ? 0.25 : 0.0) 116 | context.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) 117 | self.window?.animator().setFrame(newWindowFrame, display: true) 118 | newView.animator().alphaValue = 1.0 119 | }) { 120 | () -> Void in 121 | self.activeViewController = viewController 122 | } 123 | } 124 | } 125 | } 126 | 127 | fileprivate func viewControllerWithIdentifier(_ identifier: NSString) -> PHPreferencesWindowControllerProtocol? { 128 | for viewController in viewControllers { 129 | if viewController.preferencesIdentifier() == identifier as String { 130 | return viewController 131 | } 132 | } 133 | 134 | return nil 135 | } 136 | 137 | func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: String, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { 138 | guard let viewController = viewControllerWithIdentifier(itemIdentifier as NSString) else { 139 | return nil 140 | } 141 | 142 | let identifier = viewController.preferencesIdentifier() 143 | let label = viewController.preferencesTitle() 144 | let icon = viewController.preferencesIcon() 145 | 146 | let toolbarItem = NSToolbarItem(itemIdentifier: identifier as String) 147 | toolbarItem.label = label 148 | toolbarItem.paletteLabel = label 149 | toolbarItem.image = icon 150 | if let tooltip = viewController.preferencesToolTip?() { 151 | toolbarItem.toolTip = tooltip 152 | } 153 | toolbarItem.target = self 154 | toolbarItem.action = #selector(PHPreferencesWindowController.toolbarItemAction(_:)) 155 | 156 | return toolbarItem 157 | } 158 | 159 | func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [String] { 160 | var identifiers = [String]() 161 | 162 | for viewController in viewControllers { 163 | identifiers.append(viewController.preferencesIdentifier()) 164 | } 165 | 166 | toolbarDefaultItemIdentifiers = identifiers 167 | 168 | return identifiers 169 | } 170 | 171 | func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [String] { 172 | return toolbarDefaultItemIdentifiers(toolbar) 173 | } 174 | 175 | func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [String] { 176 | return toolbarDefaultItemIdentifiers(toolbar) 177 | } 178 | 179 | func toolbarItemAction(_ toolbarItem: NSToolbarItem) { 180 | guard let activeViewController = activeViewController, activeViewController.preferencesIdentifier() != toolbarItem.itemIdentifier else { 181 | return 182 | } 183 | 184 | if let viewController = viewControllerWithIdentifier(toolbarItem.itemIdentifier as NSString) { 185 | activateViewController(viewController, animate: true) 186 | } 187 | } 188 | } 189 | 190 | class PHPreferencesWindow : NSWindow { 191 | 192 | override init(contentRect: NSRect, styleMask aStyle: NSWindowStyleMask, backing bufferingType: NSBackingStoreType, defer flag: Bool) { 193 | super.init(contentRect: contentRect, styleMask: aStyle, backing: bufferingType, defer: flag) 194 | commonInit() 195 | } 196 | 197 | fileprivate func commonInit() { 198 | center() 199 | 200 | setFrameAutosaveName(PHPreferencesWindowFrameAutoSaveName) 201 | setFrameFrom(PHPreferencesWindowFrameAutoSaveName) 202 | } 203 | 204 | override func keyDown(with theEvent: NSEvent) { 205 | switch Int(theEvent.keyCode) { 206 | 207 | case escapeKey: 208 | orderOut(nil) 209 | close() 210 | 211 | default: 212 | super.keyDown(with: theEvent) 213 | } 214 | } 215 | 216 | } 217 | -------------------------------------------------------------------------------- /Source/Controllers/PHGeneralSettingsViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 36 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /LaunchAtLoginHelper/LaunchAtLoginHelper.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 734C379915414CE200994189 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 734C379815414CE200994189 /* Cocoa.framework */; }; 11 | 73C920E915414E7800C2A75A /* LLHAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 73C920E615414E7800C2A75A /* LLHAppDelegate.m */; }; 12 | 73C920EA15414E7800C2A75A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 73C920E715414E7800C2A75A /* main.m */; }; 13 | 73C920EB15414E7800C2A75A /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 73C920E815414E7800C2A75A /* MainMenu.xib */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXFileReference section */ 17 | 734C379415414CE200994189 /* LaunchAtLoginHelper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LaunchAtLoginHelper.app; sourceTree = BUILT_PRODUCTS_DIR; }; 18 | 734C379815414CE200994189 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = System/Library/Frameworks/Cocoa.framework; sourceTree = SDKROOT; }; 19 | 734C379B15414CE200994189 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; 20 | 734C379D15414CE200994189 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 21 | 73C920E515414E7800C2A75A /* LLHAppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = LLHAppDelegate.h; path = LaunchAtLoginHelper/LLHAppDelegate.h; sourceTree = ""; }; 22 | 73C920E615414E7800C2A75A /* LLHAppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = LLHAppDelegate.m; path = LaunchAtLoginHelper/LLHAppDelegate.m; sourceTree = ""; }; 23 | 73C920E715414E7800C2A75A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = LaunchAtLoginHelper/main.m; sourceTree = ""; }; 24 | 73C920E815414E7800C2A75A /* MainMenu.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; name = MainMenu.xib; path = LaunchAtLoginHelper/MainMenu.xib; sourceTree = ""; }; 25 | 73C920FB15414FC900C2A75A /* LaunchAtLoginHelper.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = LaunchAtLoginHelper.entitlements; path = LaunchAtLoginHelper/LaunchAtLoginHelper.entitlements; sourceTree = ""; }; 26 | E782819B1CCFC3FB00447D45 /* LaunchAtLoginHelper-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "LaunchAtLoginHelper-Info.plist"; path = "LaunchAtLoginHelper/LaunchAtLoginHelper-Info.plist"; sourceTree = ""; }; 27 | /* End PBXFileReference section */ 28 | 29 | /* Begin PBXFrameworksBuildPhase section */ 30 | 734C379115414CE200994189 /* Frameworks */ = { 31 | isa = PBXFrameworksBuildPhase; 32 | buildActionMask = 2147483647; 33 | files = ( 34 | 734C379915414CE200994189 /* Cocoa.framework in Frameworks */, 35 | ); 36 | runOnlyForDeploymentPostprocessing = 0; 37 | }; 38 | /* End PBXFrameworksBuildPhase section */ 39 | 40 | /* Begin PBXGroup section */ 41 | 734C378915414CE200994189 = { 42 | isa = PBXGroup; 43 | children = ( 44 | E782819B1CCFC3FB00447D45 /* LaunchAtLoginHelper-Info.plist */, 45 | 73C920E715414E7800C2A75A /* main.m */, 46 | 73C920E515414E7800C2A75A /* LLHAppDelegate.h */, 47 | 73C920E615414E7800C2A75A /* LLHAppDelegate.m */, 48 | 73C920E815414E7800C2A75A /* MainMenu.xib */, 49 | 73C920FB15414FC900C2A75A /* LaunchAtLoginHelper.entitlements */, 50 | 734C379715414CE200994189 /* Frameworks */, 51 | 734C379515414CE200994189 /* Products */, 52 | ); 53 | sourceTree = ""; 54 | usesTabs = 0; 55 | }; 56 | 734C379515414CE200994189 /* Products */ = { 57 | isa = PBXGroup; 58 | children = ( 59 | 734C379415414CE200994189 /* LaunchAtLoginHelper.app */, 60 | ); 61 | name = Products; 62 | sourceTree = ""; 63 | }; 64 | 734C379715414CE200994189 /* Frameworks */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | 734C379B15414CE200994189 /* AppKit.framework */, 68 | 734C379D15414CE200994189 /* Foundation.framework */, 69 | 734C379815414CE200994189 /* Cocoa.framework */, 70 | ); 71 | name = Frameworks; 72 | sourceTree = ""; 73 | }; 74 | /* End PBXGroup section */ 75 | 76 | /* Begin PBXNativeTarget section */ 77 | 734C379315414CE200994189 /* LaunchAtLoginHelper */ = { 78 | isa = PBXNativeTarget; 79 | buildConfigurationList = 734C37B215414CE200994189 /* Build configuration list for PBXNativeTarget "LaunchAtLoginHelper" */; 80 | buildPhases = ( 81 | 734C379015414CE200994189 /* Sources */, 82 | 734C379115414CE200994189 /* Frameworks */, 83 | 734C379215414CE200994189 /* Resources */, 84 | ); 85 | buildRules = ( 86 | ); 87 | dependencies = ( 88 | ); 89 | name = LaunchAtLoginHelper; 90 | productName = LaunchAtLoginHelper; 91 | productReference = 734C379415414CE200994189 /* LaunchAtLoginHelper.app */; 92 | productType = "com.apple.product-type.application"; 93 | }; 94 | /* End PBXNativeTarget section */ 95 | 96 | /* Begin PBXProject section */ 97 | 734C378B15414CE200994189 /* Project object */ = { 98 | isa = PBXProject; 99 | attributes = { 100 | CLASSPREFIX = LLH; 101 | LastUpgradeCheck = 0810; 102 | ORGANIZATIONNAME = "David Keegan"; 103 | TargetAttributes = { 104 | 734C379315414CE200994189 = { 105 | DevelopmentTeam = MGTRPU24M6; 106 | SystemCapabilities = { 107 | com.apple.Sandbox = { 108 | enabled = 0; 109 | }; 110 | }; 111 | }; 112 | }; 113 | }; 114 | buildConfigurationList = 734C378E15414CE200994189 /* Build configuration list for PBXProject "LaunchAtLoginHelper" */; 115 | compatibilityVersion = "Xcode 3.2"; 116 | developmentRegion = English; 117 | hasScannedForEncodings = 0; 118 | knownRegions = ( 119 | en, 120 | ); 121 | mainGroup = 734C378915414CE200994189; 122 | productRefGroup = 734C379515414CE200994189 /* Products */; 123 | projectDirPath = ""; 124 | projectRoot = ""; 125 | targets = ( 126 | 734C379315414CE200994189 /* LaunchAtLoginHelper */, 127 | ); 128 | }; 129 | /* End PBXProject section */ 130 | 131 | /* Begin PBXResourcesBuildPhase section */ 132 | 734C379215414CE200994189 /* Resources */ = { 133 | isa = PBXResourcesBuildPhase; 134 | buildActionMask = 2147483647; 135 | files = ( 136 | 73C920EB15414E7800C2A75A /* MainMenu.xib in Resources */, 137 | ); 138 | runOnlyForDeploymentPostprocessing = 0; 139 | }; 140 | /* End PBXResourcesBuildPhase section */ 141 | 142 | /* Begin PBXSourcesBuildPhase section */ 143 | 734C379015414CE200994189 /* Sources */ = { 144 | isa = PBXSourcesBuildPhase; 145 | buildActionMask = 2147483647; 146 | files = ( 147 | 73C920E915414E7800C2A75A /* LLHAppDelegate.m in Sources */, 148 | 73C920EA15414E7800C2A75A /* main.m in Sources */, 149 | ); 150 | runOnlyForDeploymentPostprocessing = 0; 151 | }; 152 | /* End PBXSourcesBuildPhase section */ 153 | 154 | /* Begin XCBuildConfiguration section */ 155 | 734C37B015414CE200994189 /* Debug */ = { 156 | isa = XCBuildConfiguration; 157 | buildSettings = { 158 | ALWAYS_SEARCH_USER_PATHS = NO; 159 | CLANG_WARN_BOOL_CONVERSION = YES; 160 | CLANG_WARN_CONSTANT_CONVERSION = YES; 161 | CLANG_WARN_EMPTY_BODY = YES; 162 | CLANG_WARN_ENUM_CONVERSION = YES; 163 | CLANG_WARN_INFINITE_RECURSION = YES; 164 | CLANG_WARN_INT_CONVERSION = YES; 165 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 166 | CLANG_WARN_UNREACHABLE_CODE = YES; 167 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 168 | COMBINE_HIDPI_IMAGES = YES; 169 | COPY_PHASE_STRIP = NO; 170 | ENABLE_STRICT_OBJC_MSGSEND = YES; 171 | ENABLE_TESTABILITY = YES; 172 | GCC_C_LANGUAGE_STANDARD = gnu99; 173 | GCC_DYNAMIC_NO_PIC = NO; 174 | GCC_ENABLE_OBJC_EXCEPTIONS = YES; 175 | GCC_NO_COMMON_BLOCKS = YES; 176 | GCC_OPTIMIZATION_LEVEL = 0; 177 | GCC_PREPROCESSOR_DEFINITIONS = ( 178 | "DEBUG=1", 179 | "$(inherited)", 180 | ); 181 | GCC_SYMBOLS_PRIVATE_EXTERN = NO; 182 | GCC_VERSION = com.apple.compilers.llvm.clang.1_0; 183 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 184 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 185 | GCC_WARN_UNDECLARED_SELECTOR = YES; 186 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 187 | GCC_WARN_UNUSED_FUNCTION = YES; 188 | GCC_WARN_UNUSED_VARIABLE = YES; 189 | MACOSX_DEPLOYMENT_TARGET = 10.6; 190 | ONLY_ACTIVE_ARCH = YES; 191 | SDKROOT = macosx; 192 | SKIP_INSTALL = YES; 193 | }; 194 | name = Debug; 195 | }; 196 | 734C37B115414CE200994189 /* Release */ = { 197 | isa = XCBuildConfiguration; 198 | buildSettings = { 199 | ALWAYS_SEARCH_USER_PATHS = NO; 200 | CLANG_WARN_BOOL_CONVERSION = YES; 201 | CLANG_WARN_CONSTANT_CONVERSION = YES; 202 | CLANG_WARN_EMPTY_BODY = YES; 203 | CLANG_WARN_ENUM_CONVERSION = YES; 204 | CLANG_WARN_INFINITE_RECURSION = YES; 205 | CLANG_WARN_INT_CONVERSION = YES; 206 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 207 | CLANG_WARN_UNREACHABLE_CODE = YES; 208 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 209 | COMBINE_HIDPI_IMAGES = YES; 210 | COPY_PHASE_STRIP = YES; 211 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 212 | ENABLE_STRICT_OBJC_MSGSEND = YES; 213 | GCC_C_LANGUAGE_STANDARD = gnu99; 214 | GCC_ENABLE_OBJC_EXCEPTIONS = YES; 215 | GCC_NO_COMMON_BLOCKS = YES; 216 | GCC_VERSION = com.apple.compilers.llvm.clang.1_0; 217 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 218 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 219 | GCC_WARN_UNDECLARED_SELECTOR = YES; 220 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 221 | GCC_WARN_UNUSED_FUNCTION = YES; 222 | GCC_WARN_UNUSED_VARIABLE = YES; 223 | MACOSX_DEPLOYMENT_TARGET = 10.6; 224 | SDKROOT = macosx; 225 | SKIP_INSTALL = YES; 226 | }; 227 | name = Release; 228 | }; 229 | 734C37B315414CE200994189 /* Debug */ = { 230 | isa = XCBuildConfiguration; 231 | buildSettings = { 232 | CODE_SIGN_IDENTITY = "Mac Developer"; 233 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; 234 | INFOPLIST_FILE = "LaunchAtLoginHelper/LaunchAtLoginHelper-Info.plist"; 235 | MACOSX_DEPLOYMENT_TARGET = 10.6.8; 236 | PRODUCT_BUNDLE_IDENTIFIER = com.producthunt.producthuntosx.LaunchAtLoginHelper; 237 | PRODUCT_NAME = "$(TARGET_NAME)"; 238 | PROVISIONING_PROFILE = ""; 239 | WRAPPER_EXTENSION = app; 240 | }; 241 | name = Debug; 242 | }; 243 | 734C37B415414CE200994189 /* Release */ = { 244 | isa = XCBuildConfiguration; 245 | buildSettings = { 246 | CODE_SIGN_IDENTITY = "Mac Developer"; 247 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; 248 | INFOPLIST_FILE = "LaunchAtLoginHelper/LaunchAtLoginHelper-Info.plist"; 249 | MACOSX_DEPLOYMENT_TARGET = 10.6.8; 250 | PRODUCT_BUNDLE_IDENTIFIER = com.producthunt.producthuntosx.LaunchAtLoginHelper; 251 | PRODUCT_NAME = "$(TARGET_NAME)"; 252 | PROVISIONING_PROFILE = ""; 253 | WRAPPER_EXTENSION = app; 254 | }; 255 | name = Release; 256 | }; 257 | E7AC4E021CA945340099AB0E /* Testing */ = { 258 | isa = XCBuildConfiguration; 259 | buildSettings = { 260 | ALWAYS_SEARCH_USER_PATHS = NO; 261 | CLANG_WARN_BOOL_CONVERSION = YES; 262 | CLANG_WARN_CONSTANT_CONVERSION = YES; 263 | CLANG_WARN_EMPTY_BODY = YES; 264 | CLANG_WARN_ENUM_CONVERSION = YES; 265 | CLANG_WARN_INFINITE_RECURSION = YES; 266 | CLANG_WARN_INT_CONVERSION = YES; 267 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 268 | CLANG_WARN_UNREACHABLE_CODE = YES; 269 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 270 | COMBINE_HIDPI_IMAGES = YES; 271 | COPY_PHASE_STRIP = NO; 272 | ENABLE_STRICT_OBJC_MSGSEND = YES; 273 | ENABLE_TESTABILITY = YES; 274 | GCC_C_LANGUAGE_STANDARD = gnu99; 275 | GCC_DYNAMIC_NO_PIC = NO; 276 | GCC_ENABLE_OBJC_EXCEPTIONS = YES; 277 | GCC_NO_COMMON_BLOCKS = YES; 278 | GCC_OPTIMIZATION_LEVEL = 0; 279 | GCC_PREPROCESSOR_DEFINITIONS = ( 280 | "DEBUG=1", 281 | "$(inherited)", 282 | ); 283 | GCC_SYMBOLS_PRIVATE_EXTERN = NO; 284 | GCC_VERSION = com.apple.compilers.llvm.clang.1_0; 285 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 286 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 287 | GCC_WARN_UNDECLARED_SELECTOR = YES; 288 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 289 | GCC_WARN_UNUSED_FUNCTION = YES; 290 | GCC_WARN_UNUSED_VARIABLE = YES; 291 | MACOSX_DEPLOYMENT_TARGET = 10.6; 292 | ONLY_ACTIVE_ARCH = YES; 293 | SDKROOT = macosx; 294 | SKIP_INSTALL = YES; 295 | }; 296 | name = Testing; 297 | }; 298 | E7AC4E031CA945340099AB0E /* Testing */ = { 299 | isa = XCBuildConfiguration; 300 | buildSettings = { 301 | CODE_SIGN_IDENTITY = "Mac Developer"; 302 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; 303 | INFOPLIST_FILE = "LaunchAtLoginHelper/LaunchAtLoginHelper-Info.plist"; 304 | MACOSX_DEPLOYMENT_TARGET = 10.6.8; 305 | PRODUCT_BUNDLE_IDENTIFIER = com.producthunt.producthuntosx.LaunchAtLoginHelper; 306 | PRODUCT_NAME = "$(TARGET_NAME)"; 307 | PROVISIONING_PROFILE = ""; 308 | WRAPPER_EXTENSION = app; 309 | }; 310 | name = Testing; 311 | }; 312 | /* End XCBuildConfiguration section */ 313 | 314 | /* Begin XCConfigurationList section */ 315 | 734C378E15414CE200994189 /* Build configuration list for PBXProject "LaunchAtLoginHelper" */ = { 316 | isa = XCConfigurationList; 317 | buildConfigurations = ( 318 | 734C37B015414CE200994189 /* Debug */, 319 | E7AC4E021CA945340099AB0E /* Testing */, 320 | 734C37B115414CE200994189 /* Release */, 321 | ); 322 | defaultConfigurationIsVisible = 0; 323 | defaultConfigurationName = Release; 324 | }; 325 | 734C37B215414CE200994189 /* Build configuration list for PBXNativeTarget "LaunchAtLoginHelper" */ = { 326 | isa = XCConfigurationList; 327 | buildConfigurations = ( 328 | 734C37B315414CE200994189 /* Debug */, 329 | E7AC4E031CA945340099AB0E /* Testing */, 330 | 734C37B415414CE200994189 /* Release */, 331 | ); 332 | defaultConfigurationIsVisible = 0; 333 | defaultConfigurationName = Release; 334 | }; 335 | /* End XCConfigurationList section */ 336 | }; 337 | rootObject = 734C378B15414CE200994189 /* Project object */; 338 | } 339 | --------------------------------------------------------------------------------