├── .github └── workflows │ ├── ci.yml │ └── stale.yml ├── .gitignore ├── .swiftlint.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SnapCat.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── SnapCat ├── Activity │ ├── ActivityView.swift │ ├── ActivityViewModel.swift │ ├── EmptyActivityView.swift │ └── EmptyActivityViewController.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── 120-1.png │ │ ├── 120.png │ │ ├── 152.png │ │ ├── 167.png │ │ ├── 180.png │ │ ├── 20.png │ │ ├── 29.png │ │ ├── 40-1.png │ │ ├── 40-2.png │ │ ├── 40.png │ │ ├── 58-1.png │ │ ├── 58.png │ │ ├── 60.png │ │ ├── 76.png │ │ ├── 80-1.png │ │ ├── 80.png │ │ ├── 87.png │ │ ├── Contents.json │ │ └── appstore.png │ ├── Contents.json │ ├── Snapcat.imageset │ │ ├── 1024.png │ │ ├── 256.png │ │ ├── 512.png │ │ └── Contents.json │ └── netrecon.imageset │ │ ├── Contents.json │ │ ├── netrecon-1.png │ │ ├── netrecon-2.png │ │ └── netrecon.png ├── Constants.swift ├── CustomViewModels │ ├── CloudImageViewModel.swift │ └── QueryImageViewModel.swift ├── Environment │ └── TintColorKey.swift ├── Explore │ ├── EmptyExploreView.swift │ ├── EmptyExploreViewController.swift │ ├── ExploreView.swift │ ├── ExploreViewModel.swift │ └── SearchBarView.swift ├── Extentions │ ├── Date+extension.swift │ ├── Logger+extension.swift │ ├── ParseSwift+extention.swift │ ├── UIImage+extension.swift │ └── View+extension.swift ├── Home │ ├── CommentView.swift │ ├── CommentViewModel.swift │ ├── EmptyTimeLineView.swift │ ├── EmptyTimeLineViewController.swift │ ├── HomeView.swift │ ├── HomeViewModel.swift │ ├── ImagePickerView.swift │ ├── PostView.swift │ ├── PostViewModel.swift │ ├── TimeLineCommentsView.swift │ ├── TimeLineLikeCommentView.swift │ ├── TimeLinePostView.swift │ ├── TimeLineView.swift │ ├── TimeLineViewModel.swift │ └── ViewAllComments.swift ├── Info.plist ├── Main │ ├── EmptyDefaultViewController.swift │ └── MainView.swift ├── Models │ ├── Activity.swift │ ├── Installation.swift │ ├── Post.swift │ └── User.swift ├── Onboarding │ ├── OnboardingView.swift │ └── OnboardingViewModel.swift ├── ParseSwift.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Profile │ ├── ProfileEditView.swift │ ├── ProfileHeaderView.swift │ ├── ProfileUserDetailsView.swift │ ├── ProfileView.swift │ ├── ProfileViewModel.swift │ ├── SettingsView.swift │ └── SettingsViewModel.swift ├── SnapCat.entitlements ├── SnapCatApp.swift ├── SnapCatError.swift ├── Utility.swift └── ViewModels │ ├── PostStatus.swift │ └── UserStatus.swift ├── SnapCatTests ├── Info.plist └── SnapCatTests.swift └── SnapCatUITests ├── Info.plist └── SnapCatUITests.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | env: 9 | CI_XCODE: '/Applications/Xcode_14.2.app/Contents/Developer' 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | ios: 17 | runs-on: macos-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Build-Test 21 | run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project SnapCat.xcodeproj -scheme SnapCat -destination platform\=iOS\ Simulator,name\=iPhone\ 12\ Pro\ Max build | xcpretty 22 | env: 23 | DEVELOPER_DIR: ${{ env.CI_XCODE }} 24 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | stale: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/stale@v1 14 | with: 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | stale-issue-message: 'Stale issue message' 17 | stale-pr-message: 'Stale pull request message' 18 | stale-issue-label: 'no-issue-activity' 19 | stale-pr-label: 'no-pr-activity' 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | .DS_Store 11 | *.pbxuser 12 | !default.pbxuser 13 | *.mode1v3 14 | !default.mode1v3 15 | *.mode2v3 16 | !default.mode2v3 17 | *.perspectivev3 18 | !default.perspectivev3 19 | xcuserdata/ 20 | 21 | ## Other 22 | *.moved-aside 23 | *.xcuserstate 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | .build/ 40 | 41 | # CocoaPods 42 | # 43 | # We recommend against adding the Pods directory to your .gitignore. However 44 | # you should judge for yourself, the pros and cons are mentioned at: 45 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 46 | # 47 | # Pods/ 48 | 49 | # Carthage 50 | # 51 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 52 | # Carthage/Checkouts 53 | 54 | Carthage/Build 55 | 56 | # fastlane 57 | # 58 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 59 | # screenshots whenever they are needed. 60 | # For more information about the recommended setup visit: 61 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 62 | 63 | fastlane/report.xml 64 | fastlane/Preview.html 65 | fastlane/screenshots 66 | fastlane/test_output 67 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: # paths to ignore during linting. Takes precedence over `included`. 2 | - DerivedData 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SnapCat 2 | 3 | If you are not familiar with Pull Requests and want to know more about them, you can visit the [Creating a pull request](https://help.github.com/articles/creating-a-pull-request/) article. It contains detailed information about the process. 4 | 5 | ## Setting up your local machine 6 | 7 | * [Fork](https://github.com/netreconlab/SnapCat.git) this project and clone the fork on to your local machine: 8 | 9 | ```sh 10 | $ git clone https://github.com/netreconlab/SnapCat.git 11 | $ cd ParseCareKit # go into the clone directory 12 | ``` 13 | 14 | * Please install [SwiftLint](https://github.com/realm/SwiftLint) to ensure that your PR conforms to our coding standards: 15 | 16 | ```sh 17 | $ brew install swiftlint 18 | ``` 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Network Reconnaissance Lab 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SnapCat 2 | ![Swift](https://img.shields.io/badge/swift-5.5-brightgreen.svg) ![Xcode 13.2+](https://img.shields.io/badge/xcode-13.2%2B-blue.svg) ![iOS 15.0+](https://img.shields.io/badge/iOS-15.0%2B-blue.svg) ![ci](https://github.com/netreconlab/SnapCat/workflows/ci/badge.svg?branch=main) 3 | 4 | 5 | 6 | SnapCat is a social media application for posting pictures, comments, and finding friends. SnapCat is designed using SwiftUI and the [ParseSwift SDK](https://github.com/netreconlab/Parse-Swift). The app is meant to serve as a base app for University of Kentucky graudate researchers and undergraduate students learning iOS mobile app development. 7 | 8 | ## Setup Your Parse Server 9 | You can setup your parse-server locally to test using [snapcat branch](https://github.com/netreconlab/parse-hipaa/tree/snapcat) of [parse-hipaa](https://github.com/netreconlab/parse-hipaa). Simply type the following to get your parse-server running with postgres locally: 10 | 11 | 1. Fork [parse-hipaa](https://github.com/netreconlab/parse-hipaa/tree/snapcat) 12 | 2. `cd parse-hipaa` 13 | 3. `docker-compose up` - this will take a couple of minutes to setup as it needs to initialize postgres, but as soon as you see `parse-server running on port 1337.`, it's ready to go. See [here](https://github.com/netreconlab/parse-hipaa#getting-started) for details 14 | 4. If you would like to use mongo instead of postgres, in step 3, type `docker-compose -f docker-compose.mongo.yml up` instead of `docker-compose up` 15 | 16 | ## Fork this repo 17 | 18 | 1. Fork [SnapCat](https://github.com/netreconlab/SnapCat.git), in particular the [snapcat branch](https://github.com/netreconlab/parse-hipaa/tree/snapcat). 19 | 2. Open `SnapCat.xcodeproj` in Xcode 20 | 3. You may need to configure your "Team" and "Bundle Identifier" in "Signing and Capabilities" 21 | 4. Run the app and data will synchronize with parse-hipaa via http://localhost:1337/parse automatically 22 | 5. You can edit Parse server setup in the ParseSwift.plist file in the Xcode browser 23 | 24 | ## View your data in Parse Dashboard 25 | Parse Dashboard is the easiest way to view your data in the Cloud (or local machine in this example) and comes with [parse-hipaa](https://github.com/netreconlab/parse-hipaa). To access: 26 | 1. Open your browser and go to http://localhost:4040/dashboard 27 | 2. Username: `parse` 28 | 3. Password: `1234` 29 | 4. Be sure to refresh your browser to see new changes synched from your CareKitSample app 30 | -------------------------------------------------------------------------------- /SnapCat.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SnapCat.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SnapCat.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "dznemptydataset", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/dzenbot/DZNEmptyDataSet.git", 7 | "state" : { 8 | "branch" : "master", 9 | "revision" : "9bffa69a83a9fa58a14b3cf43cb6dd8a63774179" 10 | } 11 | }, 12 | { 13 | "identity" : "parse-swift", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/netreconlab/Parse-Swift.git", 16 | "state" : { 17 | "revision" : "a5ba8cb16651ea189b888981e98c2606dc73573d", 18 | "version" : "4.16.2" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /SnapCat/Activity/ActivityView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ActivityView: View { 12 | @ObservedObject var followingsActivityViewModel = ActivityViewModel 13 | .queryFollowingsActivity 14 | .include(ActivityKey.fromUser) 15 | .include(ActivityKey.toUser) 16 | .viewModel 17 | 18 | var body: some View { 19 | if !followingsActivityViewModel.results.isEmpty { 20 | List(followingsActivityViewModel.results, id: \.id) { result in 21 | VStack { 22 | HStack { 23 | if let fromUsername = result.fromUser?.username { 24 | Text("@\(fromUsername)") 25 | .font(.headline) 26 | } 27 | if let activity = result.type { 28 | switch activity { 29 | case .like: 30 | Text("liked") 31 | case .follow: 32 | Text("followed") 33 | case .comment: 34 | Text("commented on") 35 | } 36 | } 37 | if let fromUsername = result.toUser?.username { 38 | Text("@\(fromUsername)") 39 | .font(.headline) 40 | } 41 | Spacer() 42 | } 43 | if let createdAt = result.createdAt { 44 | HStack { 45 | Text(createdAt.relativeTime) 46 | .font(.footnote) 47 | Spacer() 48 | } 49 | } 50 | } 51 | } 52 | .onAppear(perform: { 53 | followingsActivityViewModel.find() 54 | }) 55 | } else { 56 | EmptyActivityView() 57 | } 58 | } 59 | 60 | init () { 61 | followingsActivityViewModel.find() 62 | } 63 | } 64 | 65 | struct ActivityView_Previews: PreviewProvider { 66 | static var previews: some View { 67 | ActivityView() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /SnapCat/Activity/ActivityViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | 13 | class ActivityViewModel: ObservableObject { 14 | 15 | class var queryFollowingsActivity: Query { 16 | let followings = ProfileViewModel.queryFollowings() 17 | return Activity.query(matchesKeyInQuery(key: ActivityKey.fromUser, 18 | queryKey: ActivityKey.toUser, 19 | query: followings)) 20 | .order([.descending(ParseKey.createdAt)]) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /SnapCat/Activity/EmptyActivityView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyActivityView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | 12 | struct EmptyActivityView: UIViewControllerRepresentable { 13 | 14 | func makeUIViewController(context: Context) -> some UIViewController { 15 | let view = EmptyActivityViewController() 16 | let viewController = UINavigationController(rootViewController: view) 17 | viewController.navigationBar.isHidden = true 18 | return viewController 19 | } 20 | 21 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { 22 | 23 | } 24 | } 25 | 26 | struct EmptyActivityView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | EmptyActivityView() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /SnapCat/Activity/EmptyActivityViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyActivityViewController.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import DZNEmptyDataSet 12 | 13 | class EmptyActivityViewController: EmptyDefaultViewController { 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | } 18 | 19 | override func viewDidAppear(_ animated: Bool) { 20 | super.viewDidAppear(animated) 21 | } 22 | 23 | override func title(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString { 24 | let text = "Activity" 25 | 26 | let attributes = [ 27 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 18.0), 28 | NSAttributedString.Key.foregroundColor: UIColor.darkGray 29 | ] 30 | 31 | return NSAttributedString(string: text, attributes: attributes) 32 | } 33 | 34 | override func description(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString { 35 | let text = "See acitivity from people you follow" 36 | 37 | let paragragh = NSMutableParagraphStyle() 38 | paragragh.lineBreakMode = NSLineBreakMode.byWordWrapping 39 | paragragh.alignment = NSTextAlignment.center 40 | 41 | let attributes = [ 42 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 14.0), 43 | NSAttributedString.Key.foregroundColor: UIColor.lightGray, 44 | NSAttributedString.Key.paragraphStyle: paragragh 45 | ] 46 | 47 | return NSAttributedString(string: text, attributes: attributes) 48 | } 49 | 50 | override func buttonTitle(forEmptyDataSet scrollView: UIScrollView, 51 | for state: UIControl.State) -> NSAttributedString { 52 | let attributes = [ 53 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 17.0) 54 | ] 55 | 56 | return NSAttributedString(string: "Tap here to find friends", attributes: attributes) 57 | } 58 | 59 | override func image(forEmptyDataSet scrollView: UIScrollView) -> UIImage? { 60 | 61 | if var image = UIImage(systemName: "heart") { 62 | 63 | image = image.tint(UIColor.lightGray, blendMode: CGBlendMode.color) 64 | image = image.imageRotatedByDegrees(180, flip: false) 65 | return image 66 | } 67 | return UIImage() 68 | 69 | } 70 | 71 | override func emptyDataSet(_ scrollView: UIScrollView, didTap view: UIView) { 72 | presentView() 73 | } 74 | 75 | override func emptyDataSet(_ scrollView: UIScrollView, didTap button: UIButton) { 76 | presentView() 77 | } 78 | 79 | func presentView() { 80 | let friendsViewController = ExploreView().formattedHostingController() 81 | friendsViewController.modalPresentationStyle = .popover 82 | present(friendsViewController, animated: true, completion: nil) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/120-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/120-1.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/40-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/40-1.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/40-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/40-2.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/58-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/58-1.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/76.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/80-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/80-1.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "58.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "87.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "80.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "120.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "120-1.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "180.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "40-1.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "58-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "40-2.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "80-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "152.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "167.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "appstore.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/AppIcon.appiconset/appstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/AppIcon.appiconset/appstore.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/Snapcat.imageset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/Snapcat.imageset/1024.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/Snapcat.imageset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/Snapcat.imageset/256.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/Snapcat.imageset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/Snapcat.imageset/512.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/Snapcat.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "256.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "512.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "1024.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/netrecon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "netrecon.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "netrecon-1.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "netrecon-2.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/netrecon.imageset/netrecon-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/netrecon.imageset/netrecon-1.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/netrecon.imageset/netrecon-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/netrecon.imageset/netrecon-2.png -------------------------------------------------------------------------------- /SnapCat/Assets.xcassets/netrecon.imageset/netrecon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netreconlab/SnapCat/3098fcbe474a8e623c227d58f4883df9de334718/SnapCat/Assets.xcassets/netrecon.imageset/netrecon.png -------------------------------------------------------------------------------- /SnapCat/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum Constants { 12 | static let firstRun = "FirstRun" 13 | static let lastProfilePicURL = "lastProfilePicURL" 14 | static let numberOfImagesToDownload = 50 15 | static let fileDownloadsDirectory = "Downloads" 16 | } 17 | 18 | // MARK: - Standard Parse Class Keys 19 | enum ParseKey { 20 | static let objectId = "objectId" 21 | static let createdAt = "createdAt" 22 | static let updatedAt = "updatedAt" 23 | static let ACL = "ACL" 24 | } 25 | 26 | // MARK: - User Class Keys 27 | enum UserKey { 28 | static let username = "username" 29 | static let password = "password" 30 | static let email = "email" 31 | static let emailVerified = "emailVerified" 32 | static let authData = "authData" 33 | static let name = "name" 34 | static let profileImage = "profileImage" 35 | static let profileThumbnail = "profileThumbnail" 36 | static let bio = "bio" 37 | static let link = "link" 38 | } 39 | 40 | // MARK: - Installation Class Keys 41 | enum InstallationKey { 42 | static let deviceType = "deviceType" 43 | static let installationId = "installationId" 44 | static let deviceToken = "deviceToken" 45 | static let badge = "badge" 46 | static let timeZone = "timeZone" 47 | static let channels = "channels" 48 | static let appName = "appName" 49 | static let appIdentifier = "appIdentifier" 50 | static let parseVersion = "parseVersion" 51 | static let localeIdentifier = "localeIdentifier" 52 | } 53 | 54 | // MARK: - Activity Class Keys 55 | enum ActivityKey { 56 | static let fromUser = "fromUser" 57 | static let toUser = "toUser" 58 | static let type = "type" 59 | static let comment = "comment" 60 | static let post = "post" 61 | static let activity = "activity" 62 | } 63 | 64 | // MARK: - Post Class Keys 65 | enum PostKey { 66 | static let user = "user" 67 | static let image = "image" 68 | static let thumbnail = "thumbnail" 69 | static let location = "location" 70 | static let caption = "caption" 71 | } 72 | -------------------------------------------------------------------------------- /SnapCat/CustomViewModels/CloudImageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudImageViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/12/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | 12 | class CloudImageViewModel: CloudViewModel { 13 | 14 | override var results: T.ReturnType? { 15 | willSet { 16 | 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /SnapCat/CustomViewModels/QueryImageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryImageViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/12/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | import UIKit 13 | 14 | class QueryImageViewModel: Subscription { 15 | 16 | override var results: [QueryViewModel.Object] { 17 | willSet { 18 | count = newValue.count 19 | if let posts = newValue as? [Post] { 20 | postResults = posts 21 | } else if let users = newValue as? [User] { 22 | userResults = users 23 | } 24 | DispatchQueue.main.async { 25 | self.objectWillChange.send() 26 | } 27 | } 28 | } 29 | 30 | override var event: (query: Query, event: Event)? { 31 | willSet { 32 | guard let event = newValue?.event else { 33 | return 34 | } 35 | switch event { 36 | 37 | case .created(let object), .entered(let object): 38 | if var post = object as? Post { 39 | // LiveQuery doesn't include pointers, need to check to fetch 40 | guard let userObjectId = post.user?.id, 41 | let user = relatedUser[userObjectId] else { 42 | let currentPost = post 43 | Task { 44 | do { 45 | let fetchPost = try await currentPost.fetch(includeKeys: [PostKey.user]) 46 | if let parseObject = fetchPost as? T { 47 | self.results.insert(parseObject, at: 0) 48 | } 49 | } catch { 50 | Logger.post.error("Couldn't fetch \(error.localizedDescription)") 51 | } 52 | } 53 | return 54 | } 55 | post.user = user 56 | if let parseObject = post as? T { 57 | self.results.insert(parseObject, at: 0) 58 | } 59 | } 60 | 61 | case .updated(let object): 62 | guard let index = results.firstIndex(where: { $0.hasSameObjectId(as: object) }) else { 63 | return 64 | } 65 | if var post = object as? Post { 66 | // LiveQuery doesn't include pointers, need to check to fetch 67 | guard let userObjectId = post.user?.id, 68 | let user = relatedUser[userObjectId] else { 69 | let currentPost = post 70 | Task { 71 | do { 72 | let fetchPost = try await currentPost 73 | .fetch(includeKeys: [PostKey.user]) 74 | if let parseObject = fetchPost as? T { 75 | self.results[index] = parseObject 76 | } 77 | } catch { 78 | Logger.post.error("Couldn't fetch \(error.localizedDescription)") 79 | } 80 | } 81 | return 82 | } 83 | post.user = user 84 | if let parseObject = post as? T { 85 | self.results[index] = parseObject 86 | } 87 | } 88 | 89 | case .deleted(let object): 90 | guard let index = results.firstIndex(where: { $0.hasSameObjectId(as: object) }) else { 91 | return 92 | } 93 | results.remove(at: index) 94 | default: 95 | break 96 | } 97 | subscribed = nil 98 | unsubscribed = nil 99 | } 100 | } 101 | 102 | var userOfInterest: User? 103 | 104 | var postResults = [Post]() { 105 | willSet { 106 | newValue.forEach { object in 107 | storeRelatedUser(object.user) 108 | if likes[object.id] == nil { 109 | Task { 110 | do { 111 | let foundLikes = try await PostViewModel 112 | .queryLikes(post: object) 113 | .find() 114 | DispatchQueue.main.async { 115 | self.likes[object.id] = foundLikes 116 | } 117 | } catch { 118 | Logger.post.error("QueryImageViewModel: couldn't find likes: \(error.localizedDescription)") 119 | } 120 | } 121 | } 122 | if comments[object.id] == nil { 123 | Task { 124 | do { 125 | let foundComments = try await PostViewModel 126 | .queryComments(post: object) 127 | .include(ActivityKey.fromUser) 128 | .find() 129 | DispatchQueue.main.async { 130 | self.comments[object.id] = foundComments 131 | } 132 | } catch { 133 | // swiftlint:disable:next line_length 134 | Logger.post.error("QueryImageViewModel: couldn't find comments: \(error.localizedDescription)") 135 | } 136 | } 137 | } 138 | // Fetch images 139 | if imageResults.count >= Constants.numberOfImagesToDownload { 140 | return 141 | } 142 | guard imageResults[object.id] == nil else { 143 | return 144 | } 145 | Task { 146 | if let image = await Utility.fetchImage(object.image) { 147 | DispatchQueue.main.async { 148 | self.imageResults[object.id] = image 149 | } 150 | } 151 | } 152 | Task { 153 | if let image = await Utility.fetchImage(object.user?.profileImage) { 154 | if let userObjectId = object.user?.id { 155 | DispatchQueue.main.async { 156 | self.imageResults[userObjectId] = image 157 | } 158 | } 159 | } 160 | 161 | } 162 | } 163 | DispatchQueue.main.async { 164 | self.objectWillChange.send() 165 | } 166 | } 167 | } 168 | 169 | var userResults = [User]() { 170 | willSet { 171 | newValue.forEach { object in 172 | storeRelatedUser(object) 173 | // Fetch images 174 | if imageResults.count == Constants.numberOfImagesToDownload { 175 | return 176 | } 177 | guard imageResults[object.id] == nil else { 178 | return 179 | } 180 | Task { 181 | if let image = await Utility.fetchImage(object.profileImage) { 182 | DispatchQueue.main.async { 183 | self.imageResults[object.id] = image 184 | } 185 | } 186 | } 187 | } 188 | } 189 | } 190 | 191 | /// Contains all fetched images. 192 | var imageResults = [String: UIImage]() { 193 | willSet { 194 | DispatchQueue.main.async { 195 | self.objectWillChange.send() 196 | } 197 | } 198 | } 199 | 200 | /// Contains all fetched thumbnail images. 201 | var thubmNailImageResults = [String: UIImage]() { 202 | willSet { 203 | DispatchQueue.main.async { 204 | self.objectWillChange.send() 205 | } 206 | } 207 | } 208 | 209 | /// Contains likes for each post 210 | var likes = [String: [Activity]]() { 211 | willSet { 212 | DispatchQueue.main.async { 213 | self.objectWillChange.send() 214 | } 215 | } 216 | } 217 | 218 | /// Contains comments for each post 219 | var comments = [String: [Activity]]() { 220 | willSet { 221 | DispatchQueue.main.async { 222 | self.objectWillChange.send() 223 | } 224 | } 225 | } 226 | 227 | var relatedUser = [String: User]() 228 | 229 | // MARK: Helpers 230 | func storeRelatedUser(_ user: User?) { 231 | guard let userObjectId = user?.id, 232 | relatedUser[userObjectId] == nil else { 233 | return 234 | } 235 | relatedUser[userObjectId] = user 236 | } 237 | 238 | func isLikedPost(_ post: Post, userObjectId: String? = nil) -> Bool { 239 | let userOfInterest: String! 240 | if let user = userObjectId { 241 | userOfInterest = user 242 | } else { 243 | guard let currentUser = User.current?.id else { 244 | Logger.home.error("User is suppose to be logged") 245 | return false 246 | } 247 | userOfInterest = currentUser 248 | } 249 | guard let activities = likes[post.id], 250 | activities.first(where: { $0.fromUser?.objectId == userOfInterest }) != nil else { 251 | return false 252 | } 253 | return true 254 | } 255 | 256 | func isCommentedOnPost(_ post: Post, userObjectId: String? = nil) -> Bool { 257 | let userOfInterest: String! 258 | if let user = userObjectId { 259 | userOfInterest = user 260 | } else { 261 | guard let currentUser = User.current?.id else { 262 | Logger.home.error("User is suppose to be logged") 263 | return false 264 | } 265 | userOfInterest = currentUser 266 | } 267 | guard let activities = comments[post.id], 268 | activities.first(where: { $0.fromUser?.objectId == userOfInterest }) != nil else { 269 | return false 270 | } 271 | return true 272 | } 273 | } 274 | 275 | // MARK: ParseLiveQuery 276 | public extension ParseLiveQuery { 277 | internal func subscribeCustom(_ query: Query) throws -> QueryImageViewModel { 278 | try subscribe(QueryImageViewModel(query: query)) 279 | } 280 | } 281 | 282 | // MARK: QueryImageViewModel 283 | public extension Query { 284 | 285 | /** 286 | Registers the query for live updates, using the default subscription handler, 287 | and the default `ParseLiveQuery` client. Suitable for `ObjectObserved` 288 | as the subscription can be used as a SwiftUI publisher. Meaning it can serve 289 | indepedently as a ViewModel in MVVM. 290 | */ 291 | internal var subscribeCustom: QueryImageViewModel? { 292 | try? ParseLiveQuery.client?.subscribeCustom(self) 293 | } 294 | 295 | /** 296 | Registers the query for live updates, using the default subscription handler, 297 | and a specific `ParseLiveQuery` client. Suitable for `ObjectObserved` 298 | as the subscription can be used as a SwiftUI publisher. Meaning it can serve 299 | indepedently as a ViewModel in MVVM. 300 | - parameter client: A specific client. 301 | - returns: The subscription that has just been registered 302 | */ 303 | internal func subscribeCustom(_ client: ParseLiveQuery) async throws -> QueryImageViewModel { 304 | guard try await Utility.isServerAvailable() else { 305 | let errorMessage = "Server health is not \"ok\"" 306 | Logger.queryImageViewModel.error("\(errorMessage)") 307 | throw SnapCatError(message: errorMessage) 308 | } 309 | return try client.subscribe(QueryImageViewModel(query: self)) 310 | } 311 | 312 | /** 313 | Creates a view model for this query. Suitable for `ObjectObserved` 314 | as the view model can be used as a SwiftUI publisher. Meaning it can serve 315 | indepedently as a ViewModel in MVVM. 316 | */ 317 | internal var imageViewModel: QueryImageViewModel { 318 | QueryImageViewModel(query: self) 319 | } 320 | 321 | /** 322 | Creates a view model for this query. Suitable for `ObjectObserved` 323 | as the view model can be used as a SwiftUI publisher. Meaning it can serve 324 | indepedently as a ViewModel in MVVM. 325 | - parameter query: Any query. 326 | - returns: The view model for this query. 327 | */ 328 | internal static func imageViewModel(_ query: Self) -> QueryImageViewModel { 329 | QueryImageViewModel(query: query) 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /SnapCat/Environment/TintColorKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TintColorKey.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 1/8/22. 6 | // Copyright © 2022 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct TintColorKey: EnvironmentKey { 13 | 14 | static var defaultValue: UIColor { 15 | UIColor { $0.userInterfaceStyle == .light ? #colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1) : #colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1) } 16 | } 17 | } 18 | 19 | extension EnvironmentValues { 20 | 21 | var tintColor: UIColor { 22 | self[TintColorKey.self] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SnapCat/Explore/EmptyExploreView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyExploreView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | 12 | struct EmptyExploreView: UIViewControllerRepresentable { 13 | 14 | func makeUIViewController(context: Context) -> some UIViewController { 15 | let view = EmptyExploreViewController() 16 | let viewController = UINavigationController(rootViewController: view) 17 | viewController.navigationBar.isHidden = true 18 | return viewController 19 | } 20 | 21 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { 22 | 23 | } 24 | } 25 | 26 | struct EmptyExploreView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | EmptyExploreView() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /SnapCat/Explore/EmptyExploreViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyExploreViewController.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import DZNEmptyDataSet 12 | 13 | class EmptyExploreViewController: EmptyDefaultViewController { 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | } 18 | 19 | override func viewDidAppear(_ animated: Bool) { 20 | super.viewDidAppear(animated) 21 | } 22 | 23 | override func title(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString { 24 | let text = "Explore" 25 | 26 | let attributes = [ 27 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 18.0), 28 | NSAttributedString.Key.foregroundColor: UIColor.darkGray 29 | ] 30 | 31 | return NSAttributedString(string: text, attributes: attributes) 32 | } 33 | 34 | override func description(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString { 35 | let text = "Follow, meet, and engage with people" 36 | 37 | let paragragh = NSMutableParagraphStyle() 38 | paragragh.lineBreakMode = NSLineBreakMode.byWordWrapping 39 | paragragh.alignment = NSTextAlignment.center 40 | 41 | let attributes = [ 42 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 14.0), 43 | NSAttributedString.Key.foregroundColor: UIColor.lightGray, 44 | NSAttributedString.Key.paragraphStyle: paragragh 45 | ] 46 | 47 | return NSAttributedString(string: text, attributes: attributes) 48 | } 49 | 50 | override func buttonTitle(forEmptyDataSet scrollView: UIScrollView, 51 | for state: UIControl.State) -> NSAttributedString { 52 | let attributes = [ 53 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 17.0) 54 | ] 55 | 56 | return NSAttributedString(string: "", attributes: attributes) 57 | } 58 | 59 | override func image(forEmptyDataSet scrollView: UIScrollView) -> UIImage? { 60 | 61 | if var image = UIImage(systemName: "magnifyingglass.circle") { 62 | 63 | image = image.tint(UIColor.lightGray, blendMode: CGBlendMode.color) 64 | image = image.imageRotatedByDegrees(180, flip: false) 65 | return image 66 | } 67 | return UIImage() 68 | 69 | } 70 | 71 | override func emptyDataSet(_ scrollView: UIScrollView, didTap view: UIView) { 72 | 73 | } 74 | 75 | override func emptyDataSet(_ scrollView: UIScrollView, didTap button: UIButton) { 76 | 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /SnapCat/Explore/ExploreView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ExploreView: View { 12 | 13 | @Environment(\.tintColor) private var tintColor 14 | @StateObject var viewModel = ExploreViewModel() 15 | @State var searchText: String = "" 16 | 17 | var body: some View { 18 | if !viewModel.users.isEmpty { 19 | VStack { 20 | SearchBarView(searchText: $searchText) 21 | ScrollView { 22 | ForEach(viewModel 23 | .users 24 | .filter({ 25 | // swiftlint:disable:next line_length 26 | searchText == "" ? true : $0.username!.lowercased().contains(searchText.lowercased()) 27 | }), id: \.id) { user in 28 | 29 | HStack { 30 | NavigationLink(destination: ProfileView(user: user, isShowingHeading: false), label: { 31 | if let image = viewModel.profileImages[user.id] { 32 | Image(uiImage: image) 33 | .resizable() 34 | .frame(width: 50, height: 50, alignment: .leading) 35 | .clipShape(Circle()) 36 | .shadow(radius: 3) 37 | .overlay(Circle().stroke(Color(tintColor), lineWidth: 1)) 38 | .padding() 39 | } else { 40 | Image(systemName: "person.circle") 41 | .resizable() 42 | .frame(width: 50, height: 50, alignment: .leading) 43 | .clipShape(Circle()) 44 | .shadow(radius: 3) 45 | .overlay(Circle().stroke(Color(tintColor), lineWidth: 1)) 46 | .padding() 47 | } 48 | VStack(alignment: .leading) { 49 | if let username = user.username { 50 | Text("@\(username)") 51 | .font(.headline) 52 | } 53 | HStack { 54 | if let name = user.name { 55 | Text(name) 56 | .font(.footnote) 57 | } 58 | if viewModel.isCurrentFollower(user) { 59 | Label("Follows You", 60 | systemImage: "checkmark.square.fill") 61 | } 62 | } 63 | } 64 | Spacer() 65 | }) 66 | if viewModel.isCurrentFollowing(user) { 67 | Button(action: { 68 | viewModel.unfollowUser(user) 69 | }, label: { 70 | Text("Unfollow") 71 | .foregroundColor(.white) 72 | .padding() 73 | .background(Color(#colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1))) 74 | .cornerRadius(15) 75 | }) 76 | } else { 77 | Button(action: { 78 | viewModel.followUser(user) 79 | }, label: { 80 | Text("Follow") 81 | .foregroundColor(.white) 82 | .padding() 83 | .background(Color(#colorLiteral(red: 0.7843137255, green: 0.7843137255, blue: 0.7843137255, alpha: 1))) 84 | .cornerRadius(15) 85 | }) 86 | } 87 | } 88 | Divider() 89 | } 90 | .navigationBarHidden(true) 91 | Spacer() 92 | } 93 | .padding() 94 | }.onAppear(perform: { 95 | viewModel.update() 96 | }) 97 | } else { 98 | VStack { 99 | EmptyExploreView() 100 | .onAppear(perform: { 101 | viewModel.update() 102 | }) 103 | Spacer() 104 | } 105 | } 106 | } 107 | } 108 | 109 | struct ExploreView_Previews: PreviewProvider { 110 | static var previews: some View { 111 | ExploreView() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /SnapCat/Explore/ExploreViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExploreViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | import UIKit 13 | 14 | @dynamicMemberLookup 15 | class ExploreViewModel: ObservableObject { 16 | var isSettingForFirstTime = true 17 | var isShowingFollowers: Bool? 18 | var followersViewModel: QueryViewModel? { 19 | willSet { 20 | if isSettingForFirstTime { 21 | guard let followers = newValue else { 22 | return 23 | } 24 | users = followers.results.compactMap { $0.fromUser } 25 | } 26 | } 27 | } 28 | var followingsViewModel: QueryViewModel? { 29 | willSet { 30 | if isSettingForFirstTime { 31 | guard let followings = newValue else { 32 | return 33 | } 34 | users = followings.results.compactMap { $0.toUser } 35 | } 36 | } 37 | } 38 | var users = [User]() { 39 | willSet { 40 | newValue.forEach { object in 41 | // Fetch images 42 | if profileImages.count == Constants.numberOfImagesToDownload { 43 | return 44 | } 45 | guard profileImages[object.id] == nil else { 46 | return 47 | } 48 | Task { 49 | if let image = await Utility.fetchImage(object.profileImage) { 50 | DispatchQueue.main.async { 51 | self.profileImages[object.id] = image 52 | } 53 | } 54 | } 55 | } 56 | objectWillChange.send() 57 | } 58 | } 59 | 60 | @Published var currentUserFollowers = [User]() 61 | @Published var currentUserFollowings = [User]() 62 | 63 | /// Contains all fetched images. 64 | @Published var profileImages = [String: UIImage]() 65 | 66 | subscript(dynamicMember member: String) -> [User] { 67 | return users 68 | } 69 | 70 | init(isShowingFollowers: Bool? = nil, 71 | followersViewModel: QueryViewModel? = nil, 72 | followingsViewModel: QueryViewModel? = nil) { 73 | DispatchQueue.main.async { 74 | if let isShowingFollowers = isShowingFollowers { 75 | guard let followersViewModel = followersViewModel, 76 | let followingsViewModel = followingsViewModel else { 77 | return 78 | } 79 | self.followersViewModel = followersViewModel 80 | self.followingsViewModel = followingsViewModel 81 | self.isShowingFollowers = isShowingFollowers 82 | if isShowingFollowers { 83 | self.updateFollowers() 84 | } else { 85 | self.updateFollowings() 86 | } 87 | } 88 | DispatchQueue.main.async { 89 | self.update() 90 | } 91 | } 92 | } 93 | 94 | // MARK: Helpers 95 | @MainActor 96 | func update() { 97 | self.isSettingForFirstTime = true 98 | if let isShowingFollowers = self.isShowingFollowers { 99 | DispatchQueue.main.async { 100 | if isShowingFollowers { 101 | self.updateFollowers() 102 | } else { 103 | self.updateFollowings() 104 | } 105 | } 106 | self.isSettingForFirstTime = false 107 | } else { 108 | Task { 109 | await queryUsersNotFollowing() 110 | } 111 | } 112 | Task { 113 | do { 114 | let activities = try await ProfileViewModel.queryFollowers().find() 115 | self.currentUserFollowers = activities.compactMap { $0.fromUser } 116 | } catch { 117 | Logger.explore.error("Failed to query current followers: \(error.localizedDescription)") 118 | } 119 | } 120 | Task { 121 | do { 122 | let activities = try await ProfileViewModel.queryFollowings().find() 123 | self.currentUserFollowings = activities.compactMap { $0.toUser } 124 | } catch { 125 | Logger.explore.error("Failed to query current followings: \(error.localizedDescription)") 126 | } 127 | } 128 | } 129 | 130 | // MARK: Intents 131 | @MainActor 132 | func followUser(_ user: User) { 133 | do { 134 | let newActivity = try Activity(type: .follow, from: User.current, to: user) 135 | .setupForFollowing() 136 | self.users = self.users.filter({ $0.objectId != user.objectId }) 137 | Task { 138 | do { 139 | _ = try await newActivity.save() 140 | } catch { 141 | Logger.explore.error("Couldn't save follow: \(error.localizedDescription)") 142 | } 143 | } 144 | } catch { 145 | Logger.explore.error("Can't create follow activity \(error.localizedDescription)") 146 | } 147 | } 148 | 149 | @MainActor 150 | func unfollowUser(_ toUser: User) { 151 | guard let currentUser = User.current, 152 | let activity = followingsViewModel?.results.first(where: { activity in 153 | guard let activityToUser = activity.toUser, 154 | let activityFromUser = activity.fromUser, 155 | let activityType = activity.type, 156 | activityToUser.hasSameObjectId(as: toUser), 157 | activityFromUser.hasSameObjectId(as: currentUser), 158 | activityType == Activity.ActionType.follow else { 159 | return false 160 | } 161 | return true 162 | }) else { 163 | return 164 | } 165 | self.followingsViewModel?.results.removeAll(where: { $0.hasSameObjectId(as: activity) }) 166 | self.updateFollowings() 167 | Task { 168 | do { 169 | try await activity.delete() 170 | } catch { 171 | Logger.explore.error("Couldn't delete activity \(error.localizedDescription)") 172 | } 173 | } 174 | } 175 | 176 | // MARK: Helpers 177 | @MainActor 178 | func queryUsersNotFollowing() async { 179 | guard let currentUserObjectId = User.current?.objectId else { 180 | Logger.explore.error("Couldn't get own objectId") 181 | return 182 | } 183 | Task { 184 | do { 185 | let foundUsers = try await ProfileViewModel.queryFollowings().find() 186 | var objectIds = foundUsers.compactMap { $0.toUser?.id } 187 | objectIds.append(currentUserObjectId) 188 | let query = User.query(notContainedIn(key: ParseKey.objectId, array: objectIds)) 189 | do { 190 | self.users = try await query.find() 191 | } catch { 192 | Logger.explore.error("Couldn't query users: \(error.localizedDescription)") 193 | } 194 | } catch { 195 | Logger.explore.error("Couldn't find followings: \(error.localizedDescription)") 196 | } 197 | } 198 | } 199 | 200 | func isCurrentFollower(_ user: User?) -> Bool { 201 | guard let user = user else { return false } 202 | return currentUserFollowers.first(where: { $0.hasSameObjectId(as: user) }) != nil 203 | } 204 | 205 | func isCurrentFollowing(_ user: User?) -> Bool { 206 | guard let user = user else { return false } 207 | return currentUserFollowings.first(where: { $0.hasSameObjectId(as: user) }) != nil 208 | } 209 | 210 | @MainActor 211 | func updateFollowers() { 212 | guard let followersViewModel = followersViewModel else { 213 | return 214 | } 215 | self.users = followersViewModel.results.compactMap { $0.fromUser } 216 | } 217 | 218 | @MainActor 219 | func updateFollowings() { 220 | guard let followingsViewModel = followingsViewModel else { 221 | return 222 | } 223 | self.users = followingsViewModel.results.compactMap { $0.toUser } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /SnapCat/Explore/SearchBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBarView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/10/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SearchBarView: View { 12 | @Binding var searchText: String 13 | @State private var isEditing = false 14 | 15 | var body: some View { 16 | HStack { 17 | TextField("Search...", text: $searchText) 18 | .padding() 19 | .textFieldStyle(RoundedBorderTextFieldStyle()) 20 | .overlay( 21 | HStack { 22 | Spacer() 23 | if isEditing { 24 | Button(action: { 25 | self.cancelEditing() 26 | }, label: { 27 | Image(systemName: "multiply.circle.fill") 28 | .foregroundColor(.gray) 29 | .padding() 30 | }) 31 | } 32 | } 33 | ) 34 | .background(Color(.systemGray6)) 35 | .onChange(of: searchText, perform: { value in 36 | if value != "" { 37 | self.isEditing = true 38 | } 39 | }) 40 | Spacer() 41 | if isEditing { 42 | Button(action: { 43 | self.cancelEditing() 44 | }, label: { 45 | Text("Cancel") 46 | .padding() 47 | }) 48 | } 49 | } 50 | } 51 | 52 | func cancelEditing() { 53 | self.isEditing = false 54 | self.searchText = "" 55 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 56 | } 57 | } 58 | 59 | struct SearchBarView_Previews: PreviewProvider { 60 | static var previews: some View { 61 | SearchBarView(searchText: .constant("")) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /SnapCat/Extentions/Date+extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+extension.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // swiftlint:disable line_length 12 | 13 | // Source: Leo Dabus, http://stackoverflow.com/questions/27310883/swift-ios-doesrelativedateformatting-have-different-values-besides-today-and 14 | extension Date { 15 | func yearsFrom(_ date: Date) -> Int { 16 | return (Calendar.current as NSCalendar).components(.year, 17 | from: date, 18 | to: self, 19 | options: []).year ?? 0 20 | } 21 | func monthsFrom(_ date: Date) -> Int { 22 | return (Calendar.current as NSCalendar).components(.month, from: date, 23 | to: self, 24 | options: []).month ?? 0 25 | } 26 | func weeksFrom(_ date: Date) -> Int { 27 | return (Calendar.current as NSCalendar).components(.weekOfYear, 28 | from: date, 29 | to: self, 30 | options: []).weekOfYear ?? 0 31 | } 32 | func daysFrom(_ date: Date) -> Int { 33 | return (Calendar.current as NSCalendar).components(.day, 34 | from: date, 35 | to: self, 36 | options: []).day ?? 0 37 | } 38 | func hoursFrom(_ date: Date) -> Int { 39 | return (Calendar.current as NSCalendar).components(.hour, 40 | from: date, 41 | to: self, 42 | options: []).hour ?? 0 43 | } 44 | func minutesFrom(_ date: Date) -> Int { 45 | return (Calendar.current as NSCalendar).components(.minute, 46 | from: date, 47 | to: self, 48 | options: []).minute ?? 0 49 | } 50 | func secondsFrom(_ date: Date) -> Int { 51 | return (Calendar.current as NSCalendar).components(.second, 52 | from: date, 53 | to: self, 54 | options: []).second ?? 0 55 | } 56 | var relativeTime: String { 57 | let now = Date() 58 | if now.yearsFrom(self) > 0 { 59 | return now.yearsFrom(self).description + " year" + { return now.yearsFrom(self) > 1 ? "s" : "" }() + " ago" 60 | } 61 | if now.monthsFrom(self) > 0 { 62 | return now.monthsFrom(self).description + " month" + { return now.monthsFrom(self) > 1 ? "s" : "" }() + " ago" 63 | } 64 | if now.weeksFrom(self) > 0 { 65 | return now.weeksFrom(self).description + " week" + { return now.weeksFrom(self) > 1 ? "s" : "" }() + " ago" 66 | } 67 | if now.daysFrom(self) > 0 { 68 | if daysFrom(self) == 1 { return "Yesterday" } 69 | return now.daysFrom(self).description + " days ago" 70 | } 71 | if now.hoursFrom(self) > 0 { 72 | return "\(now.hoursFrom(self)) hour" + { return now.hoursFrom(self) > 1 ? "s" : "" }() + " ago" 73 | } 74 | if now.minutesFrom(self) > 0 { 75 | return "\(now.minutesFrom(self)) minute" + { return now.minutesFrom(self) > 1 ? "s" : "" }() + " ago" 76 | } 77 | if now.secondsFrom(self) > 0 { 78 | if now.secondsFrom(self) < 15 { return "Just now" } 79 | return "\(now.secondsFrom(self)) second" + { return now.secondsFrom(self) > 1 ? "s" : "" }() + " ago" 80 | } 81 | return "" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /SnapCat/Extentions/Logger+extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger+extension.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | 12 | extension Logger { 13 | private static var subsystem = Bundle.main.bundleIdentifier! 14 | static let category = "SnapCat" 15 | static let user = Logger(subsystem: subsystem, category: "\(category).user") 16 | static let installation = Logger(subsystem: subsystem, category: "\(category).installation") 17 | static let activity = Logger(subsystem: subsystem, category: "\(category).activity") 18 | static let post = Logger(subsystem: subsystem, category: "\(category).post") 19 | static let onboarding = Logger(subsystem: subsystem, category: "\(category).onboarding") 20 | static let main = Logger(subsystem: subsystem, category: "\(category).main") 21 | static let home = Logger(subsystem: subsystem, category: "\(category).home") 22 | static let explore = Logger(subsystem: subsystem, category: "\(category).explore") 23 | static let profile = Logger(subsystem: subsystem, category: "\(category).profile") 24 | static let notification = Logger(subsystem: subsystem, category: "\(category).notification") 25 | static let utility = Logger(subsystem: subsystem, category: "\(category).utility") 26 | static let settings = Logger(subsystem: subsystem, category: "\(category).settings") 27 | static let comment = Logger(subsystem: subsystem, category: "\(category).comment") 28 | static let queryImageViewModel = Logger(subsystem: subsystem, category: "\(category).queryImageViewModel") 29 | } 30 | -------------------------------------------------------------------------------- /SnapCat/Extentions/ParseSwift+extention.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParseSwift+extention.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | 12 | // swiftlint:disable line_length 13 | // swiftlint:disable function_body_length 14 | 15 | extension Utility { 16 | /** Can setup a connection to Parse Server based on a ParseCareKit.plist file. 17 | 18 | The key/values supported in the file are a dictionary named `ParseClientConfiguration`: 19 | - Server - (String) The server URL to connect to Parse Server. 20 | - ApplicationID - (String) The application id of your Parse application. 21 | - ClientKey - (String) The client key of your Parse application. 22 | - LiveQueryServer - (String) The live query server URL to connect to Parse Server. 23 | - UseTransactions - (Boolean) Use transactions inside the Client SDK. 24 | - parameter authentication: A callback block that will be used to receive/accept/decline network challenges. 25 | Defaults to `nil` in which the SDK will use the default OS authentication methods for challenges. 26 | It should have the following argument signature: `(challenge: URLAuthenticationChallenge, 27 | completionHandler: (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void`. 28 | See Apple's [documentation](https://developer.apple.com/documentation/foundation/urlsessiontaskdelegate/1411595-urlsession) for more for details. 29 | */ 30 | static func setupServer(authentication: ((URLAuthenticationChallenge, 31 | (URLSession.AuthChallengeDisposition, 32 | URLCredential?) -> Void) -> Void)? = nil) { 33 | var propertyListFormat = PropertyListSerialization.PropertyListFormat.xml 34 | var plistConfiguration: [String: AnyObject] 35 | var clientKey: String? 36 | var liveQueryURL: URL? 37 | var useTransactions = false 38 | var cacheMemoryCapacity = 512_000 39 | var cacheDiskCapacity = 10_000_000 40 | var deleteKeychainIfNeeded = false 41 | 42 | guard let path = Bundle.main.path(forResource: "ParseSwift", ofType: "plist"), 43 | let xml = FileManager.default.contents(atPath: path) else { 44 | fatalError("Error in Utility.setupServer(). Can't find ParseSwift.plist in this project") 45 | } 46 | do { 47 | plistConfiguration = 48 | try PropertyListSerialization.propertyList(from: xml, 49 | options: .mutableContainersAndLeaves, 50 | // swiftlint:disable:next force_cast 51 | format: &propertyListFormat) as! [String: AnyObject] 52 | } catch { 53 | fatalError("Error in Utility.setupServer(). Couldn't serialize plist. \(error)") 54 | } 55 | 56 | guard let appID = plistConfiguration["ApplicationID"] as? String, 57 | let server = plistConfiguration["Server"] as? String, 58 | let serverURL = URL(string: server) else { 59 | fatalError("Error in Utility.setupServer()") 60 | } 61 | 62 | if let client = plistConfiguration["ClientKey"] as? String { 63 | clientKey = client 64 | } 65 | 66 | if let liveQuery = plistConfiguration["LiveQueryServer"] as? String { 67 | liveQueryURL = URL(string: liveQuery) 68 | } 69 | 70 | if let transactions = plistConfiguration["UseTransactions"] as? Bool { 71 | useTransactions = transactions 72 | } 73 | 74 | if let capacity = plistConfiguration["CacheMemoryCapacity"] as? Int { 75 | cacheMemoryCapacity = capacity 76 | } 77 | 78 | if let capacity = plistConfiguration["CacheDiskCapacity"] as? Int { 79 | cacheDiskCapacity = capacity 80 | } 81 | 82 | if let deleteKeychain = plistConfiguration["DeleteKeychainIfNeeded"] as? Bool { 83 | deleteKeychainIfNeeded = deleteKeychain 84 | } 85 | 86 | ParseSwift.initialize(applicationId: appID, 87 | clientKey: clientKey, 88 | serverURL: serverURL, 89 | liveQueryServerURL: liveQueryURL, 90 | usingTransactions: useTransactions, 91 | requestCachePolicy: .reloadIgnoringLocalCacheData, 92 | cacheMemoryCapacity: cacheMemoryCapacity, 93 | cacheDiskCapacity: cacheDiskCapacity, 94 | deletingKeychainIfNeeded: deleteKeychainIfNeeded, 95 | authentication: authentication) 96 | } 97 | 98 | /** 99 | Check server health. 100 | - returns: **true** if the server is available. **false** if the server reponds with not healthy. 101 | - throws: `ParseError`. 102 | */ 103 | static func isServerAvailable() async throws -> Bool { 104 | let serverHealth = try await ParseHealth.check() 105 | guard serverHealth.contains("ok") else { 106 | return false 107 | } 108 | return true 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /SnapCat/Extentions/UIImage+extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+extension.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | // Source: https://gist.github.com/alexruperez/90f44545b57c25b977c4 13 | extension UIImage { 14 | func tint(_ color: UIColor, blendMode: CGBlendMode) -> UIImage { 15 | let drawRect = CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height) 16 | UIGraphicsBeginImageContextWithOptions(size, false, scale) 17 | 18 | if let context = UIGraphicsGetCurrentContext(), let mask = cgImage { 19 | context.clip(to: drawRect, mask: mask) 20 | } 21 | color.setFill() 22 | UIRectFill(drawRect) 23 | draw(in: drawRect, blendMode: blendMode, alpha: 1.0) 24 | 25 | if let tintedImage = UIGraphicsGetImageFromCurrentImageContext() { 26 | UIGraphicsEndImageContext() 27 | 28 | return tintedImage 29 | } 30 | return UIImage() 31 | } 32 | 33 | // Source: http://stackoverflow.com/questions/29137488/how-do-i-resize-the-uiimage-to-reduce-upload-image-size 34 | func resize(_ scale: CGFloat) -> UIImage { 35 | let imageView = UIImageView(frame: CGRect(origin: CGPoint(x: 0, y: 0), 36 | size: CGSize(width: size.width*scale, 37 | height: size.height*scale))) 38 | imageView.contentMode = UIView.ContentMode.scaleAspectFit 39 | imageView.image = self 40 | UIGraphicsBeginImageContext(imageView.bounds.size) 41 | if let UIGraphicsGetCurrentContext = UIGraphicsGetCurrentContext() { 42 | imageView.layer.render(in: UIGraphicsGetCurrentContext) 43 | } 44 | 45 | let result = UIGraphicsGetImageFromCurrentImageContext() 46 | UIGraphicsEndImageContext() 47 | if let result = result { 48 | return result 49 | } 50 | 51 | return UIImage() 52 | } 53 | func resizeToWidth(_ width: CGFloat) -> UIImage { 54 | let imageView = UIImageView(frame: CGRect(origin: CGPoint(x: 0, y: 0), 55 | size: CGSize(width: width, 56 | height: width))) 57 | // imageView.contentMode = UIViewContentMode.ScaleAspectFit 58 | imageView.image = self 59 | UIGraphicsBeginImageContext(imageView.bounds.size) 60 | 61 | if let UIGraphicsGetCurrentContext = UIGraphicsGetCurrentContext() { 62 | imageView.layer.render(in: UIGraphicsGetCurrentContext) 63 | } 64 | let result = UIGraphicsGetImageFromCurrentImageContext() 65 | UIGraphicsEndImageContext() 66 | if let result = result { 67 | return result 68 | } 69 | return UIImage() 70 | } 71 | 72 | func resizeToWidthHalfHeight(_ width: CGFloat) -> UIImage { 73 | let imageView = UIImageView(frame: CGRect(origin: CGPoint(x: 0, y: 0), 74 | size: CGSize(width: width, 75 | height: CGFloat(ceil(width/size.width * size.height))))) 76 | // imageView.contentMode = UIViewContentMode.ScaleAspectFit 77 | imageView.image = self 78 | UIGraphicsBeginImageContext(imageView.bounds.size) 79 | if let UIGraphicsGetCurrentContext = UIGraphicsGetCurrentContext() { 80 | imageView.layer.render(in: UIGraphicsGetCurrentContext) 81 | } 82 | 83 | let result = UIGraphicsGetImageFromCurrentImageContext() 84 | UIGraphicsEndImageContext() 85 | if let result = result { 86 | return result 87 | } 88 | return UIImage() 89 | } 90 | 91 | // Source: https://ruigomes.me/blog/how-to-rotate-an-uiimage-using-swift/ 92 | func imageRotatedByDegrees(_ degrees: CGFloat, flip: Bool) -> UIImage { 93 | /*let radiansToDegrees: (CGFloat) -> CGFloat = { 94 | return $0 * (180.0 / CGFloat(Double.pi)) 95 | }*/ 96 | let degreesToRadians: (CGFloat) -> CGFloat = { 97 | return $0 / 180.0 * CGFloat(Double.pi) 98 | } 99 | 100 | // calculate the size of the rotated view's containing box for our drawing space 101 | let rotatedViewBox = UIView(frame: CGRect(origin: CGPoint.zero, size: size)) 102 | let transform = CGAffineTransform(rotationAngle: degreesToRadians(degrees)) 103 | rotatedViewBox.transform = transform 104 | let rotatedSize = rotatedViewBox.frame.size 105 | 106 | // Create the bitmap context 107 | UIGraphicsBeginImageContext(rotatedSize) 108 | let bitmap = UIGraphicsGetCurrentContext() 109 | 110 | // Move the origin to the middle of the image so we will rotate and scale around the center. 111 | bitmap?.translateBy(x: rotatedSize.width / 2.0, y: rotatedSize.height / 2.0) 112 | 113 | // // Rotate the image context 114 | bitmap?.rotate(by: degreesToRadians(degrees)) 115 | 116 | // Now, draw the rotated/scaled image into the context 117 | var yFlip: CGFloat 118 | 119 | if flip { 120 | yFlip = CGFloat(-1.0) 121 | } else { 122 | yFlip = CGFloat(1.0) 123 | } 124 | 125 | bitmap?.scaleBy(x: yFlip, y: -1.0) 126 | bitmap?.draw(cgImage!, 127 | in: CGRect(x: -size.width / 2, 128 | y: -size.height / 2, 129 | width: size.width, 130 | height: size.height)) 131 | 132 | let newImage = UIGraphicsGetImageFromCurrentImageContext() 133 | UIGraphicsEndImageContext() 134 | 135 | return newImage! 136 | } 137 | } 138 | 139 | // swiftlint:disable:next line_length 140 | // Source: https://stackoverflow.com/questions/29726643/how-to-compress-of-reduce-the-size-of-an-image-before-uploading-to-parse-as-pffi/29726675 141 | extension UIImage { 142 | // MARK: - UIImage+Resize 143 | func compressTo(_ expectedSizeInMb: Int) -> Data? { 144 | let sizeInBytes = expectedSizeInMb * 1024 * 1024 145 | var needCompress: Bool = true 146 | var imgData: Data? 147 | var compressingValue: CGFloat = 1.0 148 | while needCompress && compressingValue > 0.0 { 149 | if let data: Data = self.jpegData(compressionQuality: compressingValue) { 150 | if data.count < sizeInBytes { 151 | needCompress = false 152 | imgData = data 153 | } else { 154 | compressingValue -= 0.1 155 | } 156 | } 157 | } 158 | 159 | if let data = imgData { 160 | if data.count < sizeInBytes { 161 | return data 162 | } 163 | } 164 | 165 | return nil 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /SnapCat/Extentions/View+extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+extension.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension View { 12 | func formattedHostingController() -> UIHostingController { 13 | let viewController = UIHostingController(rootView: self) 14 | viewController.view.backgroundColor = UIColor { $0.userInterfaceStyle == .light ? .white : .black } 15 | return viewController 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /SnapCat/Home/CommentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/16/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ParseSwift 11 | import os.log 12 | 13 | struct CommentView: View { 14 | @ObservedObject var viewModel: CommentViewModel 15 | @ObservedObject var timeLineViewModel: QueryImageViewModel 16 | @Environment(\.presentationMode) var presentationMode 17 | 18 | var body: some View { 19 | VStack { 20 | Form { 21 | Section { 22 | TextField("Add a comment", text: $viewModel.comment) 23 | } 24 | } 25 | .navigationBarBackButtonHidden(true) 26 | .navigationTitle(Text("Comment")) 27 | .navigationBarItems(leading: Button(action: { 28 | self.presentationMode.wrappedValue.dismiss() 29 | }, label: { 30 | Text("Cancel") 31 | }), trailing: Button(action: { 32 | Task { 33 | do { 34 | let comment = try await viewModel.save() 35 | if let postId = viewModel.activity?.post?.id { 36 | timeLineViewModel.comments[postId]?.insert(comment, at: 0) 37 | } 38 | } catch { 39 | Logger.comment.error("Error saving: \(error.localizedDescription)") 40 | } 41 | } 42 | self.presentationMode.wrappedValue.dismiss() 43 | }, label: { 44 | Text("Done") 45 | })) 46 | } 47 | } 48 | 49 | init(timeLineViewModel: QueryImageViewModel, 50 | post: Post, 51 | activity: Activity? = nil) { 52 | self.timeLineViewModel = timeLineViewModel 53 | viewModel = CommentViewModel(post: post, activity: activity) 54 | } 55 | } 56 | 57 | struct CommentView_Previews: PreviewProvider { 58 | static var previews: some View { 59 | CommentView(timeLineViewModel: .init(query: Post.query()), post: Post()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /SnapCat/Home/CommentViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommentViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/16/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | import UIKit 13 | 14 | @MainActor 15 | class CommentViewModel: ObservableObject { 16 | @Published var activity: Activity? 17 | @Published var comment = "" 18 | 19 | init(post: Post? = nil, activity: Activity? = nil) { 20 | if activity != nil { 21 | self.activity = activity 22 | } else { 23 | self.activity = Activity(type: .comment, from: User.current, to: post?.user) 24 | self.activity?.post = post 25 | } 26 | } 27 | 28 | // MARK: Intents 29 | @MainActor 30 | func save() async throws -> Activity { 31 | guard var currentActivity = activity else { 32 | return Activity() 33 | } 34 | if !comment.isEmpty { 35 | currentActivity.comment = comment 36 | activity = currentActivity 37 | return try await currentActivity.save() 38 | } else { 39 | return currentActivity 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SnapCat/Home/EmptyTimeLineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyTimeLineView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | 12 | struct EmptyTimeLineView: UIViewControllerRepresentable { 13 | 14 | func makeUIViewController(context: Context) -> some UIViewController { 15 | let view = EmptyTimeLineViewController() 16 | let viewController = UINavigationController(rootViewController: view) 17 | viewController.navigationBar.isHidden = true 18 | return viewController 19 | } 20 | 21 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { 22 | 23 | } 24 | } 25 | 26 | struct EmptyTimeLineView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | EmptyTimeLineView() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /SnapCat/Home/EmptyTimeLineViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyTimeLineViewController.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import DZNEmptyDataSet 12 | 13 | class EmptyTimeLineViewController: EmptyDefaultViewController { 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | } 18 | 19 | override func viewDidAppear(_ animated: Bool) { 20 | super.viewDidAppear(animated) 21 | } 22 | 23 | override func title(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString { 24 | let text = "Timeline" 25 | 26 | let attributes = [ 27 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 18.0), 28 | NSAttributedString.Key.foregroundColor: UIColor.darkGray 29 | ] 30 | 31 | return NSAttributedString(string: text, attributes: attributes) 32 | } 33 | 34 | override func description(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString { 35 | let text = "See posts from people you follow" 36 | 37 | let paragragh = NSMutableParagraphStyle() 38 | paragragh.lineBreakMode = NSLineBreakMode.byWordWrapping 39 | paragragh.alignment = NSTextAlignment.center 40 | 41 | let attributes = [ 42 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 14.0), 43 | NSAttributedString.Key.foregroundColor: UIColor.lightGray, 44 | NSAttributedString.Key.paragraphStyle: paragragh 45 | ] 46 | 47 | return NSAttributedString(string: text, attributes: attributes) 48 | } 49 | 50 | override func buttonTitle(forEmptyDataSet scrollView: UIScrollView, 51 | for state: UIControl.State) -> NSAttributedString { 52 | let attributes = [ 53 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 17.0) 54 | ] 55 | 56 | return NSAttributedString(string: "Tap here to find friends", attributes: attributes) 57 | } 58 | 59 | override func image(forEmptyDataSet scrollView: UIScrollView) -> UIImage? { 60 | 61 | if var image = UIImage(systemName: "house") { 62 | 63 | image = image.tint(UIColor.lightGray, blendMode: CGBlendMode.color) 64 | image = image.imageRotatedByDegrees(180, flip: false) 65 | return image 66 | } 67 | return UIImage() 68 | 69 | } 70 | 71 | override func emptyDataSet(_ scrollView: UIScrollView, didTap view: UIView) { 72 | presentView() 73 | } 74 | 75 | override func emptyDataSet(_ scrollView: UIScrollView, didTap button: UIButton) { 76 | presentView() 77 | } 78 | 79 | func presentView() { 80 | let friendsViewController = ExploreView().formattedHostingController() 81 | friendsViewController.modalPresentationStyle = .popover 82 | present(friendsViewController, animated: true, completion: nil) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /SnapCat/Home/HomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ParseSwift 11 | 12 | struct HomeView: View { 13 | 14 | @Environment(\.tintColor) private var tintColor 15 | @ObservedObject var timeLineViewModel: QueryImageViewModel 16 | @StateObject var postStatus = PostStatus() 17 | 18 | var body: some View { 19 | VStack { 20 | NavigationLink(destination: PostView(timeLineViewModel: timeLineViewModel) 21 | .environmentObject(postStatus), 22 | isActive: $postStatus.isShowing) { 23 | EmptyView() 24 | } 25 | HStack { 26 | Text("SnapCat") 27 | .font(Font.custom("noteworthy-bold", size: 30)) 28 | .foregroundColor(Color(tintColor)) 29 | .padding() 30 | Spacer() 31 | Button(action: { 32 | self.postStatus.isShowing = true 33 | }, label: { 34 | Image(systemName: "square.and.pencil") 35 | .resizable() 36 | .foregroundColor(Color(tintColor)) 37 | .frame(width: 30, height: 30, alignment: .trailing) 38 | .padding() 39 | }) 40 | } 41 | Divider() 42 | TimeLineView(viewModel: timeLineViewModel) 43 | } 44 | } 45 | 46 | init() { 47 | let timeLineQuery = TimeLineViewModel.queryTimeLine() 48 | .include(PostKey.user) 49 | if let timeLine = timeLineQuery.subscribeCustom { 50 | timeLineViewModel = timeLine 51 | } else { 52 | timeLineViewModel = timeLineQuery.imageViewModel 53 | } 54 | timeLineViewModel.find() 55 | } 56 | } 57 | 58 | struct HomeView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | HomeView() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /SnapCat/Home/HomeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | 13 | class HomeViewModel: ObservableObject { 14 | 15 | // MARK: Queries 16 | } 17 | -------------------------------------------------------------------------------- /SnapCat/Home/ImagePickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImagePickerView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/11/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | // swiftlint:disable line_length 10 | 11 | // Source: https://www.hackingwithswift.com/books/ios-swiftui/importing-an-image-into-swiftui-using-uiimagepickercontroller 12 | 13 | import SwiftUI 14 | import UIKit 15 | 16 | struct ImagePickerView: UIViewControllerRepresentable { 17 | @Environment(\.presentationMode) var presentationMode 18 | @Binding var image: UIImage? 19 | 20 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { 21 | let picker = UIImagePickerController() 22 | picker.delegate = context.coordinator 23 | return picker 24 | } 25 | 26 | func updateUIViewController(_ uiViewController: UIImagePickerController, 27 | context: UIViewControllerRepresentableContext) { 28 | 29 | } 30 | 31 | func makeCoordinator() -> Coordinator { 32 | Coordinator(self) 33 | } 34 | 35 | class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { 36 | let parent: ImagePickerView 37 | 38 | init(_ parent: ImagePickerView) { 39 | self.parent = parent 40 | } 41 | 42 | func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { 43 | DispatchQueue.main.async { 44 | if let uiImage = info[.originalImage] as? UIImage { 45 | self.parent.image = uiImage 46 | } 47 | self.parent.presentationMode.wrappedValue.dismiss() 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /SnapCat/Home/PostView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/11/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ParseSwift 11 | 12 | struct PostView: View { 13 | 14 | @Environment(\.presentationMode) var presentationMode 15 | @EnvironmentObject var postStatus: PostStatus 16 | @ObservedObject var timeLineViewModel: QueryImageViewModel 17 | @StateObject var viewModel = PostViewModel() 18 | @State private var isShowingImagePicker = false 19 | 20 | var body: some View { 21 | VStack { 22 | GeometryReader { geometry in 23 | Form { 24 | Section { 25 | HStack { 26 | Spacer() 27 | Button(action: { 28 | self.isShowingImagePicker = true 29 | }, label: { 30 | if let image = viewModel.image { 31 | Image(uiImage: image) 32 | .resizable() 33 | .frame(width: 0.75 * geometry.size.width, 34 | height: 0.75 * geometry.size.width, 35 | alignment: .center) 36 | .clipShape(Rectangle()) 37 | .scaledToFill() 38 | } else { 39 | Image(systemName: "camera") 40 | .resizable() 41 | .frame(width: 200, height: 200, alignment: .center) 42 | .clipShape(Rectangle()) 43 | .padding() 44 | } 45 | }) 46 | .buttonStyle(PlainButtonStyle()) 47 | Spacer() 48 | } 49 | 50 | TextField("Caption", text: $viewModel.caption) 51 | if let placeMark = viewModel.currentPlacemark, 52 | let name = placeMark.name { 53 | Text(name) 54 | } else { 55 | Text("Location: N/A") 56 | } 57 | } 58 | Section { 59 | Button(action: { 60 | viewModel.requestPermission() 61 | }, label: { 62 | if viewModel.currentPlacemark == nil { 63 | Text("Use Location") 64 | } else { 65 | Text("Remove Location") 66 | } 67 | }) 68 | } 69 | } 70 | .navigationBarBackButtonHidden(true) 71 | .navigationTitle(Text("Post")) 72 | .navigationBarItems(leading: Button(action: { 73 | self.postStatus.isShowing = false 74 | }, label: { 75 | Text("Cancel") 76 | }), trailing: Button(action: { 77 | Task { 78 | _ = try await viewModel.save() 79 | } 80 | self.postStatus.isShowing = false 81 | }, label: { 82 | Text("Done") 83 | })) 84 | .sheet(isPresented: $isShowingImagePicker, onDismiss: {}, content: { 85 | ImagePickerView(image: $viewModel.image) 86 | }) 87 | } 88 | } 89 | } 90 | } 91 | 92 | struct PostView_Previews: PreviewProvider { 93 | static var previews: some View { 94 | PostView(timeLineViewModel: .init(query: Post.query())) 95 | .environmentObject(PostStatus(isShowing: true)) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /SnapCat/Home/PostViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/11/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | import UIKit 13 | import CoreLocation 14 | 15 | class PostViewModel: NSObject, ObservableObject { 16 | @Published var post: Post? 17 | @Published var image: UIImage? 18 | @Published var caption = "" 19 | @Published var location: ParseGeoPoint? 20 | var currentPlacemark: CLPlacemark? { 21 | willSet { 22 | if let currentLocation = newValue?.location { 23 | location = try? ParseGeoPoint(location: currentLocation) 24 | } else { 25 | location = nil 26 | } 27 | objectWillChange.send() 28 | } 29 | } 30 | private var authorizationStatus: CLAuthorizationStatus 31 | private var lastSeenLocation: CLLocation? 32 | private let locationManager: CLLocationManager 33 | 34 | init(post: Post? = nil) { 35 | if post != nil { 36 | self.post = post 37 | } else { 38 | self.post = Post(image: nil) 39 | } 40 | locationManager = CLLocationManager() 41 | authorizationStatus = locationManager.authorizationStatus 42 | 43 | super.init() 44 | locationManager.delegate = self 45 | locationManager.desiredAccuracy = kCLLocationAccuracyKilometer 46 | locationManager.startUpdatingLocation() 47 | } 48 | 49 | // MARK: Intents 50 | func requestPermission() { 51 | locationManager.requestWhenInUseAuthorization() 52 | } 53 | 54 | func save() async throws -> Post { 55 | guard let image = image, 56 | let compressed = image.compressTo(3), 57 | var currentPost = post else { 58 | return Post() 59 | } 60 | currentPost.image = ParseFile(data: compressed) 61 | currentPost.caption = caption 62 | currentPost.location = location 63 | return try await currentPost.save() 64 | } 65 | 66 | // MARK: Queries 67 | class func queryLikes(post: Post?) -> Query { 68 | guard let pointer = try? post?.toPointer() else { 69 | Logger.home.error("Should have created pointer.") 70 | return Activity.query().limit(0) 71 | } 72 | let query = Activity.query(ActivityKey.post == pointer, 73 | ActivityKey.type == Activity.ActionType.like) 74 | .order([.descending(ParseKey.createdAt)]) 75 | return query 76 | } 77 | 78 | class func queryComments(post: Post?) -> Query { 79 | guard let pointer = try? post?.toPointer() else { 80 | Logger.home.error("Should have created pointer.") 81 | return Activity.query().limit(0) 82 | } 83 | let query = Activity.query(ActivityKey.post == pointer, 84 | ActivityKey.type == Activity.ActionType.comment) 85 | .order([.descending(ParseKey.createdAt)]) 86 | return query 87 | } 88 | } 89 | 90 | // MARK: CLLocationManagerDelegate 91 | 92 | // Source: https://www.andyibanez.com/posts/using-corelocation-with-swiftui/ 93 | extension PostViewModel: CLLocationManagerDelegate { 94 | func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 95 | authorizationStatus = manager.authorizationStatus 96 | } 97 | 98 | func locationManager(_ manager: CLLocationManager, 99 | didUpdateLocations locations: [CLLocation]) { 100 | lastSeenLocation = locations.first 101 | fetchCountryAndCity(for: locations.first) 102 | } 103 | 104 | func fetchCountryAndCity(for location: CLLocation?) { 105 | guard let location = location else { return } 106 | let geocoder = CLGeocoder() 107 | geocoder.reverseGeocodeLocation(location) { (placemarks, _) in 108 | self.currentPlacemark = placemarks?.first 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /SnapCat/Home/TimeLineCommentsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeLineCommentsView.swift 3 | // TimeLineCommentsView 4 | // 5 | // Created by Corey Baker on 7/17/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TimeLineCommentsView: View { 12 | @ObservedObject var timeLineViewModel: QueryImageViewModel 13 | @State var post: Post 14 | @State var isShowingAllComments = false 15 | @State var postSelected = Post() 16 | var body: some View { 17 | VStack { 18 | if let comments = timeLineViewModel.comments[post.id], 19 | comments.count > 0 { 20 | NavigationLink(destination: ViewAllComments(timeLineViewModel: timeLineViewModel, 21 | post: post), 22 | isActive: $isShowingAllComments) { 23 | EmptyView() 24 | } 25 | if comments.count > 1 { 26 | HStack { 27 | Text("View all \(comments.count) comments") 28 | .font(.footnote) 29 | .onTapGesture(count: 1) { 30 | self.postSelected = post 31 | self.isShowingAllComments = true 32 | } 33 | Spacer() 34 | } 35 | } 36 | HStack { 37 | if let username = comments.first?.fromUser?.username { 38 | Text("\(username)") 39 | .font(.headline) 40 | } 41 | if let lastComment = comments.first?.comment { 42 | Text(lastComment) 43 | } 44 | Spacer() 45 | } 46 | } 47 | if let createdAt = post.createdAt { 48 | HStack { 49 | Text(createdAt.relativeTime) 50 | .font(.footnote) 51 | Spacer() 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | struct TimeLineCommentsView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | TimeLineCommentsView(timeLineViewModel: .init(query: Post.query()), post: Post()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /SnapCat/Home/TimeLineLikeCommentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeLineLikeCommentView.swift 3 | // TimeLineLikeCommentView 4 | // 5 | // Created by Corey Baker on 7/17/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct TimeLineLikeCommentView: View { 12 | @ObservedObject var timeLineViewModel: QueryImageViewModel 13 | @State var post: Post 14 | @State var isShowingComment = false 15 | @State var postSelected: Post? 16 | let currentObjectId: String 17 | var body: some View { 18 | VStack { 19 | HStack { 20 | VStack { 21 | if timeLineViewModel.isLikedPost(post, 22 | userObjectId: currentObjectId) { 23 | Image(systemName: "heart.fill") 24 | } else { 25 | Image(systemName: "heart") 26 | } 27 | }.onTapGesture(count: 1) { 28 | let currentTimeLine = timeLineViewModel 29 | Task { 30 | let (activity, status) = await TimeLineViewModel.likePost(post, 31 | currentLikes: currentTimeLine 32 | .likes[post.id]) 33 | switch status { 34 | 35 | case .like: 36 | timeLineViewModel.likes[post.id]?.append(activity) 37 | case .unlike: 38 | timeLineViewModel.likes[post.id]?.removeAll(where: {$0.hasSameObjectId(as: activity)}) 39 | case .error: 40 | break 41 | } 42 | 43 | } 44 | } 45 | VStack { 46 | if timeLineViewModel.isCommentedOnPost(post, 47 | userObjectId: currentObjectId) { 48 | Image(systemName: "bubble.left.fill") 49 | } else { 50 | Image(systemName: "bubble.left") 51 | } 52 | }.onTapGesture(count: 1) { 53 | self.postSelected = post 54 | self.isShowingComment = true 55 | } 56 | Spacer() 57 | } 58 | HStack { 59 | if let likes = timeLineViewModel.likes[post.id] { 60 | Text("Liked by") 61 | if likes.count > 2 { 62 | if let lastLikeUsername = likes.last?.fromUser?.username { 63 | Text("\(lastLikeUsername) ") 64 | .font(.headline) 65 | } 66 | Text("and \(likes.count - 1) others") 67 | } else if likes.count == 1 { 68 | Text("\(likes.count) person") 69 | } else { 70 | Text("\(likes.count) people") 71 | } 72 | Spacer() 73 | } 74 | } 75 | HStack { 76 | if let username = post.user?.username { 77 | Text("\(username)") 78 | .font(.headline) 79 | } 80 | if let caption = post.caption { 81 | Text(caption) 82 | } 83 | Spacer() 84 | } 85 | }.sheet(isPresented: $isShowingComment, content: { 86 | if let post = self.postSelected { 87 | CommentView(timeLineViewModel: timeLineViewModel, 88 | post: post) 89 | } 90 | }) 91 | } 92 | } 93 | 94 | struct TimeLineLikeCommentView_Previews: PreviewProvider { 95 | static var previews: some View { 96 | TimeLineLikeCommentView(timeLineViewModel: .init(query: Post.query()), post: Post(), currentObjectId: "") 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /SnapCat/Home/TimeLinePostView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeLinePostView.swift 3 | // TimeLinePostView 4 | // 5 | // Created by Corey Baker on 7/17/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | // swiftlint:disable line_length 12 | 13 | struct TimeLinePostView: View { 14 | @ObservedObject var timeLineViewModel: QueryImageViewModel 15 | @State var post: Post 16 | var body: some View { 17 | VStack { 18 | GeometryReader { geometry in 19 | HStack { 20 | if let image = timeLineViewModel.imageResults[post.id] { 21 | Spacer() 22 | Image(uiImage: image) 23 | .resizable() 24 | .frame(width: 0.95 * geometry.size.width, 25 | height: 0.95 * geometry.size.width, 26 | alignment: .leading) 27 | .clipShape(Rectangle()) 28 | .onTapGesture(count: 2) { 29 | Task { 30 | let currentTimeLine = timeLineViewModel 31 | 32 | let (activity, status) = await TimeLineViewModel.likePost(post, 33 | currentLikes: currentTimeLine 34 | .likes[post.id]) 35 | switch status { 36 | 37 | case .like: 38 | timeLineViewModel.likes[post.id]?.append(activity) 39 | case .unlike: 40 | timeLineViewModel 41 | .likes[post.id]? 42 | .removeAll(where: { $0.hasSameObjectId(as: activity) }) 43 | case .error: 44 | break 45 | } 46 | } 47 | } 48 | Spacer() 49 | } else { 50 | Image(systemName: "camera") 51 | .resizable() 52 | .clipShape(Rectangle()) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | struct TimeLineImageView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | TimeLinePostView(timeLineViewModel: .init(query: Post.query()), 63 | post: Post()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /SnapCat/Home/TimeLineView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeLineView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/5/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ParseSwift 11 | import os.log 12 | import UIKit 13 | 14 | struct TimeLineView: View { 15 | 16 | @Environment(\.tintColor) private var tintColor 17 | @ObservedObject var timeLineViewModel: QueryImageViewModel 18 | @State var isShowingProfile = false 19 | @State var gradient = LinearGradient(gradient: Gradient(colors: [Color(#colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1)), Color(#colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1))]), 20 | startPoint: .top, 21 | endPoint: .bottom) 22 | @State var userTapped = User() 23 | let currentObjectId: String 24 | var body: some View { 25 | if !timeLineViewModel.results.isEmpty { 26 | NavigationLink(destination: ProfileView(user: userTapped, 27 | isShowingHeading: false) 28 | .navigationTitle("") 29 | .navigationBarHidden(false), 30 | isActive: $isShowingProfile) { 31 | EmptyView() 32 | } 33 | NavigationView { 34 | List(timeLineViewModel.results, id: \.id) { result in 35 | VStack { 36 | HStack { 37 | if let userObjectId = result.user?.id, 38 | let image = timeLineViewModel.imageResults[userObjectId] { 39 | Image(uiImage: image) 40 | .resizable() 41 | .frame(width: 40, height: 40, alignment: .leading) 42 | .clipShape(Circle()) 43 | .shadow(radius: 3) 44 | .overlay(Circle().stroke(gradient, lineWidth: 3)) 45 | } else { 46 | Image(systemName: "person.circle") 47 | .resizable() 48 | .frame(width: 40, height: 40, alignment: .leading) 49 | .clipShape(Circle()) 50 | .shadow(radius: 3) 51 | .overlay(Circle().stroke(gradient, lineWidth: 1)) 52 | } 53 | if let username = result.user?.username { 54 | Text("\(username)") 55 | .font(.headline) 56 | } 57 | Spacer() 58 | }.onTapGesture(count: 1) { 59 | if let user = result.user { 60 | self.timeLineViewModel.userOfInterest = user 61 | self.userTapped = user 62 | self.isShowingProfile = true 63 | } 64 | } 65 | TimeLinePostView(timeLineViewModel: timeLineViewModel, 66 | post: result) 67 | .scaledToFill() 68 | TimeLineLikeCommentView(timeLineViewModel: timeLineViewModel, 69 | post: result, 70 | currentObjectId: currentObjectId) 71 | TimeLineCommentsView(timeLineViewModel: timeLineViewModel, post: result) 72 | 73 | Spacer() 74 | } 75 | }.navigationBarHidden(true) 76 | }.onAppear(perform: { 77 | timeLineViewModel.find() 78 | }) 79 | } else { 80 | VStack { 81 | EmptyTimeLineView() 82 | Spacer() 83 | } 84 | } 85 | } 86 | 87 | init(viewModel: QueryImageViewModel? = nil) { 88 | if let objectId = User.current?.id { 89 | currentObjectId = objectId 90 | } else { 91 | currentObjectId = "" 92 | } 93 | guard let viewModel = viewModel else { 94 | let timeLineQuery = TimeLineViewModel.queryTimeLine() 95 | .include(PostKey.user) 96 | if let timeLine = timeLineQuery.subscribeCustom { 97 | timeLineViewModel = timeLine 98 | } else { 99 | timeLineViewModel = timeLineQuery.imageViewModel 100 | } 101 | timeLineViewModel.find() 102 | return 103 | } 104 | timeLineViewModel = viewModel 105 | } 106 | } 107 | 108 | struct TimeLineView_Previews: PreviewProvider { 109 | static var previews: some View { 110 | TimeLineView() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /SnapCat/Home/TimeLineViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeLineViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/5/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | 13 | @MainActor 14 | class TimeLineViewModel: ObservableObject { 15 | 16 | // MARK: Intents 17 | class func likePost(_ post: Post, 18 | currentLikes: [Activity]?) async -> (Activity, Activity.LikeState) { 19 | guard let alreadyLikes = currentLikes? 20 | .first(where: { User.current?.id == $0.fromUser?.id }) else { 21 | let likeActivity = Activity.like(post: post) 22 | do { 23 | let liked = try await likeActivity.save() 24 | return (liked, .like) 25 | } catch { 26 | Logger.home.error("Error liking post \(post): Error: \(error.localizedDescription)") 27 | return (likeActivity, .error) 28 | } 29 | } 30 | do { 31 | try await alreadyLikes.delete() 32 | return (alreadyLikes, .unlike) 33 | } catch { 34 | Logger.home.error("Error deleting like: \(error.localizedDescription)") 35 | return (alreadyLikes, .error) 36 | } 37 | } 38 | 39 | // MARK: Queries 40 | class func queryTimeLine() -> Query { 41 | guard let pointer = try? User.current?.toPointer() else { 42 | Logger.home.error("Should have created pointer.") 43 | return Post.query().limit(0) 44 | } 45 | 46 | let findFollowings = ProfileViewModel.queryFollowings() 47 | let findTimeLineData = Post.query(matchesKeyInQuery(key: PostKey.user, 48 | queryKey: ActivityKey.toUser, 49 | query: findFollowings)) 50 | let findTimeLineDataForCurrentUser = Post.query(PostKey.user == pointer) 51 | let subQueries = [findTimeLineData, findTimeLineDataForCurrentUser] 52 | let query = Post.query(or(queries: subQueries)) 53 | .order([.descending(ParseKey.createdAt)]) 54 | return query 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /SnapCat/Home/ViewAllComments.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewAllComments.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/17/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ViewAllComments: View { 12 | @Environment(\.presentationMode) var presentationMode 13 | @ObservedObject var timeLineViewModel: QueryImageViewModel 14 | @State var post: Post 15 | var body: some View { 16 | if let comments = timeLineViewModel.comments[post.id] { 17 | List(comments, id: \.id) { result in 18 | VStack(alignment: .leading) { 19 | HStack { 20 | if let username = result.fromUser?.username { 21 | Text("\(username)") 22 | .font(.headline) 23 | } 24 | if let lastComment = result.comment { 25 | Text(lastComment) 26 | } 27 | } 28 | if let createdAt = result.createdAt { 29 | HStack { 30 | Text(createdAt.relativeTime) 31 | .font(.footnote) 32 | Spacer() 33 | } 34 | } 35 | } 36 | } 37 | .navigationBarBackButtonHidden(true) 38 | .navigationTitle(Text("Comments")) 39 | .navigationBarItems(leading: Button(action: { 40 | self.presentationMode.wrappedValue.dismiss() 41 | }, label: { 42 | Text("Cancel") 43 | }), trailing: Button(action: { 44 | self.presentationMode.wrappedValue.dismiss() 45 | }, label: { 46 | Text("Done") 47 | })) 48 | } else { 49 | EmptyView() 50 | } 51 | } 52 | } 53 | 54 | struct ViewAllComments_Previews: PreviewProvider { 55 | static var previews: some View { 56 | ViewAllComments(timeLineViewModel: .init(query: Post.query()), 57 | post: Post()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /SnapCat/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | 28 | UIApplicationSupportsIndirectInputEvents 29 | 30 | UILaunchScreen 31 | 32 | UIImageRespectsSafeAreaInsets 33 | 34 | UIImageName 35 | Snapcat 36 | 37 | UIRequiredDeviceCapabilities 38 | 39 | armv7 40 | 41 | UISupportedInterfaceOrientations 42 | 43 | UIInterfaceOrientationPortrait 44 | UIInterfaceOrientationLandscapeLeft 45 | UIInterfaceOrientationLandscapeRight 46 | 47 | UISupportedInterfaceOrientations~ipad 48 | 49 | UIInterfaceOrientationPortrait 50 | UIInterfaceOrientationPortraitUpsideDown 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | NSAppTransportSecurity 55 | 56 | NSAllowsArbitraryLoads 57 | 58 | NSExceptionDomains 59 | 60 | localhost 61 | 62 | NSAllowsArbitraryLoads 63 | 64 | 65 | 66 | 67 | NSLocationWhenInUseUsageDescription 68 | Will only use your location whenever you post a message when the app is open. 69 | NSPhotoLibraryUsageDescription 70 | Post photos from your library 71 | 72 | 73 | -------------------------------------------------------------------------------- /SnapCat/Main/EmptyDefaultViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyDefaultViewController.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import DZNEmptyDataSet 12 | 13 | class EmptyDefaultViewController: UITableViewController { 14 | 15 | let defaultImage = UIImage(systemName: "house") 16 | 17 | deinit { 18 | self.tableView?.emptyDataSetSource = nil 19 | self.tableView?.emptyDataSetDelegate = nil 20 | } 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | self.tableView?.emptyDataSetSource = self 25 | self.tableView?.emptyDataSetDelegate = self 26 | self.tableView?.tableFooterView = UIView() 27 | } 28 | } 29 | 30 | // UITableViewDataSource, UITableViewDelegate 31 | extension EmptyDefaultViewController { 32 | override func numberOfSections(in tableView: UITableView) -> Int { 33 | return 1 34 | } 35 | 36 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 37 | return 0 38 | } 39 | 40 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 41 | return UITableViewCell() 42 | } 43 | } 44 | 45 | // DZNEmptyDataSet Delegates. More info about how to use here: https://github.com/dzenbot/DZNEmptyDataSet 46 | extension EmptyDefaultViewController: DZNEmptyDataSetSource { 47 | func title(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString? { 48 | let text = "Empty Data" 49 | 50 | let attributes = [ 51 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 18.0), 52 | NSAttributedString.Key.foregroundColor: UIColor.darkGray 53 | ] 54 | 55 | return NSAttributedString(string: text, attributes: attributes) 56 | } 57 | 58 | func description(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString? { 59 | let text = "Empty data" 60 | 61 | let paragragh = NSMutableParagraphStyle() 62 | paragragh.lineBreakMode = NSLineBreakMode.byWordWrapping 63 | paragragh.alignment = NSTextAlignment.center 64 | 65 | let attributes = [ 66 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 14.0), 67 | NSAttributedString.Key.foregroundColor: UIColor.lightGray, 68 | NSAttributedString.Key.paragraphStyle: paragragh 69 | ] 70 | 71 | return NSAttributedString(string: text, attributes: attributes) 72 | } 73 | 74 | func buttonTitle(forEmptyDataSet scrollView: UIScrollView, for state: UIControl.State) -> NSAttributedString? { 75 | let attributes = [ 76 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 17.0) 77 | ] 78 | 79 | return NSAttributedString(string: "Tap here", attributes: attributes) 80 | } 81 | 82 | func image(forEmptyDataSet scrollView: UIScrollView) -> UIImage? { 83 | if var image = self.defaultImage { 84 | image = image.tint(UIColor.lightGray, blendMode: CGBlendMode.color) 85 | image = image.imageRotatedByDegrees(180, flip: false) 86 | return image 87 | } 88 | return UIImage() 89 | } 90 | 91 | func backgroundColor(forEmptyDataSet scrollView: UIScrollView) -> UIColor? { 92 | return UIColor.white 93 | } 94 | } 95 | 96 | extension EmptyDefaultViewController: DZNEmptyDataSetDelegate { 97 | func emptyDataSetShouldDisplay(_ scrollView: UIScrollView) -> Bool { 98 | return true 99 | } 100 | 101 | func emptyDataSetShouldAllowTouch(_ scrollView: UIScrollView) -> Bool { 102 | return true 103 | } 104 | 105 | func emptyDataSetShouldAllowScroll(_ scrollView: UIScrollView) -> Bool { 106 | return false 107 | } 108 | 109 | func emptyDataSet(_ scrollView: UIScrollView, didTap view: UIView) { 110 | 111 | } 112 | 113 | func emptyDataSet(_ scrollView: UIScrollView, didTap button: UIButton) { 114 | 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /SnapCat/Main/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MainView: View { 12 | 13 | @Environment(\.tintColor) private var tintColor 14 | @StateObject var userStatus = UserStatus() 15 | @State private var selectedTab = 0 16 | 17 | var body: some View { 18 | 19 | NavigationView { 20 | VStack { 21 | NavigationLink(destination: OnboardingView(), 22 | isActive: $userStatus.isLoggedOut) { 23 | EmptyView() 24 | } 25 | 26 | TabView(selection: $selectedTab) { 27 | 28 | HomeView() 29 | .tabItem { 30 | if selectedTab == 0 { 31 | Image(systemName: "house.fill") 32 | .renderingMode(.template) 33 | } else { 34 | Image(systemName: "house") 35 | .renderingMode(.template) 36 | } 37 | } 38 | .tag(0) 39 | .navigationBarTitle("") 40 | .navigationBarHidden(true) 41 | 42 | ExploreView() 43 | .tabItem { 44 | if selectedTab == 1 { 45 | Image(systemName: "magnifyingglass.circle.fill") 46 | .renderingMode(.template) 47 | } else { 48 | Image(systemName: "magnifyingglass.circle") 49 | .renderingMode(.template) 50 | } 51 | } 52 | .tag(1) 53 | .navigationBarTitle("") 54 | .navigationBarHidden(true) 55 | 56 | ActivityView() 57 | .tabItem { 58 | if selectedTab == 2 { 59 | Image(systemName: "heart.fill") 60 | .renderingMode(.template) 61 | } else { 62 | Image(systemName: "heart") 63 | .renderingMode(.template) 64 | } 65 | } 66 | .tag(2) 67 | .navigationBarTitle("") 68 | .navigationBarHidden(true) 69 | 70 | ProfileView() 71 | .tabItem { 72 | if selectedTab == 3 { 73 | Image(systemName: "person.fill") 74 | .renderingMode(.template) 75 | } else { 76 | Image(systemName: "person") 77 | .renderingMode(.template) 78 | } 79 | } 80 | .tag(3) 81 | .navigationBarTitle("") 82 | .navigationBarHidden(true) 83 | } 84 | } 85 | } 86 | .environmentObject(userStatus) 87 | .accentColor(Color(tintColor)) 88 | .statusBar(hidden: true) 89 | .navigationViewStyle(StackNavigationViewStyle()) 90 | } 91 | } 92 | 93 | struct MainView_Previews: PreviewProvider { 94 | static var previews: some View { 95 | MainView() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /SnapCat/Models/Activity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Activity.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | 12 | struct Activity: ParseObject { 13 | 14 | var objectId: String? 15 | var createdAt: Date? 16 | var updatedAt: Date? 17 | var ACL: ParseACL? 18 | var originalData: Data? 19 | 20 | var fromUser: User? 21 | var toUser: User? 22 | var type: ActionType? 23 | var comment: String? 24 | var post: Post? 25 | var activity: Pointer? 26 | 27 | enum ActionType: String, Codable { 28 | case like 29 | case follow 30 | case comment 31 | } 32 | 33 | enum LikeState: String, Codable { 34 | case like 35 | case unlike 36 | case error 37 | } 38 | 39 | func merge(with object: Self) throws -> Self { 40 | var updated = try mergeParse(with: object) 41 | if updated.shouldRestoreKey(\.fromUser, 42 | original: object) { 43 | updated.fromUser = object.fromUser 44 | } 45 | if updated.shouldRestoreKey(\.toUser, 46 | original: object) { 47 | updated.toUser = object.toUser 48 | } 49 | if updated.shouldRestoreKey(\.type, 50 | original: object) { 51 | updated.type = object.type 52 | } 53 | if updated.shouldRestoreKey(\.comment, 54 | original: object) { 55 | updated.comment = object.comment 56 | } 57 | if updated.shouldRestoreKey(\.post, 58 | original: object) { 59 | updated.post = object.post 60 | } 61 | if updated.shouldRestoreKey(\.activity, 62 | original: object) { 63 | updated.activity = object.activity 64 | } 65 | return updated 66 | } 67 | 68 | func setupForFollowing() throws -> Activity { 69 | var activity = self 70 | if activity.type == .follow { 71 | guard let followUser = activity.toUser else { 72 | throw SnapCatError(message: "missing \(ActivityKey.toUser)") 73 | } 74 | activity.ACL?.setWriteAccess(user: followUser, value: true) 75 | activity.ACL?.setReadAccess(user: followUser, value: true) 76 | } else { 77 | throw SnapCatError(message: "Can't setup for following for type: \"\(String(describing: type))\"") 78 | } 79 | return activity 80 | } 81 | 82 | static func like(post: Post) -> Self { 83 | var activity = Activity(type: .like, from: User.current, to: post.user) 84 | activity.post = post 85 | return activity 86 | } 87 | } 88 | 89 | extension Activity { 90 | 91 | init(type: ActionType, from fromUser: User?, to toUser: User?) { 92 | self.type = type 93 | self.fromUser = fromUser 94 | self.toUser = toUser 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /SnapCat/Models/Installation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Installation.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | 12 | struct Installation: ParseInstallation { 13 | var deviceType: String? 14 | 15 | var installationId: String? 16 | 17 | var deviceToken: String? 18 | 19 | var badge: Int? 20 | 21 | var timeZone: String? 22 | 23 | var channels: [String]? 24 | 25 | var appName: String? 26 | 27 | var appIdentifier: String? 28 | 29 | var appVersion: String? 30 | 31 | var parseVersion: String? 32 | 33 | var localeIdentifier: String? 34 | 35 | var objectId: String? 36 | 37 | var createdAt: Date? 38 | 39 | var updatedAt: Date? 40 | 41 | var ACL: ParseACL? 42 | 43 | var originalData: Data? 44 | 45 | var user: User? 46 | 47 | func merge(with object: Self) throws -> Self { 48 | var updated = try mergeParse(with: object) 49 | if updated.shouldRestoreKey(\.user, 50 | original: object) { 51 | updated.user = object.user 52 | } 53 | return updated 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /SnapCat/Models/Post.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Post.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | 12 | struct Post: ParseObject { 13 | 14 | var objectId: String? 15 | var createdAt: Date? 16 | var updatedAt: Date? 17 | var ACL: ParseACL? 18 | var originalData: Data? 19 | 20 | var user: User? 21 | var image: ParseFile? 22 | var thumbnail: ParseFile? 23 | var location: ParseGeoPoint? 24 | var caption: String? 25 | 26 | func merge(with object: Self) throws -> Self { 27 | var updated = try mergeParse(with: object) 28 | if updated.shouldRestoreKey(\.user, 29 | original: object) { 30 | updated.user = object.user 31 | } 32 | if updated.shouldRestoreKey(\.image, 33 | original: object) { 34 | updated.image = object.image 35 | } 36 | if updated.shouldRestoreKey(\.thumbnail, 37 | original: object) { 38 | updated.thumbnail = object.thumbnail 39 | } 40 | if updated.shouldRestoreKey(\.location, 41 | original: object) { 42 | updated.location = object.location 43 | } 44 | if updated.shouldRestoreKey(\.caption, 45 | original: object) { 46 | updated.caption = object.caption 47 | } 48 | return updated 49 | } 50 | } 51 | 52 | extension Post { 53 | init(image: ParseFile?) { 54 | user = User.current 55 | self.image = image 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /SnapCat/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | 12 | struct User: ParseUser { 13 | 14 | // Mandatory properties 15 | var authData: [String: [String: String]?]? 16 | var username: String? 17 | var email: String? 18 | var emailVerified: Bool? 19 | var password: String? 20 | var objectId: String? 21 | var createdAt: Date? 22 | var updatedAt: Date? 23 | var ACL: ParseACL? 24 | var originalData: Data? 25 | 26 | // Custom properties 27 | var name: String? 28 | var profileImage: ParseFile? 29 | var profileThumbnail: ParseFile? 30 | var bio: String? 31 | var link: URL? 32 | 33 | func merge(with object: Self) throws -> Self { 34 | var updated = try mergeParse(with: object) 35 | if updated.shouldRestoreKey(\.name, 36 | original: object) { 37 | updated.name = object.name 38 | } 39 | if updated.shouldRestoreKey(\.profileImage, 40 | original: object) { 41 | updated.profileImage = object.profileImage 42 | } 43 | if updated.shouldRestoreKey(\.profileThumbnail, 44 | original: object) { 45 | updated.profileThumbnail = object.profileThumbnail 46 | } 47 | if updated.shouldRestoreKey(\.bio, 48 | original: object) { 49 | updated.bio = object.bio 50 | } 51 | if updated.shouldRestoreKey(\.link, 52 | original: object) { 53 | updated.link = object.link 54 | } 55 | return updated 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /SnapCat/Onboarding/OnboardingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ParseSwift 11 | import AuthenticationServices 12 | 13 | struct OnboardingView: View { 14 | @Environment(\.tintColor) private var tintColor 15 | @EnvironmentObject var userStatus: UserStatus 16 | @StateObject private var viewModel = OnboardingViewModel() 17 | @State var gradient = LinearGradient(gradient: Gradient(colors: [Color(#colorLiteral(red: 0.7843137255, green: 0.7843137255, blue: 0.7843137255, alpha: 1)), Color(#colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1))]), 18 | startPoint: .top, 19 | endPoint: .bottom) 20 | @State private var usersname = "" 21 | @State private var password = "" 22 | @State var name: String = "" 23 | @State private var signupLoginSegmentValue = 0 24 | @State private var presentMainScreen = false 25 | 26 | var body: some View { 27 | 28 | VStack { 29 | 30 | Text("SnapCat") 31 | .font(Font.custom("noteworthy-bold", size: 40)) 32 | .foregroundColor(.white) 33 | .padding([.top], 40) 34 | 35 | Image("Snapcat") 36 | .resizable() 37 | .frame(width: 250, height: 250, alignment: .center) 38 | .clipShape(Circle()) 39 | .overlay(Circle().stroke(gradient, lineWidth: 4)) 40 | .shadow(radius: 10) 41 | .padding() 42 | 43 | Picker(selection: $signupLoginSegmentValue, label: Text("Login Picker"), content: { 44 | Text("Login").tag(0) 45 | Text("Sign Up").tag(1) 46 | }) 47 | .pickerStyle(SegmentedPickerStyle()) 48 | .background(Color.white) 49 | .cornerRadius(20.0) 50 | .padding() 51 | 52 | VStack(alignment: .leading) { 53 | TextField("Username", text: $usersname) 54 | .padding() 55 | .background(Color.white) 56 | .cornerRadius(20.0) 57 | .shadow(radius: 10.0, x: 20, y: 10) 58 | 59 | SecureField("Password", text: $password) 60 | .padding() 61 | .background(Color.white) 62 | .cornerRadius(20.0) 63 | .shadow(radius: 10.0, x: 20, y: 10) 64 | 65 | if signupLoginSegmentValue == 1 { 66 | TextField("Name", text: $name) 67 | .padding() 68 | .background(Color.white) 69 | .cornerRadius(20.0) 70 | .shadow(radius: 10.0, x: 20, y: 10) 71 | } 72 | }.padding([.leading, .trailing], 27.5) 73 | 74 | Button(action: { 75 | if signupLoginSegmentValue == 1 { 76 | Task { 77 | await viewModel.signup(username: usersname, 78 | password: password, 79 | name: name) 80 | } 81 | } else { 82 | Task { 83 | await viewModel.login(username: usersname, password: password) 84 | } 85 | } 86 | 87 | }, label: { 88 | 89 | if signupLoginSegmentValue == 1 { 90 | Text("Sign Up") 91 | .font(.headline) 92 | .foregroundColor(.white) 93 | .padding() 94 | .frame(width: 300, height: 50) 95 | } else { 96 | Text("Login") 97 | .font(.headline) 98 | .foregroundColor(.white) 99 | .padding() 100 | .frame(width: 300, height: 50) 101 | } 102 | }) 103 | .background(Color(.green)) 104 | .cornerRadius(15) 105 | 106 | Button(action: { 107 | Task { 108 | await viewModel.loginAnonymously() 109 | } 110 | 111 | }, label: { 112 | 113 | if signupLoginSegmentValue == 1 { 114 | Text("Login Anonymously") 115 | .font(.headline) 116 | .foregroundColor(.white) 117 | .padding() 118 | .frame(width: 300, height: 50) 119 | } else { 120 | Text("Login Anonymously") 121 | .font(.headline) 122 | .foregroundColor(.white) 123 | .padding() 124 | .frame(width: 300, height: 50) 125 | } 126 | }) 127 | .background(Color(#colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1))) 128 | .cornerRadius(15) 129 | 130 | SignInWithAppleButton(.signIn, 131 | onRequest: { (request) in 132 | request.requestedScopes = [.fullName, .email] 133 | }, 134 | onCompletion: { (result) in 135 | 136 | switch result { 137 | case .success(let authorization): 138 | Task { 139 | await viewModel.loginWithApple(authorization: authorization) 140 | } 141 | case .failure(let error): 142 | viewModel.loginError = SnapCatError(message: error.localizedDescription) 143 | } 144 | }) 145 | .frame(width: 300, height: 50) 146 | .cornerRadius(15) 147 | 148 | // If error occurs show it on the screen 149 | if let error = viewModel.loginError { 150 | Text("Error: \(error.message)") 151 | .foregroundColor(.red) 152 | } 153 | 154 | Spacer() 155 | } 156 | .onReceive(viewModel.$isLoggedOut, perform: { value in 157 | if self.userStatus.isLoggedOut != value { 158 | self.userStatus.check() 159 | } 160 | }) 161 | .background(LinearGradient(gradient: Gradient(colors: [Color(#colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1)), Color(#colorLiteral(red: 0.7843137255, green: 0.7843137255, blue: 0.7843137255, alpha: 1))]), 162 | startPoint: .top, 163 | endPoint: .bottom)) 164 | .edgesIgnoringSafeArea(.all) 165 | .signInWithAppleButtonStyle(.black) 166 | .navigationBarHidden(true) 167 | .navigationBarBackButtonHidden(true) 168 | } 169 | } 170 | 171 | struct OnboardingView_Previews: PreviewProvider { 172 | static var previews: some View { 173 | OnboardingView() 174 | .environmentObject(UserStatus(isLoggedOut: false)) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /SnapCat/Onboarding/OnboardingViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | import AuthenticationServices 12 | import os.log 13 | 14 | // swiftlint:disable cyclomatic_complexity 15 | 16 | class OnboardingViewModel: ObservableObject { 17 | 18 | @Published private(set) var isLoggedOut = true 19 | @Published var loginError: SnapCatError? 20 | 21 | init() { 22 | if User.current != nil { 23 | DispatchQueue.main.async { 24 | self.isLoggedOut = false 25 | } 26 | } 27 | } 28 | 29 | // MARK: Intents 30 | /** 31 | Signs up the user *asynchronously*. 32 | 33 | This will also enforce that the username isn't already taken. 34 | 35 | - parameter username: The username the user is signing in with. 36 | - parameter password: The password the user is signing in with. 37 | - parameter name: The name the user is signing in with. 38 | */ 39 | @MainActor 40 | func signup(username: String, password: String, name: String) async { 41 | var user = User() 42 | user.username = username 43 | user.password = password 44 | user.name = name 45 | do { 46 | guard try await Utility.isServerAvailable() else { 47 | Logger.onboarding.error("Server health is not \"ok\"") 48 | return 49 | } 50 | let user = try await user.signup() 51 | Logger.onboarding.debug("Signup Successful: \(user)") 52 | self.completeOnboarding() 53 | } catch { 54 | guard let parseError = error as? ParseError else { 55 | Logger.onboarding.error("\(error.localizedDescription)") 56 | return 57 | } 58 | Logger.onboarding.error("\(parseError)") 59 | switch parseError.code { 60 | case .usernameTaken: // Account already exists for this username. 61 | self.loginError = SnapCatError(parseError: parseError) 62 | 63 | default: 64 | // There was a different issue that we don't know how to handle 65 | Logger.onboarding.error(""" 66 | *** Error Signing up as user for Parse Server. Are you running parse-hipaa 67 | and is the initialization complete? Check http://localhost:1337 in your 68 | browser. If you are still having problems check for help here: 69 | https://github.com/netreconlab/parse-postgres#getting-started *** 70 | """) 71 | self.loginError = SnapCatError(parseError: parseError) 72 | } 73 | } 74 | } 75 | 76 | /** 77 | Logs in the user *asynchronously*. 78 | 79 | This will also enforce that the username isn't already taken. 80 | 81 | - parameter username: The username the user is logging in with. 82 | - parameter password: The password the user is logging in with. 83 | */ 84 | @MainActor 85 | func login(username: String, password: String) async { 86 | do { 87 | guard try await Utility.isServerAvailable() else { 88 | Logger.onboarding.error("Server health is not \"ok\"") 89 | return 90 | } 91 | let user = try await User.login(username: username, password: password) 92 | Logger.onboarding.debug("Login Success: \(user, privacy: .private)") 93 | self.completeOnboarding() 94 | } catch { 95 | guard let parseError = error as? ParseError else { 96 | Logger.onboarding.error("\(error.localizedDescription)") 97 | return 98 | } 99 | Logger.onboarding.error(""" 100 | *** Error logging into Parse Server. If you are still having problems 101 | check for help here: 102 | https://github.com/netreconlab/parse-hipaa#getting-started *** 103 | """) 104 | Logger.onboarding.debug("Login Error: \(parseError)") 105 | self.loginError = SnapCatError(parseError: parseError) 106 | } 107 | 108 | } 109 | 110 | /** 111 | Logs in the user anonymously *asynchronously*. 112 | */ 113 | @MainActor 114 | func loginAnonymously() async { 115 | do { 116 | guard try await Utility.isServerAvailable() else { 117 | Logger.onboarding.error("Server health is not \"ok\"") 118 | return 119 | } 120 | let user = try await User.anonymous.login() 121 | Logger.onboarding.debug("Anonymous Login Success: \(user, privacy: .private)") 122 | self.completeOnboarding() 123 | } catch { 124 | guard let parseError = error as? ParseError else { 125 | Logger.onboarding.error("\(error.localizedDescription)") 126 | return 127 | } 128 | Logger.onboarding.error(""" 129 | *** Error logging into Parse Server. If you are still having 130 | problems check for help here: 131 | https://github.com/netreconlab/parse-hipaa#getting-started *** 132 | """) 133 | Logger.onboarding.error("Anonymous Login Error: \(parseError)") 134 | self.loginError = SnapCatError(parseError: parseError) 135 | } 136 | } 137 | 138 | /** 139 | Logs in the user with Apple *asynchronously*. 140 | - parameter authorization: The encapsulation of a successful authorization performed by a controller.. 141 | */ 142 | @MainActor 143 | func loginWithApple(authorization: ASAuthorization) async { // swiftlint:disable:this function_body_length 144 | guard let credentials = authorization.credential as? ASAuthorizationAppleIDCredential, 145 | let identityToken = credentials.identityToken else { 146 | let error = "Failed unwrapping Apple authorization credentials." 147 | Logger.onboarding.error("Apple Login Error: \(error)") 148 | self.loginError = SnapCatError(message: error) 149 | return 150 | } 151 | do { 152 | guard try await Utility.isServerAvailable() else { 153 | Logger.onboarding.error("Server health is not \"ok\"") 154 | return 155 | } 156 | var user = try await User.apple.login(user: credentials.user, identityToken: identityToken) 157 | var isUpdatedUser = false 158 | if credentials.email != nil { 159 | user.email = credentials.email 160 | isUpdatedUser = true 161 | } 162 | if user.name == nil { 163 | if let name = credentials.fullName { 164 | var currentName = "" 165 | if let givenName = name.givenName { 166 | currentName = givenName 167 | } 168 | if let familyName = name.familyName { 169 | if currentName != "" { 170 | currentName = "\(currentName) \(familyName)" 171 | } else { 172 | currentName = familyName 173 | } 174 | } 175 | user.name = currentName 176 | isUpdatedUser = true 177 | } 178 | } 179 | let loggedInUser: User! 180 | if isUpdatedUser { 181 | loggedInUser = try await user.save() 182 | } else { 183 | loggedInUser = user 184 | } 185 | Logger.onboarding.debug("Apple Login Success: \(loggedInUser, privacy: .private)") 186 | self.completeOnboarding() 187 | } catch { 188 | guard let parseError = error as? ParseError else { 189 | Logger.onboarding.error("\(error.localizedDescription)") 190 | return 191 | } 192 | Logger.onboarding.error("Apple Login Error: \(parseError)") 193 | self.loginError = SnapCatError(parseError: parseError) 194 | } 195 | } 196 | 197 | // MARK: - Helper Methods 198 | @MainActor 199 | func completeOnboarding() { 200 | Self.setDefaultACL() 201 | saveInstallation() 202 | registerForNotifications() 203 | isLoggedOut = false 204 | } 205 | 206 | func saveInstallation() { 207 | // Setup installation to receive push notifications 208 | guard var currentInstallation = Installation.current else { 209 | return 210 | } 211 | currentInstallation.user = User.current 212 | currentInstallation.channels = ["global"] // Subscribe to particular channels 213 | let installation = currentInstallation 214 | Task { 215 | do { 216 | let installation = try await installation.save() 217 | Logger.installation.debug(""" 218 | Parse Installation saved, can now receive 219 | push notificaitons. \(installation, privacy: .private) 220 | """) 221 | } catch { 222 | Logger.installation.debug("Error saving Parse Installation saved: \(error.localizedDescription)") 223 | } 224 | } 225 | } 226 | 227 | func registerForNotifications() { 228 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (allowed, error) in 229 | if allowed { 230 | Logger.notification.debug("User allowed notifications") 231 | } else { 232 | Logger.notification.debug("\(error.debugDescription)") 233 | } 234 | } 235 | } 236 | 237 | class func setDefaultACL() { 238 | if User.current != nil { 239 | // Set default ACL for all ParseObjects 240 | var defaultACL = ParseACL() 241 | defaultACL.publicRead = true 242 | defaultACL.publicWrite = false 243 | do { 244 | _ = try ParseACL.setDefaultACL(defaultACL, withAccessForCurrentUser: true) 245 | } catch { 246 | guard let parseError = error as? ParseError else { 247 | Logger.main.error("Error setting default ACL: \(error.localizedDescription)") 248 | return 249 | } 250 | Logger.main.error("Error setting default ACL: \(parseError)") 251 | } 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /SnapCat/ParseSwift.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ApplicationID 6 | E036A0C5-6829-4B40-9B3B-3E05F6DF32B2 7 | Server 8 | http://localhost:1337/parse 9 | UseTransactions 10 | 11 | CacheMemoryCapacity 12 | 60000000 13 | CacheDiskCapacity 14 | 100000000 15 | DeleteKeychainIfNeeded 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /SnapCat/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SnapCat/Profile/ProfileEditView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileEditView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/10/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import os.log 11 | import ParseSwift 12 | 13 | struct ProfileEditView: View { 14 | @ObservedObject var viewModel: ProfileViewModel 15 | @Environment(\.presentationMode) var presentationMode 16 | @State var isShowAlert = false 17 | @State var isPasswordResetSuccess = false 18 | 19 | var body: some View { 20 | NavigationView { 21 | Form { 22 | Section { 23 | TextField("Username", text: $viewModel.username) 24 | TextField("Email", text: $viewModel.email) 25 | TextField("Name", text: $viewModel.name) 26 | TextField("Bio", text: $viewModel.bio) 27 | TextField("Link", text: $viewModel.link) 28 | } 29 | 30 | Section { 31 | Button(action: { 32 | Task { 33 | do { 34 | try await viewModel.resetPassword() 35 | self.isPasswordResetSuccess = true 36 | } catch { 37 | Logger.profile.error("\(error.localizedDescription)") 38 | } 39 | self.isShowAlert = true 40 | } 41 | }, label: { 42 | Text("Reset Password") 43 | }) 44 | } 45 | } 46 | .navigationBarBackButtonHidden(true) 47 | .navigationTitle(Text("Edit Profile")) 48 | .navigationBarItems(leading: Button(action: { 49 | self.presentationMode.wrappedValue.dismiss() 50 | }, label: { 51 | Text("Cancel") 52 | }), trailing: Button(action: { 53 | Task { 54 | do { 55 | _ = try await viewModel.saveUpdates() 56 | self.presentationMode.wrappedValue.dismiss() 57 | } catch { 58 | guard let parseError = error as? SnapCatError else { 59 | return 60 | } 61 | if parseError.message.contains("No new changes") { 62 | self.presentationMode.wrappedValue.dismiss() 63 | return 64 | } 65 | self.isShowAlert = true 66 | } 67 | } 68 | }, label: { 69 | Text("Done") 70 | })) 71 | .alert(isPresented: $isShowAlert, content: { 72 | if let error = viewModel.error { 73 | return Alert(title: Text("Error"), 74 | message: Text(error.message), 75 | dismissButton: .default(Text("Ok"), action: { 76 | self.viewModel.error = nil 77 | }) 78 | ) 79 | } else if self.isPasswordResetSuccess { 80 | return Alert(title: Text("Password Reset"), 81 | message: Text("Please check your email for directions on how to reset your password"), 82 | dismissButton: .default(Text("Ok"), action: { 83 | self.presentationMode.wrappedValue.dismiss() 84 | }) 85 | ) 86 | } else { 87 | return Alert(title: Text("Updates Saved"), 88 | message: Text("All changes saved!"), 89 | dismissButton: .default(Text("Ok"), action: { 90 | self.presentationMode.wrappedValue.dismiss() 91 | }) 92 | ) 93 | } 94 | 95 | }) 96 | } 97 | } 98 | } 99 | 100 | struct ProfileEditView_Previews: PreviewProvider { 101 | static var previews: some View { 102 | ProfileEditView(viewModel: ProfileViewModel(user: nil, 103 | isShowingHeading: true)) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /SnapCat/Profile/ProfileHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileHeaderView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/18/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ProfileHeaderView: View { 12 | @Environment(\.tintColor) private var tintColor 13 | @StateObject var postStatus = PostStatus() 14 | @ObservedObject var viewModel: ProfileViewModel 15 | @ObservedObject var timeLineViewModel: QueryImageViewModel 16 | @State var isShowingOptions = false 17 | var body: some View { 18 | HStack { 19 | NavigationLink(destination: PostView(timeLineViewModel: timeLineViewModel) 20 | .environmentObject(postStatus), 21 | isActive: $postStatus.isShowing) { 22 | EmptyView() 23 | } 24 | NavigationLink(destination: SettingsView(), 25 | isActive: $isShowingOptions) { 26 | EmptyView() 27 | } 28 | if let username = viewModel.user.username { 29 | Text(username) 30 | .font(.title) 31 | .frame(alignment: .leading) 32 | .padding() 33 | } 34 | Spacer() 35 | if viewModel.user.objectId == User.current?.objectId { 36 | Button(action: { 37 | self.postStatus.isShowing = true 38 | }, label: { 39 | Image(systemName: "square.and.pencil") 40 | .resizable() 41 | .foregroundColor(Color(tintColor)) 42 | .frame(width: 30, height: 30, alignment: .trailing) 43 | }) 44 | Button(action: { 45 | self.isShowingOptions = true 46 | }, label: { 47 | Image(systemName: "slider.horizontal.3") 48 | .resizable() 49 | .foregroundColor(Color(tintColor)) 50 | .frame(width: 30, height: 30, alignment: .trailing) 51 | .padding([.trailing]) 52 | }) 53 | } 54 | } 55 | } 56 | } 57 | 58 | struct ProfileHeaderView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | ProfileHeaderView(viewModel: .init(user: User(), 61 | isShowingHeading: true), 62 | timeLineViewModel: .init(query: Post.query())) 63 | .environmentObject(PostStatus(isShowing: true)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /SnapCat/Profile/ProfileUserDetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileUserDetailsView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/18/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ParseSwift 11 | 12 | struct ProfileUserDetailsView: View { 13 | @Environment(\.tintColor) private var tintColor 14 | @ObservedObject var viewModel: ProfileViewModel 15 | @ObservedObject var followersViewModel: QueryViewModel 16 | @ObservedObject var followingsViewModel: QueryViewModel 17 | @ObservedObject var timeLineViewModel: QueryImageViewModel 18 | @State var gradient = LinearGradient(gradient: Gradient(colors: [Color(#colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1)), Color(#colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1))]), 19 | startPoint: .top, 20 | endPoint: .bottom) 21 | @State var explorerView: ExploreView? 22 | @State var isShowingImagePicker = false 23 | @State var isShowingEditProfile = false 24 | @State var isShowingExplorer = false 25 | 26 | var body: some View { 27 | VStack { 28 | NavigationLink(destination: self.explorerView 29 | .navigationBarHidden(false), 30 | isActive: $isShowingExplorer) { 31 | EmptyView() 32 | } 33 | HStack { 34 | Button(action: { 35 | self.isShowingImagePicker = true 36 | }, label: { 37 | if let image = viewModel.profilePicture { 38 | Image(uiImage: image) 39 | .resizable() 40 | .frame(idealWidth: 90, maxWidth: 90, idealHeight: 90, maxHeight: 90) 41 | .clipShape(Circle()) 42 | .shadow(radius: 3) 43 | .overlay(Circle().stroke(gradient, lineWidth: 3)) 44 | .padding() 45 | } else { 46 | Image(systemName: "person.circle") 47 | .resizable() 48 | .frame(idealWidth: 90, maxWidth: 90, idealHeight: 90, maxHeight: 90) 49 | .clipShape(Circle()) 50 | .shadow(radius: 3) 51 | .overlay(Circle().stroke(Color(tintColor), lineWidth: 3)) 52 | .padding() 53 | } 54 | }) 55 | .buttonStyle(PlainButtonStyle()) 56 | Spacer() 57 | VStack { 58 | Text("\(timeLineViewModel.results.count)") 59 | .padding(.trailing) 60 | Text("Posts") 61 | .padding(.trailing) 62 | } 63 | Button(action: { 64 | let explorerViewModel = ExploreViewModel(isShowingFollowers: true, 65 | followersViewModel: followersViewModel, 66 | followingsViewModel: followingsViewModel) 67 | self.explorerView = ExploreView(viewModel: explorerViewModel) 68 | self.isShowingExplorer = true 69 | }, label: { 70 | VStack { 71 | Text("\(followersViewModel.results.count)") 72 | .padding(.trailing) 73 | Text("Followers") 74 | .padding(.trailing) 75 | } 76 | }) 77 | .buttonStyle(PlainButtonStyle()) 78 | Button(action: { 79 | let explorerViewModel = ExploreViewModel(isShowingFollowers: false, 80 | followersViewModel: followersViewModel, 81 | followingsViewModel: followingsViewModel) 82 | self.explorerView = ExploreView(viewModel: explorerViewModel) 83 | self.isShowingExplorer = true 84 | }, label: { 85 | VStack { 86 | Text("\(followingsViewModel.results.count)") 87 | .padding(.trailing) 88 | Text("Following") 89 | .padding(.trailing) 90 | } 91 | }) 92 | .buttonStyle(PlainButtonStyle()) 93 | } 94 | HStack { 95 | VStack(alignment: .leading) { 96 | if let name = viewModel.user.name { 97 | Text(name) 98 | .padding([.leading]) 99 | .font(.title2) 100 | } 101 | if let bio = viewModel.user.bio { 102 | Text(bio) 103 | .padding([.leading]) 104 | } 105 | if let link = viewModel.user.link { 106 | Link(destination: link, label: { 107 | Text("\(link.absoluteString)") 108 | }) 109 | .padding([.leading]) 110 | } 111 | } 112 | Spacer() 113 | } 114 | if viewModel.user.objectId == User.current?.objectId { 115 | Button(action: { 116 | self.isShowingEditProfile = true 117 | }, label: { 118 | Text("Edit Profile") 119 | .frame(minWidth: 0, maxWidth: .infinity) 120 | .foregroundColor(.white) 121 | .padding() 122 | .cornerRadius(15) 123 | .background(Color(tintColor)) 124 | }) 125 | .padding([.leading, .trailing], 20) 126 | } else { 127 | if viewModel.isCurrentFollowing() { 128 | Button(action: { 129 | Task { 130 | await self.viewModel.unfollowUser() 131 | } 132 | }, label: { 133 | Text("Unfollow") 134 | .frame(minWidth: 0, maxWidth: .infinity) 135 | .foregroundColor(.white) 136 | .padding() 137 | .cornerRadius(15) 138 | .background(Color(#colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1))) 139 | }) 140 | .padding([.leading, .trailing], 20) 141 | } else { 142 | Button(action: { 143 | Task { 144 | await self.viewModel.followUser() 145 | } 146 | }, label: { 147 | Text("Follow") 148 | .frame(minWidth: 0, maxWidth: .infinity) 149 | .foregroundColor(.white) 150 | .padding() 151 | .cornerRadius(15) 152 | .background(Color(#colorLiteral(red: 0.7843137255, green: 0.7843137255, blue: 0.7843137255, alpha: 1))) 153 | }) 154 | .padding([.leading, .trailing], 20) 155 | } 156 | } 157 | }.sheet(isPresented: $isShowingImagePicker, onDismiss: {}, content: { 158 | ImagePickerView(image: $viewModel.profilePicture) 159 | }).sheet(isPresented: $isShowingEditProfile, onDismiss: {}, content: { 160 | ProfileEditView(viewModel: viewModel) 161 | }) 162 | } 163 | } 164 | 165 | struct ProfileUserDetailsView_Previews: PreviewProvider { 166 | static var previews: some View { 167 | ProfileUserDetailsView(viewModel: .init(user: User(), isShowingHeading: true), 168 | followersViewModel: .init(query: Activity.query()), 169 | followingsViewModel: .init(query: Activity.query()), 170 | timeLineViewModel: .init(query: Post.query())) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /SnapCat/Profile/ProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ParseSwift 11 | 12 | struct ProfileView: View { 13 | @Environment(\.tintColor) private var tintColor 14 | @ObservedObject var timeLineViewModel: QueryImageViewModel 15 | @ObservedObject var followersViewModel: QueryViewModel 16 | @ObservedObject var followingsViewModel: QueryViewModel 17 | @ObservedObject var viewModel: ProfileViewModel 18 | 19 | var body: some View { 20 | VStack { 21 | if viewModel.isShowingHeading { 22 | ProfileHeaderView(viewModel: viewModel, 23 | timeLineViewModel: timeLineViewModel) 24 | } else { 25 | HStack { 26 | if let username = viewModel.user.username { 27 | Text(username) 28 | .font(.title) 29 | .frame(alignment: .leading) 30 | .padding() 31 | } 32 | if viewModel.isCurrentFollower() { 33 | Label("Follows You", 34 | systemImage: "checkmark.square.fill") 35 | } 36 | Spacer() 37 | } 38 | } 39 | ProfileUserDetailsView(viewModel: viewModel, 40 | followersViewModel: followersViewModel, 41 | followingsViewModel: followingsViewModel, 42 | timeLineViewModel: timeLineViewModel) 43 | Divider() 44 | TimeLineView(viewModel: timeLineViewModel) 45 | }.onAppear(perform: { 46 | followersViewModel.find() 47 | followingsViewModel.find() 48 | }) 49 | } 50 | 51 | init(user: User? = nil, isShowingHeading: Bool = true) { 52 | var userProfile: User! 53 | if let user = user { 54 | userProfile = user 55 | } else if let currentUser = User.current { 56 | userProfile = currentUser 57 | } else { 58 | userProfile = User() 59 | } 60 | viewModel = ProfileViewModel(user: userProfile, 61 | isShowingHeading: isShowingHeading) 62 | let timeLineQuery = ProfileViewModel 63 | .queryUserTimeLine(userProfile) 64 | .include(PostKey.user) 65 | if let timeLine = timeLineQuery.subscribeCustom { 66 | timeLineViewModel = timeLine 67 | } else { 68 | timeLineViewModel = timeLineQuery.imageViewModel 69 | } 70 | followersViewModel = ProfileViewModel 71 | .queryFollowers(userProfile) 72 | .include(ActivityKey.fromUser) 73 | .viewModel 74 | followingsViewModel = ProfileViewModel 75 | .queryFollowings(userProfile) 76 | .include(ActivityKey.toUser) 77 | .viewModel 78 | timeLineViewModel.find() 79 | followersViewModel.find() 80 | followingsViewModel.find() 81 | } 82 | } 83 | 84 | struct ProfileView_Previews: PreviewProvider { 85 | static var previews: some View { 86 | ProfileView() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /SnapCat/Profile/ProfileViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/4/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | import SwiftUI 13 | import UIKit 14 | 15 | class ProfileViewModel: ObservableObject { // swiftlint:disable:this type_body_length 16 | @Published var user = User() 17 | @Published var error: SnapCatError? 18 | var username: String = "" { 19 | willSet { 20 | if !isSettingForFirstTime { 21 | isHasChanges = true 22 | } 23 | objectWillChange.send() 24 | } 25 | } 26 | var email: String = "" { 27 | willSet { 28 | if !isSettingForFirstTime { 29 | isHasChanges = true 30 | } 31 | objectWillChange.send() 32 | } 33 | } 34 | var name: String = "" { 35 | willSet { 36 | if !isSettingForFirstTime { 37 | isHasChanges = true 38 | } 39 | objectWillChange.send() 40 | } 41 | } 42 | var bio: String = "" { 43 | willSet { 44 | if !isSettingForFirstTime { 45 | isHasChanges = true 46 | } 47 | objectWillChange.send() 48 | } 49 | } 50 | var link: String = "" { 51 | willSet { 52 | if !isSettingForFirstTime { 53 | isHasChanges = true 54 | } 55 | objectWillChange.send() 56 | } 57 | } 58 | @Published var isHasChanges = false 59 | @Published var currentUserFollowers = [User]() 60 | @Published var currentUserFollowings = [User]() 61 | @Published var isShowingHeading = true 62 | private var settingProfilePicForFirstTime = true 63 | var profilePicture = UIImage(systemName: "person.circle") { 64 | willSet { 65 | if !isSettingForFirstTime { 66 | guard var currentUser = User.current?.mergeable, 67 | currentUser.hasSameObjectId(as: user), 68 | let image = newValue, 69 | let compressed = image.compressTo(3) else { 70 | return 71 | } 72 | let newProfilePicture = ParseFile(name: "profile.jpeg", data: compressed) 73 | currentUser.profileImage = newProfilePicture 74 | let immutableCurrentUser = currentUser 75 | Task { 76 | do { 77 | let user = try await immutableCurrentUser.save() 78 | DispatchQueue.main.async { 79 | self.user = user 80 | } 81 | do { 82 | let fetchedUser = try await user.fetch() 83 | do { 84 | guard let cachedFile = try await fetchedUser 85 | .profileImage? 86 | .fetch() else { 87 | Logger.profile.error("Error fetching pic, couldn't unwrap") 88 | return 89 | } 90 | Logger.profile.info("Saved profile pic to cache: \(cachedFile, privacy: .private)") 91 | } catch { 92 | Logger.profile.error("Error fetching pic \(error.localizedDescription)") 93 | } 94 | } catch { 95 | Logger.profile.error("Error fetching profile pic from cloud: \(error.localizedDescription)") 96 | } 97 | } catch { 98 | guard let parseError = error as? ParseError else { 99 | return 100 | } 101 | Logger.profile.error("Error saving profile pic \(error.localizedDescription)") 102 | DispatchQueue.main.async { 103 | self.error = SnapCatError(parseError: parseError) 104 | } 105 | } 106 | } 107 | } 108 | objectWillChange.send() 109 | } 110 | } 111 | private var isSettingForFirstTime = true 112 | 113 | // swiftlint:disable:next function_body_length 114 | init(user: User?, isShowingHeading: Bool) { 115 | guard let currentUser = User.current else { 116 | Logger.profile.error("User should be logged in to perfom action.") 117 | return 118 | } 119 | DispatchQueue.main.async { 120 | if let user = user { 121 | self.user = user 122 | } else { 123 | self.user = currentUser 124 | } 125 | self.isShowingHeading = isShowingHeading 126 | if let username = self.user.username { 127 | self.username = username 128 | } 129 | if let email = self.user.email { 130 | self.email = email 131 | } 132 | if let name = self.user.name { 133 | self.name = name 134 | } 135 | if let bio = self.user.bio { 136 | self.bio = bio 137 | } 138 | if let link = self.user.link { 139 | self.link = link.absoluteString 140 | } 141 | self.isSettingForFirstTime = false 142 | DispatchQueue.main.async { 143 | Task { 144 | let image = await Utility.fetchImage(self.user.profileImage) 145 | self.isSettingForFirstTime = true 146 | self.profilePicture = image 147 | self.isSettingForFirstTime = false 148 | } 149 | } 150 | Task { 151 | do { 152 | let activities = try await Self.queryFollowers().find() 153 | self.currentUserFollowers = activities.compactMap { $0.fromUser } 154 | } catch { 155 | Logger.explore.error("Failed to query current followers: \(error.localizedDescription)") 156 | } 157 | } 158 | Task { 159 | do { 160 | let activities = try await Self.queryFollowings().find() 161 | self.currentUserFollowings = activities.compactMap { $0.toUser } 162 | } catch { 163 | Logger.explore.error("Failed to query current followings: \(error.localizedDescription)") 164 | } 165 | } 166 | } 167 | } 168 | 169 | // MARK: Intents 170 | func followUser() async { 171 | do { 172 | let newActivity = try Activity(type: .follow, from: User.current, to: user) 173 | .setupForFollowing() 174 | do { 175 | _ = try await newActivity.save() 176 | } catch { 177 | Logger.profile.error("Couldn't save follow: \(error.localizedDescription)") 178 | } 179 | } catch { 180 | Logger.profile.error("Can't create follow activity \(error.localizedDescription)") 181 | } 182 | } 183 | 184 | func unfollowUser() async { 185 | do { 186 | guard let currentUser = User.current else { 187 | return 188 | } 189 | let query = try Activity.query(ActivityKey.fromUser == currentUser, 190 | ActivityKey.toUser == user, 191 | ActivityKey.type == Activity.ActionType.follow) 192 | do { 193 | let activity = try await query.first() 194 | do { 195 | try await activity.delete() 196 | } catch { 197 | Logger.profile.error("Couldn't unfollow user \(error.localizedDescription)") 198 | } 199 | 200 | } catch { 201 | Logger.profile.error("Couldn't find activity to unfollow \(error.localizedDescription)") 202 | } 203 | } catch { 204 | Logger.profile.error("Couldn't unwrap during unfollow \(error.localizedDescription)") 205 | } 206 | } 207 | 208 | // MARK: Helpers 209 | func isCurrentFollower() -> Bool { 210 | currentUserFollowers.first(where: { $0.hasSameObjectId(as: user) }) != nil 211 | } 212 | 213 | func isCurrentFollowing() -> Bool { 214 | currentUserFollowings.first(where: { $0.hasSameObjectId(as: user) }) != nil 215 | } 216 | 217 | class func getUsersFromFollowers(_ activities: [Activity]) -> [User] { 218 | activities.compactMap { $0.fromUser } 219 | } 220 | 221 | class func getUsersFromFollowings(_ activities: [Activity]) -> [User] { 222 | activities.compactMap { $0.toUser } 223 | } 224 | 225 | // MARK: - Intents 226 | @MainActor 227 | func saveUpdates() async throws -> User { 228 | guard var currentUser = User.current?.mergeable else { 229 | let snapCatError = SnapCatError(message: "Trying to save when user isn't logged in") 230 | Logger.profile.error("\(snapCatError.message)") 231 | throw snapCatError 232 | } 233 | if !currentUser.hasSameObjectId(as: user) { 234 | let snapCatError = SnapCatError(message: "Trying to save when this isn't the logged in user") 235 | Logger.profile.error("\(snapCatError.message)") 236 | throw snapCatError 237 | } 238 | var changesNeedToBeSaved = false 239 | if username != user.username && !username.isEmpty { 240 | currentUser.username = username 241 | changesNeedToBeSaved = true 242 | } 243 | if email != user.email && !email.isEmpty { 244 | currentUser.email = email 245 | changesNeedToBeSaved = true 246 | } 247 | if name != user.name && !name.isEmpty { 248 | currentUser.name = name 249 | changesNeedToBeSaved = true 250 | } 251 | if bio != user.bio && !bio.isEmpty { 252 | currentUser.bio = bio 253 | changesNeedToBeSaved = true 254 | } 255 | if URL(string: link) != user.link && !link.isEmpty { 256 | currentUser.link = URL(string: link) 257 | changesNeedToBeSaved = true 258 | } 259 | if changesNeedToBeSaved { 260 | let user = try await currentUser.save() 261 | Logger.profile.info("User saved updates") 262 | self.user = user 263 | self.isHasChanges = false 264 | return user 265 | } else { 266 | let snapCatError = SnapCatError(message: "No new changes to save") 267 | Logger.profile.debug("\(snapCatError.message)") 268 | throw snapCatError 269 | } 270 | } 271 | 272 | func resetPassword() async throws { 273 | guard let email = User.current?.email else { 274 | let snapCatError = SnapCatError(message: "Need to save a valid email address before reseting password") 275 | self.error = snapCatError 276 | throw snapCatError 277 | } 278 | do { 279 | return try await User.passwordReset(email: email) 280 | } catch { 281 | guard let parseError = error as? ParseError else { 282 | return 283 | } 284 | let snapCatError = SnapCatError(parseError: parseError) 285 | self.error = snapCatError 286 | throw snapCatError 287 | } 288 | } 289 | 290 | // MARK: - Queries 291 | class func queryUserTimeLine(_ user: User?=nil) -> Query { 292 | let userPointer: Pointer! 293 | if let otherUser = user { 294 | guard let pointer = try? otherUser.toPointer() else { 295 | return Post.query().limit(0) 296 | } 297 | userPointer = pointer 298 | } else { 299 | guard let pointer = try? User.current?.toPointer() else { 300 | return Post.query().limit(0) 301 | } 302 | userPointer = pointer 303 | } 304 | return Post.query(PostKey.user == userPointer) 305 | .order([.descending(ParseKey.createdAt)]) 306 | } 307 | 308 | class func queryFollowers(_ user: User?=nil) -> Query { 309 | 310 | var query = Activity.query().limit(0) 311 | if let currentUser = User.current { 312 | if let user = user { 313 | query = Activity.query(ActivityKey.toUser == user.objectId, 314 | ActivityKey.fromUser != user.objectId, 315 | ActivityKey.type == Activity.ActionType.follow.rawValue) 316 | .order([.descending(ParseKey.updatedAt)]) 317 | } else { 318 | query = Activity.query(ActivityKey.toUser == currentUser.objectId, 319 | ActivityKey.fromUser != currentUser.objectId, 320 | ActivityKey.type == Activity.ActionType.follow.rawValue) 321 | .order([.descending(ParseKey.updatedAt)]) 322 | } 323 | } 324 | return query 325 | } 326 | 327 | class func queryFollowings(_ user: User?=nil) -> Query { 328 | 329 | if let user = user { 330 | return Activity.query(ActivityKey.fromUser == user.objectId, 331 | ActivityKey.toUser != user.objectId, 332 | ActivityKey.type == Activity.ActionType.follow.rawValue) 333 | .order([.descending(ParseKey.updatedAt)]) 334 | } else { 335 | guard let currentUser = User.current else { 336 | Logger.main.error("Utility.queryActivitiesForFollowings(), user not logged in.") 337 | return Activity.query().limit(0) 338 | } 339 | return Activity.query(ActivityKey.fromUser == currentUser.objectId, 340 | ActivityKey.toUser != currentUser.objectId, 341 | ActivityKey.type == Activity.ActionType.follow.rawValue) 342 | .order([.descending(ParseKey.updatedAt)]) 343 | } 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /SnapCat/Profile/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/10/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import AuthenticationServices 11 | 12 | struct SettingsView: View { 13 | 14 | @Environment(\.tintColor) private var tintColor 15 | @EnvironmentObject var userStatus: UserStatus 16 | @StateObject var viewModel = SettingsViewModel() 17 | 18 | var body: some View { 19 | VStack { 20 | Spacer() 21 | if !User.apple.isLinked { 22 | SignInWithAppleButton(.continue, 23 | onRequest: { (request) in 24 | request.requestedScopes = [.fullName, .email] 25 | }, 26 | onCompletion: { (result) in 27 | 28 | switch result { 29 | case .success(let authorization): 30 | Task { 31 | await viewModel.linkWithApple(authorization: authorization) 32 | } 33 | case .failure(let error): 34 | viewModel.linkError = SnapCatError(message: error.localizedDescription) 35 | } 36 | }) 37 | .frame(width: 300, height: 50) 38 | .cornerRadius(15) 39 | } 40 | Button(action: { 41 | Task { 42 | await viewModel.logout() 43 | } 44 | }, label: { 45 | Text("Log out") 46 | .font(.headline) 47 | .foregroundColor(.white) 48 | .padding() 49 | .frame(width: 300, height: 50) 50 | }) 51 | .background(Color(.red)) 52 | .cornerRadius(15) 53 | Spacer() 54 | if let link = URL(string: "https://www.cs.uky.edu/~baker/"), 55 | let image = UIImage(named: "netrecon") { 56 | HStack { 57 | Spacer() 58 | Link(destination: link, label: { 59 | VStack { 60 | Text("Developed by the") 61 | .foregroundColor(Color(tintColor)) 62 | Text("University of Kentucky") 63 | .foregroundColor(Color(tintColor)) 64 | Image(uiImage: image) 65 | .resizable() 66 | .frame(width: 100, height: 50, alignment: .center) 67 | } 68 | }) 69 | Spacer() 70 | } 71 | } 72 | }.onReceive(viewModel.$isLoggedOut, perform: { value in 73 | if self.userStatus.isLoggedOut != value { 74 | self.userStatus.check() 75 | } 76 | }) 77 | .navigationBarTitle("Settings") 78 | .navigationBarHidden(false) 79 | } 80 | } 81 | 82 | struct SettingsView_Previews: PreviewProvider { 83 | static var previews: some View { 84 | SettingsView() 85 | .environmentObject(UserStatus(isLoggedOut: false)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /SnapCat/Profile/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModel.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/10/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os.log 11 | import ParseSwift 12 | import AuthenticationServices 13 | 14 | class SettingsViewModel: ObservableObject { 15 | @Published var isLoggedOut = false 16 | @Published var linkError: SnapCatError? 17 | 18 | // MARK: - Intents 19 | /** 20 | Links the user with Apple *asynchronously*. 21 | - parameter authorization: The encapsulation of a successful authorization performed by a controller.. 22 | */ 23 | @MainActor 24 | func linkWithApple(authorization: ASAuthorization) async { 25 | guard let credentials = authorization.credential as? ASAuthorizationAppleIDCredential, 26 | let identityToken = credentials.identityToken else { 27 | let error = "Failed unwrapping Apple authorization credentials." 28 | Logger.settings.error("Apple Login Error: \(error)") 29 | return 30 | } 31 | 32 | do { 33 | var user = try await User.apple.link(user: credentials.user, 34 | identityToken: identityToken) 35 | var isUpdatedUser = false 36 | if user.email == nil && user.email != nil { 37 | user.email = credentials.email 38 | isUpdatedUser = true 39 | } 40 | if user.name == nil { 41 | if let name = credentials.fullName { 42 | var currentName = "" 43 | if let givenName = name.givenName { 44 | currentName = givenName 45 | } 46 | if let familyName = name.familyName { 47 | if currentName != "" { 48 | currentName = "\(currentName) \(familyName)" 49 | } else { 50 | currentName = familyName 51 | } 52 | } 53 | user.name = currentName 54 | isUpdatedUser = true 55 | } 56 | } 57 | let loggedInUser: User! 58 | if isUpdatedUser { 59 | loggedInUser = try await user.save() 60 | } else { 61 | loggedInUser = user 62 | } 63 | Logger.settings.debug("Apple Linking Success: \(loggedInUser, privacy: .private)") 64 | } catch { 65 | guard let parseError = error as? ParseError else { 66 | Logger.settings.error("Apple Linking Error: \(error.localizedDescription)") 67 | return 68 | } 69 | Logger.settings.error("Apple Linking Error: \(parseError)") 70 | self.linkError = SnapCatError(parseError: parseError) 71 | } 72 | } 73 | 74 | func logout() async { 75 | do { 76 | _ = try await User.logout() 77 | Logger.settings.info("User logged out") 78 | self.isLoggedOut = true 79 | } catch { 80 | guard let parseError = error as? ParseError else { 81 | Logger.settings.error("Error logging out: \(error.localizedDescription)") 82 | return 83 | } 84 | Logger.settings.error("Error logging out: \(parseError.localizedDescription)") 85 | self.linkError = SnapCatError(parseError: parseError) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /SnapCat/SnapCat.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.applesignin 8 | 9 | Default 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /SnapCat/SnapCatApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapCatApp.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ParseSwift 11 | import os.log 12 | 13 | @main 14 | struct SnapCatApp: App { 15 | 16 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 17 | @Environment(\.scenePhase) private var scenePhase 18 | 19 | var body: some Scene { 20 | WindowGroup { 21 | MainView() 22 | }.onChange(of: scenePhase, perform: { _ in 23 | 24 | }) 25 | } 26 | 27 | init() { 28 | Utility.setupServer() 29 | } 30 | } 31 | 32 | class AppDelegate: NSObject, UIApplicationDelegate { 33 | func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 34 | guard var currentInstallation = Installation.current?.mergeable else { 35 | return 36 | } 37 | currentInstallation.setDeviceToken(deviceToken) 38 | currentInstallation.save { _ in } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /SnapCat/SnapCatError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapCatError.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | 12 | public struct SnapCatError: Swift.Error { 13 | 14 | public let message: String 15 | 16 | public var localizedDescription: String { 17 | return "AssuageError error=\(message)" 18 | } 19 | } 20 | 21 | extension SnapCatError { 22 | init(parseError: ParseError) { 23 | message = parseError.description 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SnapCat/Utility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utility.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // Copyright © 2021 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ParseSwift 11 | import os.log 12 | import UIKit 13 | 14 | struct Utility { 15 | 16 | @MainActor 17 | static func fetchImage(_ file: ParseFile?) async -> UIImage? { 18 | let defaultImage = UIImage(systemName: "camera") 19 | guard let file = file else { 20 | return defaultImage 21 | } 22 | do { 23 | let image = try await file.fetch() 24 | if let path = image.localURL?.relativePath, 25 | let image = UIImage(contentsOfFile: path) { 26 | return image 27 | } else { 28 | return defaultImage 29 | } 30 | } catch { 31 | Logger.home.error("Error fetching picture: \(error.localizedDescription)") 32 | return defaultImage 33 | } 34 | } 35 | 36 | static func removeFilesAtDirectory(_ originalPath: String, isDirectory: Bool) throws { 37 | var path = URL(fileURLWithPath: originalPath, isDirectory: true) 38 | if !isDirectory { 39 | var pathArray = originalPath.components(separatedBy: "/") 40 | pathArray.removeFirst() 41 | pathArray.removeLast() 42 | path = URL(fileURLWithPath: "") 43 | pathArray.forEach { 44 | path.appendPathComponent($0, isDirectory: true) 45 | } 46 | } 47 | 48 | let contents = try FileManager.default.contentsOfDirectory(atPath: path.path) 49 | if contents.count == 0 { 50 | return 51 | } 52 | try contents.forEach { 53 | let filePath = path.appendingPathComponent($0) 54 | try FileManager.default.removeItem(at: filePath) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /SnapCat/ViewModels/PostStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostStatus.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 1/8/22. 6 | // Copyright © 2022 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class PostStatus: ObservableObject { 12 | @Published var isShowing: Bool 13 | 14 | init(isShowing: Bool = false) { 15 | self.isShowing = isShowing 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /SnapCat/ViewModels/UserStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserStatus.swift 3 | // SnapCat 4 | // 5 | // Created by Corey Baker on 1/8/22. 6 | // Copyright © 2022 Network Reconnaissance Lab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class UserStatus: ObservableObject { 12 | @Published var isLoggedOut = true 13 | 14 | init() { 15 | check() 16 | } 17 | 18 | init(isLoggedOut: Bool) { 19 | self.isLoggedOut = isLoggedOut 20 | } 21 | 22 | func check() { 23 | if User.current != nil { 24 | isLoggedOut = false 25 | } else { 26 | isLoggedOut = true 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /SnapCatTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /SnapCatTests/SnapCatTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapCatTests.swift 3 | // SnapCatTests 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // 7 | 8 | import XCTest 9 | @testable import SnapCat 10 | 11 | class SnapCatTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /SnapCatUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /SnapCatUITests/SnapCatUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SnapCatUITests.swift 3 | // SnapCatUITests 4 | // 5 | // Created by Corey Baker on 7/3/21. 6 | // 7 | 8 | import XCTest 9 | 10 | class SnapCatUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | } 18 | 19 | override func tearDownWithError() throws { 20 | // Put teardown code here. This method is called after the invocation of each test method in the class. 21 | } 22 | 23 | func testExample() throws { 24 | // UI tests must launch the application that they test. 25 | let app = XCUIApplication() 26 | app.launch() 27 | 28 | // Use recording to get started writing UI tests. 29 | // Use XCTAssert and related functions to verify your tests produce the correct results. 30 | } 31 | 32 | func testLaunchPerformance() throws { 33 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 34 | // This measures how long it takes to launch your application. 35 | measure(metrics: [XCTApplicationLaunchMetric()]) { 36 | XCUIApplication().launch() 37 | } 38 | } 39 | } 40 | } 41 | --------------------------------------------------------------------------------