├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE.md ├── README.md ├── appveyor.yml ├── circle.yml ├── docs ├── Gemfile ├── Gemfile.lock ├── _config.yml ├── _includes │ ├── apple.html │ ├── head.html │ └── windows.html ├── _layouts │ └── home.html ├── assets │ ├── custom.css │ ├── icons │ │ ├── icon-small.png │ │ ├── icon.icns │ │ ├── icon.ico │ │ └── icon.png │ └── screenshot.png └── index.md ├── electron-builder.yml ├── icons ├── icon-small.png ├── icon.icns ├── icon.ico └── icon.png ├── package.json ├── src ├── app │ ├── app.global.css │ ├── components │ │ ├── Avatar │ │ │ └── index.tsx │ │ ├── GenericHeader │ │ │ └── index.tsx │ │ ├── GenericIconWrapper │ │ │ └── index.tsx │ │ ├── GenericNameSpan │ │ │ └── index.tsx │ │ ├── LoadingStub │ │ │ └── index.tsx │ │ ├── SimpleBarRV │ │ │ └── index.tsx │ │ ├── SimpleBarStandalone │ │ │ └── index.tsx │ │ └── SimpleBarWrapper │ │ │ └── index.tsx │ ├── configureClient.ts │ ├── configureTheme.ts │ ├── graphql │ │ ├── ElectronInterface.ts │ │ ├── createElectronInterface.ts │ │ └── index.ts │ ├── index.tsx │ ├── ravenSetupRenderer.ts │ ├── scenes │ │ └── App │ │ │ ├── index.tsx │ │ │ └── scenes │ │ │ ├── Auth │ │ │ ├── components │ │ │ │ └── FacebookLoginButton │ │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ │ ├── LoadingScreen │ │ │ ├── animations.css │ │ │ └── index.tsx │ │ │ └── Main │ │ │ ├── components │ │ │ ├── NoMatches │ │ │ │ └── index.tsx │ │ │ └── ProfileHeaderLeft │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── scenes │ │ │ ├── ChatHeader │ │ │ └── index.tsx │ │ │ ├── ChatInput │ │ │ ├── components │ │ │ │ ├── SendButton │ │ │ │ │ └── index.tsx │ │ │ │ └── TextField │ │ │ │ │ ├── components │ │ │ │ │ └── EmojiInput │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ │ ├── MatchesList │ │ │ ├── components │ │ │ │ └── Match │ │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ │ ├── MessagesFeed │ │ │ ├── components │ │ │ │ ├── GenericMessage │ │ │ │ │ ├── components │ │ │ │ │ │ ├── FirstMessage │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── Message │ │ │ │ │ │ │ ├── components │ │ │ │ │ │ │ │ ├── GIFMessage │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ │ ├── StatusIndicator │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ │ └── TextMessage │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ └── NewDayMessage │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── NoMessages │ │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ │ ├── ProfileHeader │ │ │ └── index.tsx │ │ │ ├── ProfileSection │ │ │ └── index.tsx │ │ │ ├── Stub │ │ │ └── index.tsx │ │ │ ├── UserHeader │ │ │ └── index.tsx │ │ │ └── UserSection │ │ │ ├── components │ │ │ ├── UserBio │ │ │ │ └── index.tsx │ │ │ ├── UserCommonConnections │ │ │ │ ├── components │ │ │ │ │ └── CommonConnection │ │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── UserCommonInterests │ │ │ │ ├── components │ │ │ │ │ └── CommonInterest │ │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── UserPhotos │ │ │ │ └── index.tsx │ │ │ └── UserTitle │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ ├── shims │ │ ├── emojione.ts │ │ ├── jquery.ts │ │ └── linkref.ts │ └── stores │ │ ├── API │ │ ├── API.ts │ │ ├── getFB.graphql │ │ ├── index.ts │ │ ├── loginFB.graphql │ │ ├── logout.graphql │ │ ├── showWindow.graphql │ │ └── utils │ │ │ ├── index.ts │ │ │ └── isOnline.ts │ │ ├── Caches │ │ ├── Caches.ts │ │ └── index.ts │ │ ├── FB │ │ ├── FB.ts │ │ └── index.ts │ │ ├── Navigator │ │ ├── Navigator.ts │ │ └── index.ts │ │ ├── Notifier │ │ ├── Notifier.ts │ │ └── index.ts │ │ ├── State │ │ ├── Connection.ts │ │ ├── Defaults.ts │ │ ├── Globals.ts │ │ ├── Interest.ts │ │ ├── Job.ts │ │ ├── Match.ts │ │ ├── Message.ts │ │ ├── Person.ts │ │ ├── Photo.ts │ │ ├── ProcessedFile.ts │ │ ├── Profile.ts │ │ ├── School.ts │ │ ├── State.ts │ │ ├── index.ts │ │ └── utils.ts │ │ ├── Storage │ │ ├── Storage.ts │ │ └── index.ts │ │ ├── Time │ │ ├── Time.ts │ │ └── index.ts │ │ ├── TinderAPI │ │ ├── TinderAPI.ts │ │ └── index.ts │ │ ├── configureStores.ts │ │ └── index.ts ├── client.ts ├── index.html ├── main │ ├── ServerAPI │ │ ├── AppManager │ │ │ ├── AppManager.ts │ │ │ ├── createWindowFactory.ts │ │ │ ├── index.ts │ │ │ ├── installExtensions.ts │ │ │ ├── logoutFactory.ts │ │ │ ├── reloadFactory.ts │ │ │ ├── showFactory.ts │ │ │ ├── startFactory.ts │ │ │ └── utils │ │ │ │ ├── buildMenu.ts │ │ │ │ ├── index.ts │ │ │ │ └── updateApp.ts │ │ ├── ServerAPI.ts │ │ ├── getToken.ts │ │ └── index.ts │ └── ravenSetupMain.ts ├── schema.ts ├── server.ts └── shared │ ├── constants │ ├── index.ts │ ├── ipc.ts │ ├── routes.ts │ └── view.ts │ ├── definitions │ ├── AbstractAPI.ts │ ├── AbstractAppManager.ts │ ├── AbstractFB.ts │ ├── AbstractServerAPI.ts │ ├── AbstractStorage.ts │ ├── AbstractTinderAPI.ts │ ├── FBGetTokenType.ts │ ├── IGraphQLElectronMessage.ts │ ├── NotificationMessageType.ts │ ├── State.ts │ └── index.ts │ └── utils │ ├── fromCallback.ts │ ├── index.ts │ ├── nameToPath.ts │ └── resolveRoot.ts ├── tsconfig.json ├── typings.d.ts └── webpack ├── base.js ├── dll.js ├── isDev.js ├── main.js └── renderer.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | databases/* 4 | *.log 5 | docs/_site 6 | dist -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Electron Main", 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 12 | "windows": { 13 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" 14 | }, 15 | "program": "${workspaceRoot}/dist/main.js", 16 | "protocol": "legacy", 17 | "env": { 18 | "NODE_ENV": "development" 19 | } 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Поместите параметры в этот файл, чтобы перезаписать параметры по умолчанию и пользовательские параметры. 2 | { 3 | "javascript.validate.enable": false, 4 | "files.autoSave": "onWindowChange", 5 | "eslint.autoFixOnSave": true, 6 | "flow.useNPMPackagedFlow": true, 7 | "editor.formatOnSave": true, 8 | "prettier.tabWidth": 4, 9 | "prettier.singleQuote": true, 10 | "prettier.semi": false, 11 | "prettier.useTabs": true 12 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "tasks": [ 4 | { 5 | "taskName": "ESLint", 6 | "command": "", 7 | "windows": { 8 | "command": ".\\node_modules\\.bin\\eslint" 9 | }, 10 | "linux": { 11 | "command": "./node_modules/.bin/eslint" 12 | }, 13 | "osx": { 14 | "command": "./node_modules/.bin/eslint" 15 | }, 16 | "isShellCommand": true, 17 | "args": ["src/**/*.{js,jsx}"], 18 | "showOutput": "silent", 19 | "problemMatcher": "$eslint-stylish" 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Konstantin Nesterov 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 |

2 |

 

3 | 4 | ## Chatinder 5 | [![Build status](https://ci.appveyor.com/api/projects/status/v9c0o74hl6tdl08f/branch/master?svg=true)](https://ci.appveyor.com/project/wasd171/chatinder/branch/master) 6 | [![Build status](https://circleci.com/gh/wasd171/chatinder/tree/master.svg?style=shield)](https://circleci.com/gh/wasd171/chatinder/tree/master) 7 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/132991eb75fc4d0b9d8c68ad97dc24a7)](https://www.codacy.com/app/wasd171/chatinder?utm_source=github.com&utm_medium=referral&utm_content=wasd171/chatinder&utm_campaign=Badge_Grade) 8 | [![Github All Releases](https://img.shields.io/github/downloads/wasd171/chatinder/total.svg)]() 9 | [![GitHub release](https://img.shields.io/github/release/wasd171/chatinder.svg)](https://github.com/wasd171/chatinder/releases/latest) 10 | [![license](https://img.shields.io/github/license/wasd171/chatinder.svg)](https://github.com/wasd171/chatinder/blob/master/LICENSE.md) 11 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 12 | 13 | Chatinder is an open-source desktop application for Mac and Windows that allows you to chat with all your matches from your computer. 14 | 15 | * Secure: no third-party server for you to worry. Just you and Tinder. 16 | * Convenient: typing on a real keyboard is much simpler. Express your thoughts! 17 | * Open-source: app is completely free. You can even build it from sources yourself. 18 | 19 |

20 | 21 | Give it a try *today*! -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | platform: 2 | - x64 3 | environment: 4 | nodejs_version: "stable" 5 | GH_TOKEN: 6 | secure: P3PUvll7sdLjanWtuI2fBxKqZi4dIKbzeokpLape1l9qm2PWB17/znIx4zcBvEgY 7 | cache: 8 | - '%APPDATA%\npm-cache' 9 | - '%LOCALAPPDATA%\electron\Cache' 10 | branches: 11 | only: 12 | - master 13 | install: 14 | - ps: Install-Product node $env:nodejs_version $env:platform 15 | - ps: $env:package_version = (Get-Content -Raw -Path package.json | ConvertFrom-Json).version 16 | - ps: Update-AppveyorBuild -Version "$env:package_version-$env:APPVEYOR_BUILD_NUMBER" 17 | - npm install -g npm@4 18 | - set PATH=%APPDATA%\npm;%PATH% 19 | - npm install 20 | build_script: 21 | - npm run publish 22 | after_build: 23 | - ps: Get-ChildItem .\out\win -File | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } 24 | test: off -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: node 3 | pre: 4 | - mkdir ~/Library/Caches/electron 5 | dependencies: 6 | pre: 7 | - npm run circle:clean 8 | cache_directories: 9 | - ~/.npm 10 | - ~/Library/Caches/electron 11 | compile: 12 | override: 13 | - npm run publish 14 | post: 15 | - npm run circle:artifacts 16 | general: 17 | branches: 18 | only: 19 | - master -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem 'github-pages', group: :jekyll_plugins 3 | gem 'jekyll-swiss' 4 | -------------------------------------------------------------------------------- /docs/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (4.2.7) 5 | i18n (~> 0.7) 6 | json (~> 1.7, >= 1.7.7) 7 | minitest (~> 5.1) 8 | thread_safe (~> 0.3, >= 0.3.4) 9 | tzinfo (~> 1.1) 10 | addressable (2.5.1) 11 | public_suffix (~> 2.0, >= 2.0.2) 12 | coffee-script (2.4.1) 13 | coffee-script-source 14 | execjs 15 | coffee-script-source (1.12.2) 16 | colorator (1.1.0) 17 | ethon (0.10.1) 18 | ffi (>= 1.3.0) 19 | execjs (2.7.0) 20 | faraday (0.12.1) 21 | multipart-post (>= 1.2, < 3) 22 | ffi (1.9.18) 23 | forwardable-extended (2.6.0) 24 | gemoji (3.0.0) 25 | github-pages (136) 26 | activesupport (= 4.2.7) 27 | github-pages-health-check (= 1.3.3) 28 | jekyll (= 3.4.3) 29 | jekyll-avatar (= 0.4.2) 30 | jekyll-coffeescript (= 1.0.1) 31 | jekyll-default-layout (= 0.1.4) 32 | jekyll-feed (= 0.9.2) 33 | jekyll-gist (= 1.4.0) 34 | jekyll-github-metadata (= 2.3.1) 35 | jekyll-mentions (= 1.2.0) 36 | jekyll-optional-front-matter (= 0.1.2) 37 | jekyll-paginate (= 1.1.0) 38 | jekyll-readme-index (= 0.1.0) 39 | jekyll-redirect-from (= 0.12.1) 40 | jekyll-relative-links (= 0.4.0) 41 | jekyll-sass-converter (= 1.5.0) 42 | jekyll-seo-tag (= 2.2.2) 43 | jekyll-sitemap (= 1.0.0) 44 | jekyll-swiss (= 0.4.0) 45 | jekyll-theme-architect (= 0.0.4) 46 | jekyll-theme-cayman (= 0.0.4) 47 | jekyll-theme-dinky (= 0.0.4) 48 | jekyll-theme-hacker (= 0.0.4) 49 | jekyll-theme-leap-day (= 0.0.4) 50 | jekyll-theme-merlot (= 0.0.4) 51 | jekyll-theme-midnight (= 0.0.4) 52 | jekyll-theme-minimal (= 0.0.4) 53 | jekyll-theme-modernist (= 0.0.4) 54 | jekyll-theme-primer (= 0.1.8) 55 | jekyll-theme-slate (= 0.0.4) 56 | jekyll-theme-tactile (= 0.0.4) 57 | jekyll-theme-time-machine (= 0.0.4) 58 | jekyll-titles-from-headings (= 0.1.5) 59 | jemoji (= 0.8.0) 60 | kramdown (= 1.13.2) 61 | liquid (= 3.0.6) 62 | listen (= 3.0.6) 63 | mercenary (~> 0.3) 64 | minima (= 2.1.1) 65 | rouge (= 1.11.1) 66 | terminal-table (~> 1.4) 67 | github-pages-health-check (1.3.3) 68 | addressable (~> 2.3) 69 | net-dns (~> 0.8) 70 | octokit (~> 4.0) 71 | public_suffix (~> 2.0) 72 | typhoeus (~> 0.7) 73 | html-pipeline (2.5.0) 74 | activesupport (>= 2) 75 | nokogiri (>= 1.4) 76 | i18n (0.8.1) 77 | jekyll (3.4.3) 78 | addressable (~> 2.4) 79 | colorator (~> 1.0) 80 | jekyll-sass-converter (~> 1.0) 81 | jekyll-watch (~> 1.1) 82 | kramdown (~> 1.3) 83 | liquid (~> 3.0) 84 | mercenary (~> 0.3.3) 85 | pathutil (~> 0.9) 86 | rouge (~> 1.7) 87 | safe_yaml (~> 1.0) 88 | jekyll-avatar (0.4.2) 89 | jekyll (~> 3.0) 90 | jekyll-coffeescript (1.0.1) 91 | coffee-script (~> 2.2) 92 | jekyll-default-layout (0.1.4) 93 | jekyll (~> 3.0) 94 | jekyll-feed (0.9.2) 95 | jekyll (~> 3.3) 96 | jekyll-gist (1.4.0) 97 | octokit (~> 4.2) 98 | jekyll-github-metadata (2.3.1) 99 | jekyll (~> 3.1) 100 | octokit (~> 4.0, != 4.4.0) 101 | jekyll-mentions (1.2.0) 102 | activesupport (~> 4.0) 103 | html-pipeline (~> 2.3) 104 | jekyll (~> 3.0) 105 | jekyll-optional-front-matter (0.1.2) 106 | jekyll (~> 3.0) 107 | jekyll-paginate (1.1.0) 108 | jekyll-readme-index (0.1.0) 109 | jekyll (~> 3.0) 110 | jekyll-redirect-from (0.12.1) 111 | jekyll (~> 3.3) 112 | jekyll-relative-links (0.4.0) 113 | jekyll (~> 3.3) 114 | jekyll-sass-converter (1.5.0) 115 | sass (~> 3.4) 116 | jekyll-seo-tag (2.2.2) 117 | jekyll (~> 3.3) 118 | jekyll-sitemap (1.0.0) 119 | jekyll (~> 3.3) 120 | jekyll-swiss (0.4.0) 121 | jekyll-theme-architect (0.0.4) 122 | jekyll (~> 3.3) 123 | jekyll-theme-cayman (0.0.4) 124 | jekyll (~> 3.3) 125 | jekyll-theme-dinky (0.0.4) 126 | jekyll (~> 3.3) 127 | jekyll-theme-hacker (0.0.4) 128 | jekyll (~> 3.3) 129 | jekyll-theme-leap-day (0.0.4) 130 | jekyll (~> 3.3) 131 | jekyll-theme-merlot (0.0.4) 132 | jekyll (~> 3.3) 133 | jekyll-theme-midnight (0.0.4) 134 | jekyll (~> 3.3) 135 | jekyll-theme-minimal (0.0.4) 136 | jekyll (~> 3.3) 137 | jekyll-theme-modernist (0.0.4) 138 | jekyll (~> 3.3) 139 | jekyll-theme-primer (0.1.8) 140 | jekyll (~> 3.3) 141 | jekyll-theme-slate (0.0.4) 142 | jekyll (~> 3.3) 143 | jekyll-theme-tactile (0.0.4) 144 | jekyll (~> 3.3) 145 | jekyll-theme-time-machine (0.0.4) 146 | jekyll (~> 3.3) 147 | jekyll-titles-from-headings (0.1.5) 148 | jekyll (~> 3.3) 149 | jekyll-watch (1.5.0) 150 | listen (~> 3.0, < 3.1) 151 | jemoji (0.8.0) 152 | activesupport (~> 4.0) 153 | gemoji (~> 3.0) 154 | html-pipeline (~> 2.2) 155 | jekyll (>= 3.0) 156 | json (1.8.6) 157 | kramdown (1.13.2) 158 | liquid (3.0.6) 159 | listen (3.0.6) 160 | rb-fsevent (>= 0.9.3) 161 | rb-inotify (>= 0.9.7) 162 | mercenary (0.3.6) 163 | mini_portile2 (2.1.0) 164 | minima (2.1.1) 165 | jekyll (~> 3.3) 166 | minitest (5.10.1) 167 | multipart-post (2.0.0) 168 | net-dns (0.8.0) 169 | nokogiri (1.7.1) 170 | mini_portile2 (~> 2.1.0) 171 | octokit (4.7.0) 172 | sawyer (~> 0.8.0, >= 0.5.3) 173 | pathutil (0.14.0) 174 | forwardable-extended (~> 2.6) 175 | public_suffix (2.0.5) 176 | rb-fsevent (0.9.8) 177 | rb-inotify (0.9.8) 178 | ffi (>= 0.5.0) 179 | rouge (1.11.1) 180 | safe_yaml (1.0.4) 181 | sass (3.4.23) 182 | sawyer (0.8.1) 183 | addressable (>= 2.3.5, < 2.6) 184 | faraday (~> 0.8, < 1.0) 185 | terminal-table (1.7.3) 186 | unicode-display_width (~> 1.1.1) 187 | thread_safe (0.3.6) 188 | typhoeus (0.8.0) 189 | ethon (>= 0.8.0) 190 | tzinfo (1.2.3) 191 | thread_safe (~> 0.1) 192 | unicode-display_width (1.1.3) 193 | 194 | PLATFORMS 195 | ruby 196 | 197 | DEPENDENCIES 198 | github-pages 199 | jekyll-swiss 200 | 201 | BUNDLED WITH 202 | 1.14.6 203 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-swiss 2 | 3 | # Site settings 4 | # These are used to personalize your new site. If you look in the HTML files, 5 | # you will see them accessed via {{ site.title }}, {{ site.github_repo }}, and so on. 6 | # You can create any custom variable you would like, and they will be accessible 7 | # in the templates via {{ site.myvariable }}. 8 | title: Chatinder 9 | description: Unofficial desktop messaging client for Tinder. 10 | baseurl: "/chatinder" # the subpath of your site, e.g. /blog 11 | url: "https://wasd171.github.io" # the base hostname & protocol for your site, e.g. http://example.com 12 | github_repo: chatinder # the GitHub repo name for your project 13 | github_username: wasd171 14 | 15 | # Optional social link, you can choose from the following options: 16 | # twitter (default), instagram, medium, or dribbble 17 | social_link: twitter 18 | social_username: wasd171 19 | 20 | # Set theme color here 21 | # Choose from: black (default), blue, gray, magenta, orange, red, white, and yellow. 22 | theme_color: red 23 | 24 | # Build settings 25 | markdown: kramdown -------------------------------------------------------------------------------- /docs/_includes/apple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/_includes/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% if page.title %}{{ page.title | escape }}{% else %}{{ site.title | escape }}{% endif %} 7 | 8 | 9 | {% assign user_url = site.url | append: site.baseurl %} 10 | {% assign full_base_url = user_url | default: site.github.url %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/_includes/windows.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/_layouts/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include head.html %} 5 | 6 | 7 | 8 |
9 | 10 |
11 | 12 |
13 | 23 |
24 | 25 |
26 |

{{ site.title }}

27 | 28 |
29 |
30 |
31 |
32 | {% include apple.html %} 33 |
34 |
35 | {% include windows.html %} 36 |
37 |
38 | 39 |

{{ site.description }}

40 |
41 | 42 |
43 |
44 | {% include github.html %} 45 |
46 |
47 | {% include {{ site.social_link | default: "twitter" }}.html %} 48 |
49 |
50 |
51 | 52 |
53 |
54 | 55 |
56 |
57 |
58 | 63 |
64 |
65 | 66 |
67 |

68 | Chatinder is an open-source desktop application for Mac and Windows that allows you to chat with all your matches from your computer. 69 |

70 | 71 |
    72 |
  • Secure: no third-party server for you to worry. Just you and Tinder.
  • 73 |
  • Convenient: typing on a real keyboard is much simpler. Express your thoughts!
  • 74 |
  • Open-source: app is completely free. You can even build it from sources yourself.
  • 75 |
76 |
77 | 78 |
79 | Chatinder screenshot 84 |
85 | 86 |
87 |

Give it a try today!

88 |
89 |
90 | 91 |
92 | 93 | {% include footer.html %} 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /docs/assets/custom.css: -------------------------------------------------------------------------------- 1 | .logo-wrapper { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: center; 5 | } 6 | 7 | .logo { 8 | height: 200px; 9 | width: 200px; 10 | } 11 | 12 | .screenshot { 13 | width: 100%; 14 | image-rendering: -moz-crisp-edges; 15 | /* Firefox */ 16 | image-rendering: -o-crisp-edges; 17 | /* Opera */ 18 | image-rendering: -webkit-optimize-contrast; 19 | /* Webkit (non-standard naming) */ 20 | image-rendering: crisp-edges; 21 | -ms-interpolation-mode: nearest-neighbor; 22 | /* IE (non-standard property) */ 23 | } -------------------------------------------------------------------------------- /docs/assets/icons/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/docs/assets/icons/icon-small.png -------------------------------------------------------------------------------- /docs/assets/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/docs/assets/icons/icon.icns -------------------------------------------------------------------------------- /docs/assets/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/docs/assets/icons/icon.ico -------------------------------------------------------------------------------- /docs/assets/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/docs/assets/icons/icon.png -------------------------------------------------------------------------------- /docs/assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/docs/assets/screenshot.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/docs/index.md -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: com.wasd171.chatinder 2 | productName: Chatinder 3 | directories: 4 | buildResources: dist 5 | output: out 6 | files: 7 | - dist/**/* 8 | - icons/icon.png 9 | mac: 10 | category: public.app-category.social-networking 11 | icon: ./icons/icon.icns 12 | win: 13 | target: squirrel 14 | icon: ./icons/icon.ico 15 | squirrelWindows: 16 | iconUrl: https://wasd171.github.io/chatinder/assets/icons/icon.ico 17 | remoteReleases: https://chatinder.herokuapp.com/update/win32/1.5.1 -------------------------------------------------------------------------------- /icons/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/icons/icon-small.png -------------------------------------------------------------------------------- /icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/icons/icon.icns -------------------------------------------------------------------------------- /icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/icons/icon.ico -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasd171/chatinder/5557f3a623653f2c3139eafa47946156593811fb/icons/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatinder", 3 | "productName": "Chatinder", 4 | "version": "1.6.0", 5 | "description": "Unofficial desktop messaging client for Tinder.", 6 | "main": "dist/main.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+ssh://git@github.com/wasd171/chatinder.git" 10 | }, 11 | "scripts": { 12 | "build:dll": "webpack --config=webpack/dll.js", 13 | "build:main": "webpack --config=webpack/main.js", 14 | "build:renderer": "webpack --config=webpack/renderer.js", 15 | "build:main:dev": "cross-env NODE_ENV=development npm run build:main", 16 | "build:renderer:dev": "cross-env NODE_ENV=development npm run build:renderer", 17 | "build:prod": "rimraf dist && cross-env NODE_ENV=production npm run build:dll && npm run build:main && npm run build:renderer", 18 | "build:dev": "concurrently \"npm run build:main:dev\" \"npm run build:renderer:dev\"", 19 | "publish": "npm run build:prod && electron-builder", 20 | "precommit": "lint-staged", 21 | "circle:clean": "rm -rf node_modules", 22 | "circle:artifacts": "cp ./out/*.{zip,dmg} $CIRCLE_ARTIFACTS", 23 | "postmerge": "npm install", 24 | "postinstall": "electron-builder install-app-deps", 25 | "test": "echo 'Someday there would be tests, I promise'" 26 | }, 27 | "lint-staged": { 28 | "src/**/*.{ts,tsx}": [ 29 | "prettier --tab-width 4 --use-tabs --no-semi --single-quote", 30 | "git add" 31 | ] 32 | }, 33 | "bugs": "https://github.com/wasd171/chatinder/issues", 34 | "homepage": "https://github.com/wasd171/chatinder", 35 | "keywords": [], 36 | "author": "wasd171", 37 | "license": "MIT", 38 | "dependencies": { 39 | "about-window": "^1.6.1", 40 | "app-root-path": "^2.0.1" 41 | }, 42 | "devDependencies": { 43 | "@types/emojione": "^2.2.1", 44 | "@types/he": "^0.5.29", 45 | "@types/history": "^4.6.0", 46 | "@types/jquery": "^3.2.9", 47 | "@types/lodash.trim": "^4.5.2", 48 | "@types/material-ui": "^0.17.18", 49 | "@types/node": "^8.0.16", 50 | "@types/node-fetch": "^1.6.7", 51 | "@types/raven": "^1.2.4", 52 | "@types/react": "^15.0.39", 53 | "@types/react-dom": "^15.5.1", 54 | "@types/react-router-dom": "^4.0.7", 55 | "@types/react-tap-event-plugin": "0.0.30", 56 | "@types/react-virtualized": "^9.7.3", 57 | "@types/simplebar": "^2.4.0", 58 | "@types/uuid": "^3.4.0", 59 | "@types/webpack-env": "^1.13.0", 60 | "babili-webpack-plugin": "^0.1.2", 61 | "concurrently": "^3.5.0", 62 | "copy-webpack-plugin": "^4.0.1", 63 | "cross-env": "^5.0.1", 64 | "css-loader": "^0.28.4", 65 | "date-fns": "^1.28.4", 66 | "devtron": "^1.4.0", 67 | "electron": "^1.6.11", 68 | "electron-builder": "^19.16.2", 69 | "electron-builder-squirrel-windows": "^19.16.0", 70 | "electron-context-menu": "^0.9.1", 71 | "electron-debug": "^1.3.0", 72 | "electron-devtools-installer": "^2.2.0", 73 | "electron-is-dev": "^0.3.0", 74 | "electron-squirrel-startup": "^1.0.0", 75 | "emojione": "2.2.7", 76 | "emojionearea": "^3.1.8", 77 | "extract-text-webpack-plugin": "^3.0.0", 78 | "file-loader": "^0.11.2", 79 | "font-awesome": "^4.7.0", 80 | "fork-ts-checker-webpack-plugin": "^0.2.7", 81 | "happypack": "^4.0.0-beta.1", 82 | "he": "^1.1.1", 83 | "history": "^4.6.3", 84 | "husky": "^0.14.3", 85 | "is-reachable": "2.3.2", 86 | "jquery": "^3.2.1", 87 | "lint-staged": "^4.0.2", 88 | "lodash.trim": "^4.5.1", 89 | "material-ui": "^0.18.7", 90 | "mobx": "^3.2.1", 91 | "mobx-react": "^4.1.8", 92 | "mobx-state-tree": "^0.9.3", 93 | "mobx-utils": "^3.0.0", 94 | "node-fetch": "^1.6.3", 95 | "prettier": "^1.5.3", 96 | "raven": "^2.1.0", 97 | "raven-js": "^3.17.0", 98 | "react": "^15.5.4", 99 | "react-addons-perf": "^15.4.2", 100 | "react-dom": "^15.5.4", 101 | "react-image-gallery": "^0.8.3", 102 | "react-router": "^4.1.2", 103 | "react-router-dom": "^4.1.2", 104 | "react-tap-event-plugin": "^2.0.1", 105 | "react-virtualized": "^9.9.0", 106 | "react-waypoint": "^7.0.4", 107 | "resolve-url-loader": "^2.1.0", 108 | "rimraf": "^2.6.1", 109 | "roboto-fontface": "^0.8.0", 110 | "simplebar": "^2.4.1", 111 | "skip-loader": "^1.0.0", 112 | "source-map-loader": "^0.2.1", 113 | "styled-components": "^2.1.1", 114 | "tinder-modern": "^2.0.1", 115 | "ts-loader": "^2.3.1", 116 | "tslib": "^1.7.1", 117 | "typescript": "^2.4.2", 118 | "uuid": "^3.0.1", 119 | "webpack": "^3.4.0", 120 | "webpack-merge": "^4.1.0", 121 | "webpack-node-externals": "^1.6.0", 122 | "webpack-stats-plugin": "^0.1.5" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/app/app.global.css: -------------------------------------------------------------------------------- 1 | body { 2 | min-height: 100vh; 3 | max-height: 100vh; 4 | min-width: 100vw; 5 | max-width: 100vw; 6 | margin: 0 !important; 7 | padding: 0 !important; 8 | font-family: Roboto, sans-serif; 9 | overflow: hidden; 10 | } 11 | 12 | * { 13 | outline: none; 14 | box-sizing: border-box; 15 | user-select: none; 16 | -webkit-user-select: none; 17 | -webkit-user-drag: none; 18 | } 19 | 20 | .emojione { 21 | zoom: calc(20/64); 22 | margin: 0 calc(64*.1em/20) 0 calc(64*.2em/20); 23 | -webkit-user-select: text; 24 | user-select: text; 25 | vertical-align: -.5em; 26 | cursor: text; 27 | } 28 | 29 | .emojionearea { 30 | border: none; 31 | box-shadow: none; 32 | margin-top: 12px; 33 | margin-bottom: 4px; 34 | max-width: 100%; 35 | width: 100%; 36 | background-color: transparent; 37 | } 38 | 39 | .emojionearea.focused { 40 | border: none; 41 | outline: none; 42 | box-shadow: none; 43 | } 44 | 45 | .emojionearea .emojionearea-editor { 46 | min-height: 24px; 47 | max-height: calc(6*24px); 48 | line-height: 24px; 49 | padding-top: 0; 50 | padding-bottom: 0; 51 | padding-left: 0; 52 | width: 100%; 53 | max-width: 100%; 54 | word-break: break-all; 55 | overflow: hidden; 56 | display: inline-block; 57 | resize: none; 58 | } 59 | 60 | .emojionearea-editor::-webkit-scrollbar, .emojionearea-scroll-area::-webkit-scrollbar { 61 | width: 11px; 62 | background-color: transparent; 63 | overflow: visible; 64 | } 65 | 66 | .emojionearea-editor::-webkit-scrollbar-track, .emojionearea-scroll-area::-webkit-scrollbar-track { 67 | background-color: transparent; 68 | } 69 | 70 | .emojionearea-editor::-webkit-scrollbar-thumb, .emojionearea-scroll-area::-webkit-scrollbar-thumb { 71 | border: 2px solid rgba(0, 0, 0, 0); 72 | border-radius: 7px; 73 | min-height: 14px; 74 | transition: opacity 0.2s linear; 75 | background: rgba(108, 110, 113, 0.7); 76 | background-clip: padding-box; 77 | } 78 | 79 | .emojionearea .emojionearea-button { 80 | top: -5px; 81 | } -------------------------------------------------------------------------------- /src/app/components/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { observer } from 'mobx-react' 3 | import styled from 'styled-components' 4 | 5 | export interface IAvatarImageProps { 6 | sizeProp: string 7 | } 8 | 9 | const AvatarImage = styled.img` 10 | border-radius: 50%; 11 | height: ${(props: IAvatarImageProps) => props.sizeProp}; 12 | min-height: ${(props: IAvatarImageProps) => props.sizeProp}; 13 | max-height: ${(props: IAvatarImageProps) => props.sizeProp}; 14 | width: ${(props: IAvatarImageProps) => props.sizeProp}; 15 | min-width: ${(props: IAvatarImageProps) => props.sizeProp}; 16 | max-width: ${(props: IAvatarImageProps) => props.sizeProp}; 17 | ` 18 | 19 | export interface IMatchReduced { 20 | person: { 21 | largePhoto: string 22 | smallPhoto: string 23 | } 24 | } 25 | 26 | export interface IAvatarProps { 27 | match?: IMatchReduced 28 | size: number 29 | src?: string 30 | } 31 | 32 | function Avatar({ match, size, src }: IAvatarProps) { 33 | let photo: string 34 | if (src !== undefined) { 35 | photo = src 36 | } else { 37 | photo = 38 | size > 84 39 | ? (match as IMatchReduced).person.largePhoto 40 | : (match as IMatchReduced).person.smallPhoto 41 | } 42 | 43 | return 44 | } 45 | 46 | export default observer(Avatar) 47 | -------------------------------------------------------------------------------- /src/app/components/GenericHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export interface IGenericHeaderProps { 4 | center?: boolean 5 | } 6 | 7 | const GenericHeader = styled.div` 8 | display: flex; 9 | flex-direction: row; 10 | justify-content: ${(props: IGenericHeaderProps) => 11 | props.center ? 'center' : 'space-between'}; 12 | align-items: center; 13 | height: 100%; 14 | width: 100%; 15 | padding-left: 5px; 16 | padding-right: 5px; 17 | ` 18 | 19 | export default GenericHeader 20 | -------------------------------------------------------------------------------- /src/app/components/GenericIconWrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export interface IGenericIconWrapperProps { 4 | activated?: boolean 5 | } 6 | 7 | const GenericIconWrapper = styled.span` 8 | width: 25px; 9 | ${(props: IGenericIconWrapperProps) => 10 | props.activated ? 'cursor: pointer;' : ''}; 11 | ` 12 | 13 | export default GenericIconWrapper 14 | -------------------------------------------------------------------------------- /src/app/components/GenericNameSpan/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export interface IGenericNameSpanProps { 4 | theme: { 5 | palette: { 6 | textColor: string 7 | } 8 | } 9 | clickable?: boolean 10 | } 11 | 12 | const GenericNameSpan = styled.span` 13 | color: ${(props: IGenericNameSpanProps) => props.theme.palette.textColor}; 14 | cursor: ${(props: IGenericNameSpanProps) => 15 | props.clickable ? 'pointer' : 'default'}; 16 | ` 17 | 18 | export default GenericNameSpan 19 | -------------------------------------------------------------------------------- /src/app/components/LoadingStub/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { CircularProgress } from 'material-ui' 4 | 5 | const Container = styled.div` 6 | height: 100%; 7 | width: 100%; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | ` 12 | 13 | export interface ILoadingStubProps { 14 | size: number 15 | } 16 | 17 | class LoadingStub extends React.Component { 18 | render() { 19 | return ( 20 | 21 | 22 | 23 | ) 24 | } 25 | } 26 | 27 | export default LoadingStub 28 | -------------------------------------------------------------------------------- /src/app/components/SimpleBarRV/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | AutoSizer, 4 | ScrollSync, 5 | OnScrollParams, 6 | Dimensions, 7 | ScrollSyncChildProps 8 | } from 'react-virtualized' 9 | import linkref, { ILinkedRefs } from '~/app/shims/linkref' 10 | import SimpleBarStandalone from '~/app/components/SimpleBarStandalone' 11 | import styled from 'styled-components' 12 | 13 | const Container = styled.div` 14 | height: 100%; 15 | width: 100%; 16 | ` 17 | 18 | type MergedType = Dimensions & ScrollSyncChildProps 19 | export interface IMergedProps extends MergedType { 20 | handleListScroll: (event: OnScrollParams) => any 21 | } 22 | 23 | interface ISimpleBarRVProps { 24 | forceUpdater: {} 25 | onScroll?: (props: OnScrollParams) => any 26 | children: (props?: IMergedProps) => JSX.Element 27 | simple: boolean 28 | } 29 | 30 | class SimpleBarRV extends React.Component { 31 | _linkedRefs: ILinkedRefs 32 | scrollbar: SimpleBarStandalone 33 | scrollHandler: (params: OnScrollParams) => any 34 | 35 | customScrollHandler = (event: OnScrollParams) => { 36 | if (this.props.onScroll) { 37 | this.props.onScroll(event) 38 | } 39 | this.scrollHandler(event) 40 | } 41 | 42 | handleListScroll = (event: OnScrollParams) => { 43 | this.scrollbar.showScrollbar() 44 | this.customScrollHandler(event) 45 | } 46 | 47 | handleScrollbarScroll = (event: OnScrollParams) => { 48 | this.customScrollHandler(event) 49 | } 50 | 51 | handleMouseEnter = () => { 52 | this.scrollbar.showScrollbar() 53 | } 54 | 55 | renderChildren = (props: MergedType): JSX.Element => { 56 | const { onScroll, scrollTop, scrollHeight, height } = props 57 | this.scrollHandler = onScroll 58 | return ( 59 |
60 | {this.props.children({ 61 | ...props, 62 | handleListScroll: this.handleListScroll 63 | })} 64 | 71 |
72 | ) 73 | } 74 | 75 | renderScrollSync = (props: Dimensions) => 76 | 77 | {(scrollProps: ScrollSyncChildProps) => 78 | this.renderChildren({ ...props, ...scrollProps })} 79 | 80 | 81 | render() { 82 | let content: JSX.Element 83 | if (this.props.simple) { 84 | content = this.props.children() 85 | } else { 86 | content = ( 87 | 88 | {this.renderScrollSync} 89 | 90 | ) 91 | } 92 | 93 | return ( 94 | 95 | {content} 96 | 97 | ) 98 | } 99 | } 100 | 101 | export default SimpleBarRV 102 | -------------------------------------------------------------------------------- /src/app/components/SimpleBarStandalone/index.tsx: -------------------------------------------------------------------------------- 1 | // this is a partial rewrite of SimpleBar 2 | import * as React from 'react' 3 | import { observer } from 'mobx-react' 4 | import { observable, action } from 'mobx' 5 | import styled from 'styled-components' 6 | import linkref, { ILinkedRefs } from '~/app/shims/linkref' 7 | 8 | const Track = styled.div` 9 | height: ${(props: { height: number }) => props.height}px; 10 | ` 11 | 12 | export interface IScrollBarProps { 13 | height: number 14 | offset: number 15 | } 16 | 17 | const ScrollBar = styled.div` 18 | height: ${(props: IScrollBarProps) => props.height}px; 19 | top: ${(props: IScrollBarProps) => props.offset}px; 20 | ` 21 | 22 | export interface ISimpleBarStandaloneNumberProps { 23 | clientHeight: number 24 | scrollHeight: number 25 | scrollTop: number 26 | } 27 | 28 | export interface ISimpleBarStandaloneProps 29 | extends ISimpleBarStandaloneNumberProps { 30 | componentRef: (component: React.Component) => any 31 | onScroll: (props: ISimpleBarStandaloneNumberProps) => any 32 | } 33 | 34 | @observer 35 | class SimpleBarStandalone extends React.Component { 36 | _linkedRefs: ILinkedRefs 37 | flashTimeout: number 38 | dragOffset: number 39 | track: HTMLElement 40 | scrollbar: HTMLElement 41 | @observable visible = false 42 | 43 | get fitsInScreen() { 44 | return this.props.clientHeight >= this.props.scrollHeight 45 | } 46 | 47 | @action 48 | showScrollbar = () => { 49 | this.visible = true 50 | this.clearFlashTimeout() 51 | 52 | this.flashTimeout = window.setTimeout(this.hideScrollbar, 1000) 53 | } 54 | 55 | @action 56 | hideScrollbar = () => { 57 | this.visible = false 58 | this.clearFlashTimeout() 59 | } 60 | 61 | constructor(props: ISimpleBarStandaloneProps) { 62 | super(props) 63 | this.props.componentRef(this) 64 | } 65 | 66 | componentWillUnmount() { 67 | this.clearFlashTimeout() 68 | } 69 | 70 | clearFlashTimeout = () => { 71 | if (typeof this.flashTimeout === 'number') { 72 | clearTimeout(this.flashTimeout) 73 | } 74 | } 75 | 76 | startDrag = (event: React.MouseEvent) => { 77 | event.preventDefault() 78 | 79 | this.dragOffset = 80 | event.pageY - this.scrollbar.getBoundingClientRect().top 81 | document.addEventListener('mousemove', this.drag) 82 | document.addEventListener('mouseup', this.endDrag) 83 | } 84 | 85 | drag = (event: MouseEvent) => { 86 | event.preventDefault() 87 | 88 | const dragPos = 89 | event.pageY - 90 | this.track.getBoundingClientRect().top - 91 | this.dragOffset 92 | const dragPerc = Math.max( 93 | Math.min(dragPos / this.props.clientHeight, 1), 94 | 0 95 | ) 96 | const scrollPos = 97 | dragPerc * (this.props.scrollHeight - this.props.clientHeight) 98 | 99 | this.props.onScroll({ 100 | clientHeight: this.props.clientHeight, 101 | scrollHeight: this.props.scrollHeight, 102 | scrollTop: scrollPos 103 | }) 104 | } 105 | 106 | endDrag = () => { 107 | document.removeEventListener('mousemove', this.drag) 108 | document.removeEventListener('mouseup', this.endDrag) 109 | } 110 | 111 | render(): JSX.Element { 112 | const { scrollTop, clientHeight, scrollHeight } = this.props 113 | 114 | const scrollbarRatio = clientHeight / scrollHeight 115 | const scrollPercent = scrollTop / (scrollHeight - clientHeight) 116 | // Calculate new height/position of drag handle. 117 | // Offset of 2px allows for a small top/bottom or left/right margin around handle. 118 | const height = Math.max( 119 | Math.floor(scrollbarRatio * (clientHeight - 2)) - 2, 120 | 10 121 | ) 122 | const offset = (clientHeight - 4 - height) * scrollPercent + 2 123 | const visibilityClassName = 124 | this.visible && !this.fitsInScreen ? 'visible' : null 125 | 126 | return ( 127 | 132 | 139 | 140 | ) 141 | } 142 | } 143 | 144 | export default SimpleBarStandalone 145 | -------------------------------------------------------------------------------- /src/app/components/SimpleBarWrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import linkref, { ILinkedRefs } from '~/app/shims/linkref' 3 | const Simplebar = require('simplebar') 4 | import styled from 'styled-components' 5 | 6 | const Wrapper = styled.div` 7 | height: 100%; 8 | max-width: 100%; 9 | ` 10 | 11 | interface ProperSimplebar extends SimpleBar { 12 | unMount(): void 13 | } 14 | 15 | class SimpleBarWrapper extends React.Component { 16 | _linkedRefs: ILinkedRefs 17 | simplebar: ProperSimplebar 18 | root: HTMLDivElement 19 | 20 | componentDidMount() { 21 | this.simplebar = new Simplebar(this.root, { 22 | wrapContent: false, 23 | forceEnabled: true 24 | }) 25 | } 26 | 27 | componentWillUnmount() { 28 | this.simplebar.unMount() 29 | } 30 | 31 | render(): JSX.Element { 32 | return ( 33 | 34 |
35 |
36 | {this.props.children} 37 |
38 |
39 |
40 | ) 41 | } 42 | } 43 | 44 | export default SimpleBarWrapper 45 | -------------------------------------------------------------------------------- /src/app/configureClient.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client' 2 | import { createElectronInterface } from './graphql' 3 | 4 | export default function configureClient() { 5 | const electronInterface = createElectronInterface() 6 | 7 | const client = new ApolloClient({ 8 | networkInterface: electronInterface 9 | }) 10 | 11 | return client 12 | } 13 | -------------------------------------------------------------------------------- /src/app/configureTheme.ts: -------------------------------------------------------------------------------- 1 | import getMuiTheme from 'material-ui/styles/getMuiTheme' 2 | 3 | // TODO fix the bug of scrolling and shady background when deleting a line in Textarea 4 | export default function configureTheme() { 5 | return getMuiTheme({ 6 | palette: { 7 | primary1Color: 'rgb(245, 89, 89)', 8 | accent1Color: 'rgb(59, 164, 253)' 9 | } 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/graphql/ElectronInterface.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | import { GRAPHQL } from '~/shared/constants' 3 | import * as uuid from 'uuid' 4 | import { print } from 'graphql/language/printer' 5 | import { NetworkInterface, Request } from 'apollo-client' 6 | import { ExecutionResult } from 'graphql' 7 | import { PrintedRequest } from 'apollo-client/transport/networkInterface' 8 | import { IGraphQLElectronMessage } from '~/shared/definitions' 9 | 10 | export interface IArgumments { 11 | id: string 12 | payload: ExecutionResult 13 | } 14 | 15 | export type ResolveType = (res: ExecutionResult) => any 16 | 17 | export class ElectronInterface implements NetworkInterface { 18 | ipc: Electron.IpcRenderer 19 | listeners = new Map() 20 | 21 | constructor(ipc = ipcRenderer) { 22 | this.ipc = ipc 23 | this.ipc.on(GRAPHQL, this.listener) 24 | } 25 | 26 | listener = (_event: Electron.Event, args: IArgumments) => { 27 | const { id, payload } = args 28 | if (!id) { 29 | throw new Error('Listener ID is not present!') 30 | } 31 | const resolve = this.listeners.get(id) 32 | if (!resolve) { 33 | throw new Error(`Listener with id ${id} does not exist!`) 34 | } 35 | resolve(payload) 36 | this.listeners.delete(id) 37 | } 38 | 39 | printRequest(request: Request): PrintedRequest { 40 | return { 41 | ...request, 42 | query: print(request.query) 43 | } 44 | } 45 | 46 | generateMessage(id: string, request: Request): IGraphQLElectronMessage { 47 | return { 48 | id, 49 | payload: this.printRequest(request) 50 | } 51 | } 52 | 53 | setListener(request: Request, resolve: ResolveType) { 54 | const id = uuid.v1() 55 | this.listeners.set(id, resolve) 56 | const message = this.generateMessage(id, request) 57 | this.ipc.send(GRAPHQL, message) 58 | } 59 | 60 | query = (request: Request) => { 61 | return new Promise(resolve => 62 | this.setListener(request, resolve) 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/graphql/createElectronInterface.ts: -------------------------------------------------------------------------------- 1 | import { ElectronInterface } from './ElectronInterface' 2 | 3 | export function createElectronInterface() { 4 | return new ElectronInterface() 5 | } 6 | -------------------------------------------------------------------------------- /src/app/graphql/index.ts: -------------------------------------------------------------------------------- 1 | export { createElectronInterface } from './createElectronInterface' 2 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import 'roboto-fontface/css/roboto/roboto-fontface.css' 2 | import 'font-awesome/css/font-awesome.min.css' 3 | import 'react-virtualized/styles.css' 4 | import 'simplebar/dist/simplebar.css' 5 | import 'emojionearea/dist/emojionearea.min.css' 6 | import 'react-image-gallery/styles/css/image-gallery.css' 7 | import './app.global.css' 8 | 9 | import * as React from 'react' 10 | import * as ReactDOM from 'react-dom' 11 | 12 | import { Provider } from 'mobx-react' 13 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider' 14 | import { HashRouter as Router, Route } from 'react-router-dom' 15 | 16 | import { useStrict } from 'mobx' 17 | import * as TapPlugin from 'react-tap-event-plugin' 18 | TapPlugin() 19 | 20 | import configureTheme from './configureTheme' 21 | import { configureStores } from './stores' 22 | 23 | if (process.env.NODE_ENV === 'development') { 24 | global.Perf = require('react-addons-perf') 25 | } 26 | 27 | async function configureAndRender() { 28 | const container = document.getElementById('root') 29 | 30 | const theme = configureTheme() 31 | const stores = await configureStores() 32 | useStrict(true) 33 | 34 | function render() { 35 | const App = require('./scenes/App/index').default 36 | ReactDOM.render( 37 | 38 | 39 | 40 | 41 | 42 | 43 | , 44 | container, 45 | () => {} 46 | ) 47 | } 48 | 49 | render() 50 | if (module.hot) { 51 | module.hot.accept(render) 52 | } 53 | } 54 | 55 | configureAndRender() 56 | -------------------------------------------------------------------------------- /src/app/ravenSetupRenderer.ts: -------------------------------------------------------------------------------- 1 | import * as Raven from 'raven-js' 2 | 3 | export function ravenSetupRenderer() { 4 | Raven.config( 5 | 'https://da10ea27ad724a2bbd826e378e1c389b@sentry.io/183877' 6 | ).install() 7 | } 8 | -------------------------------------------------------------------------------- /src/app/scenes/App/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Switch, Route } from 'react-router-dom' 3 | 4 | import { inject } from 'mobx-react' 5 | import { 6 | VIEW_AUTH, 7 | VIEW_MATCHES, 8 | VIEW_LOADING, 9 | routes 10 | } from '~/shared/constants' 11 | import Auth from './scenes/Auth' 12 | import Main from './scenes/Main' 13 | import LoadingScreen from './scenes/LoadingScreen' 14 | import { Navigator } from '~/app/stores/Navigator' 15 | import { History as CustomHistory } from 'history' 16 | 17 | export interface IAppProps { 18 | history: CustomHistory 19 | } 20 | 21 | export interface IInjectedProps extends IAppProps { 22 | navigator: Navigator 23 | } 24 | 25 | @inject('navigator') 26 | class App extends React.Component { 27 | get injected() { 28 | return this.props as IInjectedProps 29 | } 30 | 31 | componentDidMount() { 32 | this.injected.navigator.setHistory(this.props.history) 33 | } 34 | 35 | render() { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | } 45 | 46 | export default App 47 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Auth/components/FacebookLoginButton/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const base = { 5 | borderRadius: '2px', 6 | height: '36px', 7 | transition: 'all 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms' 8 | } 9 | 10 | const OuterWrapper = styled.div` 11 | height: ${base.height}; 12 | box-shadow: rgba(0, 0, 0, 0.117647) 0px 1px 6px, 13 | rgba(0, 0, 0, 0.117647) 0px 1px 4px; 14 | border-radius: ${base.borderRadius}; 15 | min-width: 88px; 16 | margin: 12px; 17 | transition: ${base.transition}; 18 | &:active { 19 | box-shadow: rgba(0, 0, 0, 0.156647) 0px 3px 10px, 20 | rgba(0, 0, 0, 0.227451) 0px 3px 10px; 21 | } 22 | ` 23 | 24 | const StyledButton = styled.button` 25 | border: 10px; 26 | font-family: Roboto, sans-serif; 27 | cursor: pointer; 28 | text-decoration: none; 29 | margin: 0px; 30 | padding: 0px; 31 | position: relative; 32 | height: ${base.height}; 33 | line-height: ${base.height}; 34 | width: 100%; 35 | border-radius: ${base.borderRadius}; 36 | text-align: center; 37 | background-color: #3b5998; 38 | outline: none; 39 | transition: ${base.transition}; 40 | ` 41 | 42 | const InnerWrapper = styled.div` 43 | height: ${base.height}; 44 | border-radius: ${base.borderRadius}; 45 | top: 0px; 46 | display: flex; 47 | flex-direction: row; 48 | align-items: center; 49 | transition: ${base.transition}; 50 | &:hover { 51 | background-color: rgba(255, 255, 255, 0.4); 52 | } 53 | ` 54 | 55 | const IconSpan = styled.span` 56 | color: rgb(255, 255, 255); 57 | font-size: 24px; 58 | margin-left: 12px; 59 | line-height: 1; 60 | ` 61 | 62 | const TextSpan = styled.span` 63 | position: relative; 64 | opacity: 1; 65 | font-size: 14px; 66 | letter-spacing: 0px; 67 | text-transform: uppercase; 68 | font-weight: 500; 69 | margin: 0px; 70 | padding-left: 8px; 71 | padding-right: 16px; 72 | color: rgb(255, 255, 255); 73 | ` 74 | 75 | export interface IFacebookLoginButtonProps { 76 | onClick: React.MouseEventHandler 77 | } 78 | 79 | function FacebookLoginButton({ onClick }: IFacebookLoginButtonProps) { 80 | return ( 81 | 82 | 83 |
84 | 85 | {/**/} 86 | 87 | Login with Facebook 88 | 89 |
90 |
91 |
92 | ) 93 | } 94 | 95 | export default FacebookLoginButton 96 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Auth/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import FacebookLoginButton from './components/FacebookLoginButton' 3 | import { inject } from 'mobx-react' 4 | import styled from 'styled-components' 5 | import { success } from '~/shared/constants' 6 | import { Navigator } from '~/app/stores/Navigator' 7 | import { AbstractAPI } from '~/shared/definitions' 8 | import { RouteComponentProps } from 'react-router-dom' 9 | 10 | const AuthWrapper = styled.div` 11 | min-height: 100vh; 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-around; 15 | ` 16 | 17 | export interface IGQLRes { 18 | login: { 19 | status: string 20 | } 21 | } 22 | 23 | interface IAuthProps extends RouteComponentProps<{}> {} 24 | interface IInjectedProps extends IAuthProps { 25 | navigator: Navigator 26 | api: AbstractAPI 27 | } 28 | 29 | @inject('navigator', 'api') 30 | class Auth extends React.Component { 31 | get injected() { 32 | return this.props as IInjectedProps 33 | } 34 | 35 | handleClick = async () => { 36 | const { navigator, api } = this.injected 37 | navigator.goToLoading('Performing login') 38 | const res = await api.login(false) 39 | 40 | if (res.status === success.status) { 41 | navigator.goToMatches() 42 | } else { 43 | navigator.goToAuth() 44 | } 45 | } 46 | 47 | render() { 48 | return ( 49 | 50 | 51 | 52 | ) 53 | } 54 | } 55 | 56 | export default Auth 57 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/LoadingScreen/animations.css: -------------------------------------------------------------------------------- 1 | .circular { 2 | animation: rotate 2s linear infinite; 3 | height: 100%; 4 | transform-origin: center center; 5 | width: 100%; 6 | } 7 | 8 | .path { 9 | stroke-dasharray: 1, 200; 10 | stroke-dashoffset: 0; 11 | animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite; 12 | stroke-linecap: round; 13 | } 14 | 15 | @keyframes rotate { 16 | 100% { 17 | transform: rotate(360deg); 18 | } 19 | } 20 | 21 | @keyframes dash { 22 | 0% { 23 | stroke-dasharray: 1, 200; 24 | stroke-dashoffset: 0; 25 | } 26 | 50% { 27 | stroke-dasharray: 89, 200; 28 | stroke-dashoffset: -35px; 29 | } 30 | 100% { 31 | stroke-dasharray: 89, 200; 32 | stroke-dashoffset: -124px; 33 | } 34 | } 35 | 36 | @keyframes color { 37 | 100%, 38 | 0% { 39 | stroke: #d62d20; 40 | } 41 | 40% { 42 | stroke: #0057e7; 43 | } 44 | 66% { 45 | stroke: #008744; 46 | } 47 | 80%, 48 | 90% { 49 | stroke: #ffa700; 50 | } 51 | } -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/LoadingScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import CircularProgress from 'material-ui/CircularProgress' 3 | import muiThemeable from 'material-ui/styles/muiThemeable' 4 | import styled from 'styled-components' 5 | import { RouteComponentProps } from 'react-router-dom' 6 | import { MuiTheme } from 'material-ui/styles' 7 | 8 | const OuterWrapper = styled.div` 9 | height: 100vh; 10 | width: 100%; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | justify-content: center; 15 | transition: all 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; 16 | ` 17 | 18 | const TitleWrapper = styled.h2` 19 | color: ${props => props.theme.palette.textColor}; 20 | ` 21 | 22 | const ProgressWrapper = styled.div` 23 | width: 100px; 24 | margin: auto; 25 | ` 26 | 27 | export interface IRouteProps { 28 | title?: string 29 | } 30 | 31 | export interface ILoadingScreenProps extends RouteComponentProps { 32 | muiTheme: MuiTheme 33 | } 34 | 35 | @muiThemeable() 36 | class LoadingScreen extends React.Component { 37 | render() { 38 | const { muiTheme, match } = this.props 39 | 40 | return ( 41 | 42 |
43 | 44 | {match.params.title} 45 | 46 | 47 | 48 | 49 |
50 |
51 | ) 52 | } 53 | } 54 | 55 | export default LoadingScreen 56 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/components/NoMatches/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import styled from 'styled-components' 4 | import { RaisedButton } from 'material-ui' 5 | import { MuiTheme } from 'material-ui/styles' 6 | import { inject } from 'mobx-react' 7 | import { AbstractAPI } from '~/shared/definitions' 8 | 9 | const Wrapper = styled.div` 10 | height: 100%; 11 | width: 100%; 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: center; 15 | align-items: center; 16 | ` 17 | 18 | const Title = styled.h3`color: ${props => props.theme.palette.textColor};` 19 | 20 | export interface IMessageProps { 21 | primary?: boolean 22 | theme: MuiTheme 23 | } 24 | 25 | const Message = styled.p` 26 | color: ${(props: IMessageProps) => 27 | props.primary 28 | ? props.theme.palette!.textColor! 29 | : props.theme.palette!.secondaryTextColor!}; 30 | ` 31 | 32 | export interface IGQLRes { 33 | logout: { 34 | status: string 35 | } 36 | } 37 | 38 | export interface IInjectedProps { 39 | muiTheme: MuiTheme 40 | api: AbstractAPI 41 | } 42 | 43 | @inject('api') 44 | @muiThemeable() 45 | class NoMatches extends React.Component { 46 | get injected() { 47 | return this.props as IInjectedProps 48 | } 49 | 50 | handleClick = () => { 51 | this.injected.api.logout() 52 | } 53 | 54 | render() { 55 | const { muiTheme } = this.injected 56 | return ( 57 | 58 | 59 | Looks like you don not have any matches yet! =( 60 | 61 | 62 | This app is for chatting purposes only. Use an official 63 | Tinder app on your phone to find your soulmates 64 | 65 | 66 | You can also log out and try another Tinder profile 67 | 68 | 73 | 74 | ) 75 | } 76 | } 77 | 78 | export default NoMatches 79 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/components/ProfileHeaderLeft/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Avatar from '~/app/components/Avatar' 3 | import styled from 'styled-components' 4 | import muiThemeable from 'material-ui/styles/muiThemeable' 5 | import { inject, observer } from 'mobx-react' 6 | import { Navigator } from '~/app/stores/Navigator' 7 | import { MuiTheme } from 'material-ui/styles' 8 | import { StateType } from '~/shared/definitions' 9 | 10 | const StyledContainer = styled.div` 11 | display: flex; 12 | flex-direction: row; 13 | align-items: center; 14 | height: 100%; 15 | padding-left: 12px; 16 | ` 17 | 18 | const NameWrapper = styled.span` 19 | margin-left: 12px; 20 | cursor: pointer; 21 | font-weight: 400; 22 | color: ${props => props.theme.palette.textColor}; 23 | ` 24 | 25 | export interface IInjectedProps { 26 | navigator: Navigator 27 | muiTheme: MuiTheme 28 | state: StateType 29 | } 30 | 31 | @inject('navigator', 'state') 32 | @muiThemeable() 33 | @observer 34 | class ProfileHeaderLeft extends React.Component { 35 | get injected() { 36 | return this.props as IInjectedProps 37 | } 38 | 39 | handleClick = () => { 40 | this.injected.navigator.goToProfile() 41 | } 42 | 43 | render() { 44 | const { user } = this.injected.state.defaults! 45 | return ( 46 | 47 | 48 | 52 | 53 | ) 54 | } 55 | } 56 | 57 | export default ProfileHeaderLeft 58 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import NoMatches from './components/NoMatches' 3 | import ProfileHeaderLeft from './components/ProfileHeaderLeft' 4 | import MatchesList from './scenes/MatchesList' 5 | import muiThemeable from 'material-ui/styles/muiThemeable' 6 | import styled from 'styled-components' 7 | import { observable, action } from 'mobx' 8 | import { inject, observer } from 'mobx-react' 9 | import LoadingStub from '~/app/components/LoadingStub' 10 | import { Route, RouteComponentProps } from 'react-router-dom' 11 | import { 12 | VIEW_MATCHES, 13 | VIEW_CHAT, 14 | VIEW_USER, 15 | VIEW_PROFILE, 16 | routes 17 | } from '~/shared/constants' 18 | import Stub from './scenes/Stub' 19 | import ChatHeader from './scenes/ChatHeader' 20 | import MessagesFeed from './scenes/MessagesFeed' 21 | import ChatInput from './scenes/ChatInput' 22 | import UserHeader from './scenes/UserHeader' 23 | import UserSection from './scenes/UserSection' 24 | import ProfileHeader from './scenes/ProfileHeader' 25 | import ProfileSection from './scenes/ProfileSection' 26 | import { MuiTheme } from 'material-ui/styles' 27 | import { AbstractAPI } from '~/shared/definitions' 28 | 29 | const MainContainer = styled.div` 30 | height: 100vh; 31 | width: 100vw; 32 | max-height: 100vh; 33 | min-height: 100vh; 34 | display: grid; 35 | grid-template-columns: 270px auto; 36 | grid-template-rows: 46px 1fr fit-content(100%); 37 | grid-template-areas: "head-left head-right" "aside main" "aside footer"; 38 | ` 39 | 40 | const FullPage = styled.div` 41 | grid-column: 1 / span 2; 42 | grid-row: 1 / span 3; 43 | ` 44 | 45 | const LeftHeaderWrapper = styled.div` 46 | grid-area: head-left; 47 | border-right: 1px solid ${props => props.theme.palette.borderColor}; 48 | border-bottom: 1px solid ${props => props.theme.palette.borderColor}; 49 | ` 50 | 51 | const AsideWrapper = styled.div` 52 | grid-area: aside; 53 | border-right: 1px solid ${props => props.theme.palette.borderColor}; 54 | ` 55 | 56 | const StubWrapper = styled.div` 57 | grid-column: head-right; 58 | grid-row: head-right / footer; 59 | ` 60 | 61 | const RightHeaderWrapper = styled.div` 62 | grid-area: head-right; 63 | border-bottom: 1px solid ${props => props.theme.palette.borderColor}; 64 | ` 65 | 66 | const MainWrapper = styled.div`grid-area: main;` 67 | 68 | const FooterWrapper = styled.div` 69 | grid-area: footer; 70 | align-self: end; 71 | ` 72 | 73 | const RightSection = styled.div` 74 | grid-column: head-right; 75 | grid-row: main / footer; 76 | ` 77 | 78 | export interface IRRParams { 79 | id: string 80 | } 81 | 82 | type RenderType = RouteComponentProps 83 | // export interface IRenderSignature { 84 | // match: match 85 | // } 86 | 87 | export interface IMainProps extends RouteComponentProps {} 88 | 89 | export interface IInjectedProps extends IMainProps { 90 | muiTheme: MuiTheme 91 | api: AbstractAPI 92 | } 93 | 94 | @inject('api') 95 | @muiThemeable() 96 | @observer 97 | class Main extends React.Component { 98 | get injected() { 99 | return this.props as IInjectedProps 100 | } 101 | 102 | @observable shouldShowContent: boolean | null = null 103 | 104 | @action 105 | changeStatus = (status: boolean | null) => { 106 | this.shouldShowContent = status 107 | } 108 | 109 | async componentDidMount() { 110 | console.log('Main didMount') 111 | 112 | const { checkDoMatchesExist, subscribeToUpdates } = this.injected.api 113 | const res = await checkDoMatchesExist() 114 | 115 | console.log({ res }) 116 | if (res) { 117 | await subscribeToUpdates() 118 | } 119 | this.changeStatus(res) 120 | } 121 | 122 | get stub(): JSX.Element | null { 123 | if (this.shouldShowContent == null) { 124 | return ( 125 | 126 | 127 | 128 | ) 129 | } 130 | 131 | if (!this.shouldShowContent) { 132 | return ( 133 | 134 | 135 | 136 | ) 137 | } 138 | 139 | return null 140 | } 141 | 142 | renderMatchesList = ({ match, location }: RenderType) => 143 | 144 | 145 | 146 | 147 | renderStub = () => 148 | 149 | 150 | 151 | 152 | renderChatHeader = ({ match }: RenderType) => 153 | 154 | 155 | 156 | 157 | renderMessages = ({ match }: RenderType) => 158 | 159 | 160 | 161 | 162 | renderInput = ({ match }: RenderType) => 163 | 164 | 165 | 166 | 167 | renderUserHeader = () => 168 | 169 | 170 | 171 | 172 | renderUserSection = ({ match }: RenderType) => 173 | 174 | 175 | 176 | 177 | renderProfileHeader = () => 178 | 179 | 180 | 181 | 182 | renderProfileSection = () => 183 | 184 | 185 | 186 | 187 | get children() { 188 | const { muiTheme } = this.injected 189 | 190 | return [ 191 | 192 | 193 | , 194 | , 199 | , 205 | , 210 | , 215 | , 220 | , 225 | , 230 | , 235 | 240 | ] 241 | } 242 | 243 | render() { 244 | return ( 245 | 246 | {this.stub != null ? this.stub : this.children} 247 | 248 | ) 249 | } 250 | } 251 | 252 | export default Main 253 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/ChatHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import muiThemeable from 'material-ui/styles/muiThemeable' 4 | // import { graphql, DefaultChildProps } from 'react-apollo' 5 | // import * as queryName from './query.graphql' 6 | import { KEYCODE_ESC } from '~/shared/constants' 7 | import GenericHeader from '~/app/components/GenericHeader' 8 | import GenericIconWrapper from '~/app/components/GenericIconWrapper' 9 | import GenericNameSpan from '~/app/components/GenericNameSpan' 10 | import NavigationClose from 'material-ui/svg-icons/navigation/close' 11 | import { Navigator } from '~/app/stores/Navigator' 12 | import { MuiTheme } from 'material-ui/styles' 13 | import { StateType } from '~/shared/definitions' 14 | 15 | export interface IChatHeaderProps { 16 | navigator?: Navigator 17 | muiTheme?: MuiTheme 18 | id: string 19 | } 20 | 21 | export interface IGQLRes { 22 | match?: { 23 | _id: string 24 | person: { 25 | _id: string 26 | formattedName: string 27 | } 28 | } 29 | } 30 | 31 | // export type ChatHeaderPropsType = DefaultChildProps 32 | export interface IChatHeaderProps { 33 | id: string 34 | } 35 | 36 | interface IInjectedProps extends IChatHeaderProps { 37 | navigator: Navigator 38 | muiTheme: MuiTheme 39 | state: StateType 40 | } 41 | 42 | @inject('navigator', 'state') 43 | @muiThemeable() 44 | @observer 45 | class ChatHeader extends React.Component { 46 | get injected() { 47 | return this.props as IInjectedProps 48 | } 49 | 50 | get user() { 51 | return this.injected.state.matches.get(this.props.id)!.person 52 | } 53 | 54 | handleClose = () => { 55 | this.injected.navigator.goToMatches() 56 | } 57 | 58 | handleClick = () => { 59 | this.injected.navigator.goToUser(this.props.id) 60 | } 61 | 62 | handleKeydown = (e: KeyboardEvent) => { 63 | if (e.keyCode === KEYCODE_ESC) { 64 | this.handleClose() 65 | } 66 | } 67 | 68 | componentDidMount() { 69 | document.addEventListener('keydown', this.handleKeydown) 70 | } 71 | 72 | componentWillUnmount() { 73 | document.removeEventListener('keydown', this.handleKeydown) 74 | } 75 | 76 | render() { 77 | return ( 78 | 79 | 80 | 88 | 89 | 92 | 93 | 94 | ) 95 | } 96 | } 97 | 98 | export default ChatHeader 99 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/ChatInput/components/SendButton/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import FlatButton from 'material-ui/FlatButton' 3 | 4 | const buttonStyle = { 5 | marginLeft: 10, 6 | marginBottom: 7 7 | } 8 | 9 | export interface ISendButtonProps { 10 | disabled: boolean 11 | onClick: React.EventHandler> 12 | } 13 | 14 | function SendButton({ disabled, onClick }: ISendButtonProps) { 15 | return ( 16 | 23 | ) 24 | } 25 | 26 | export default SendButton 27 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/ChatInput/components/TextField/components/EmojiInput/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import linkref, { ILinkedRefs } from '~/app/shims/linkref' 3 | import $ from '~/app/shims/jquery' 4 | 5 | interface Editor extends HTMLDivElement { 6 | emojioneArea: { 7 | getText(): string 8 | setText(value: string): any 9 | hidePicker(): any 10 | } 11 | } 12 | 13 | export type onSubmitType = () => any 14 | export interface IEmojiInputProps { 15 | onInput: (value: string) => any 16 | onSubmit: onSubmitType 17 | onFocus: (event: FocusEvent) => any 18 | onBlur: (event: FocusEvent) => any 19 | value: string 20 | } 21 | 22 | class EmojiInput extends React.Component { 23 | _linkedRefs: ILinkedRefs 24 | root: Editor 25 | 26 | get area() { 27 | return this.root.emojioneArea 28 | } 29 | 30 | shouldComponentUpdate() { 31 | return false 32 | } 33 | 34 | componentDidMount() { 35 | $(this.root).emojioneArea({ 36 | autocomplete: false, 37 | useInternalCDN: false, 38 | sprite: false, 39 | events: { 40 | focus: (_editor: Editor, event: FocusEvent) => 41 | this.props.onFocus(event), 42 | blur: (_editor: Editor, event: FocusEvent) => 43 | this.props.onBlur(event), 44 | keydown: (_editor: Editor, event: KeyboardEvent) => 45 | this.handleKeydown(event), 46 | keyup: (_editor: Editor, event: KeyboardEvent) => 47 | this.handleInput(event), 48 | 'emojibtn.click': (_button: HTMLButtonElement, event: Event) => 49 | this.handleInput(event) 50 | } 51 | }) 52 | } 53 | 54 | componentWillReceiveProps(nextProps: IEmojiInputProps) { 55 | if (nextProps.value === '') { 56 | // For some reason, setting '' results in bug (cursor at the end) 57 | this.area.setText(` `) 58 | this.area.hidePicker() 59 | } 60 | } 61 | 62 | handleKeydown = (event: KeyboardEvent) => { 63 | if (event.which === 13 && !event.shiftKey) { 64 | event.preventDefault() 65 | this.props.onSubmit() 66 | } 67 | } 68 | 69 | handleInput = (_event: Event) => { 70 | this.props.onInput(this.area.getText()) 71 | } 72 | 73 | render(): JSX.Element { 74 | return
75 | } 76 | } 77 | 78 | export default EmojiInput 79 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/ChatInput/components/TextField/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { observable, action } from 'mobx' 3 | import { observer } from 'mobx-react' 4 | import TextFieldHint from 'material-ui/TextField/TextFieldHint' 5 | import EmojiInput, { onSubmitType } from './components/EmojiInput' 6 | import TextFieldUnderline from 'material-ui/TextField/TextFieldUnderline' 7 | import styled from 'styled-components' 8 | import muiThemeable from 'material-ui/styles/muiThemeable' 9 | import { MuiTheme } from 'material-ui/styles' 10 | 11 | // This is a partial rewrite of TextField from material-ui to make it work with Inferno and to fix some bugs 12 | 13 | const OuterWrapper = styled.div` 14 | width: 100%; 15 | position: relative; 16 | display: inline-block; 17 | ` 18 | export { onSubmitType } 19 | export interface ITextFieldProps { 20 | hasValue: boolean 21 | hintText: string 22 | value: string 23 | onChange: (value: string) => any 24 | onSubmit: onSubmitType 25 | } 26 | 27 | interface IInjectedProps extends ITextFieldProps { 28 | muiTheme: MuiTheme 29 | } 30 | 31 | @muiThemeable() 32 | @observer 33 | class TextField extends React.Component { 34 | get injected() { 35 | return this.props as IInjectedProps 36 | } 37 | 38 | @observable isFocused = false 39 | 40 | @action 41 | handleFocus = () => { 42 | this.isFocused = true 43 | } 44 | 45 | @action 46 | handleBlur = () => { 47 | this.isFocused = false 48 | } 49 | 50 | handleInput = (text: string) => { 51 | this.props.onChange(text) 52 | } 53 | 54 | render() { 55 | return ( 56 | 57 | 62 | 69 | 74 | 75 | ) 76 | } 77 | } 78 | 79 | export default TextField 80 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/ChatInput/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { observable, action, computed } from 'mobx' 3 | import TextField from './components/TextField' 4 | import SendButton from './components/SendButton' 5 | import muiThemeable from 'material-ui/styles/muiThemeable' 6 | import transitions from 'material-ui/styles/transitions' 7 | import { inject, observer } from 'mobx-react' 8 | import styled from 'styled-components' 9 | const trim = require('lodash.trim') 10 | import { AbstractAPI } from '~/shared/definitions' 11 | import { MuiTheme } from 'material-ui/styles' 12 | 13 | const padding = 10 14 | 15 | const OuterWrapper = styled.div` 16 | border-top: 1px solid ${props => props.theme.palette.borderColor}; 17 | padding-left: ${padding}px; 18 | padding-right: ${padding}px; 19 | transition: ${transitions.easeOut('200ms', 'height')}; 20 | display: inline-block; 21 | max-width: 100%; 22 | width: 100%; 23 | posititon: relative; 24 | ` 25 | 26 | const MiddleWrapper = styled.div` 27 | display: flex; 28 | flex-direction: row; 29 | align-items: flex-end; 30 | justify-content: space-between; 31 | max-width: 100%; 32 | width: 100%; 33 | ` 34 | 35 | interface IChatInputProps { 36 | id: string 37 | } 38 | 39 | interface IInjectedProps extends IChatInputProps { 40 | api: AbstractAPI 41 | muiTheme: MuiTheme 42 | } 43 | 44 | @inject('api') 45 | @muiThemeable() 46 | @observer 47 | class ChatInput extends React.Component { 48 | get injected() { 49 | return this.props as IInjectedProps 50 | } 51 | 52 | @observable value: string = '' 53 | 54 | @computed 55 | get hasValue() { 56 | return !!this.isValid(this.value) 57 | } 58 | 59 | get disabled() { 60 | return !this.hasValue 61 | } 62 | 63 | @action 64 | handleChange = (text: string) => { 65 | this.value = text 66 | } 67 | 68 | @action 69 | handleSubmit = () => { 70 | if (!this.disabled) { 71 | const message = trim(this.value) 72 | this.injected.api.sendMessage({ 73 | message, 74 | matchId: this.props.id 75 | }) 76 | // this.props.submit({ 77 | // message, 78 | // messageId: uuid.v1() 79 | // }) 80 | this.value = '' 81 | } 82 | } 83 | 84 | isValid(value: string) { 85 | const normValue = trim(value) 86 | return normValue !== '' && normValue !== undefined && normValue !== null 87 | } 88 | 89 | render() { 90 | return ( 91 | 92 | 93 | 100 | 104 | 105 | 106 | ) 107 | } 108 | } 109 | 110 | export default ChatInput 111 | 112 | // { 113 | // /* rows={this.rows} 114 | // maxRows={this.maxRows} */ 115 | // } 116 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MatchesList/components/Match/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Avatar from '~/app/components/Avatar' 3 | import muiThemeable from 'material-ui/styles/muiThemeable' 4 | import styled from 'styled-components' 5 | import { fade } from 'material-ui/utils/colorManipulator' 6 | import { MuiTheme } from 'material-ui/styles' 7 | import { observer } from 'mobx-react' 8 | 9 | const paddingNum = 12 10 | 11 | export interface IMatchContainerProps { 12 | isSelected: boolean 13 | theme: MuiTheme 14 | } 15 | const MatchContainer = styled.div` 16 | display: flex; 17 | align-items: center; 18 | height: 100%; 19 | padding-left: ${paddingNum}px; 20 | cursor: pointer; 21 | width: 100%; 22 | overflow-x: hidden; 23 | box-sizing: border-box; 24 | ${(props: IMatchContainerProps) => 25 | props.isSelected 26 | ? `background-color: ${fade(props.theme.palette!.textColor!, 0.2)};` 27 | : ''}; 28 | ` 29 | 30 | export interface ITextContainerProps { 31 | showBorder: boolean 32 | theme: MuiTheme 33 | } 34 | const TextContainer = styled.div` 35 | margin-left: ${paddingNum}px; 36 | padding-right: ${paddingNum}px; 37 | width: 100%; 38 | overflow: hidden; 39 | height: 100%; 40 | display: flex; 41 | flex-direction: column; 42 | justify-content: center; 43 | border-top: 1px solid 44 | ${(props: ITextContainerProps) => 45 | props.showBorder 46 | ? props.theme.palette!.borderColor! 47 | : 'rgba(0, 0, 0, 0)'}; 48 | box-sizing: border-box; 49 | ` 50 | 51 | const NameContainer = styled.div` 52 | color: ${props => props.theme.palette.textColor}; 53 | ` 54 | 55 | const MessageContainer = styled.div` 56 | text-overflow: ellipsis; 57 | white-space: nowrap; 58 | overflow: hidden; 59 | color: ${props => props.theme.palette.secondaryTextColor}; 60 | font-size: 14px; 61 | ` 62 | export interface IMatchProps { 63 | muiTheme?: MuiTheme 64 | isSelected: boolean 65 | isPreviousSelected: boolean 66 | firstVisible: boolean 67 | goToChat: ({ id, index }: { id: string; index: number }) => any 68 | style: Object 69 | match: { 70 | _id: string 71 | person: { 72 | smallPhoto: string 73 | formattedName: string 74 | } 75 | lastMessage: { 76 | formattedMessage: string 77 | isGIPHY: boolean 78 | } | null 79 | } 80 | index: number 81 | } 82 | 83 | @muiThemeable() 84 | @observer 85 | class Match extends React.Component { 86 | get showBorder(): boolean { 87 | return !( 88 | this.props.isSelected || 89 | this.props.isPreviousSelected || 90 | this.props.firstVisible 91 | ) 92 | } 93 | 94 | handleClick = () => { 95 | this.props.goToChat({ 96 | id: this.props.match._id, 97 | index: this.props.index 98 | }) 99 | } 100 | 101 | render() { 102 | const { match, style, muiTheme, isSelected } = this.props 103 | let message: string 104 | if (match.lastMessage !== null) { 105 | if (match.lastMessage.isGIPHY) { 106 | message = 'GIPHY' 107 | } else { 108 | message = match.lastMessage.formattedMessage 109 | } 110 | } else { 111 | message = "It's a match!" 112 | } 113 | 114 | return ( 115 | 121 | 122 | 123 | 129 | 130 | 135 | 136 | 137 | 138 | ) 139 | } 140 | } 141 | 142 | export default Match 143 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MatchesList/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import Match from './components/Match' 4 | import { List, ListRowProps, ListProps } from 'react-virtualized' 5 | import styled from 'styled-components' 6 | import { VIEW_PROFILE } from '~/shared/constants' 7 | import { Navigator } from '~/app/stores/Navigator' 8 | import { match } from 'react-router-dom' 9 | import { StateType, MatchType } from '~/shared/definitions' 10 | import SimpleBarRV, { IMergedProps } from '~/app/components/SimpleBarRV' 11 | import { Location } from 'history' 12 | 13 | const rowHeight = 63 14 | 15 | const ListWithoutScrollbar = styled(List as any)` 16 | &::-webkit-scrollbar { 17 | display: none 18 | } 19 | ` 20 | 21 | export interface IRRProps { 22 | id?: string 23 | } 24 | 25 | export interface IMatchesListProps { 26 | match: match 27 | location: Location 28 | } 29 | 30 | export interface IInjectedProps extends IMatchesListProps { 31 | navigator: Navigator 32 | state: StateType 33 | } 34 | 35 | export interface IEventRV { 36 | scrollTop: number 37 | scrollHeight: number 38 | clientHeight: number 39 | } 40 | 41 | export type HandlerType = (event: IEventRV) => any 42 | 43 | interface IForceUpdater { 44 | sortedMatches?: MatchType[] 45 | location?: Location 46 | } 47 | 48 | @inject('navigator', 'state') 49 | @observer 50 | class MatchesList extends React.Component { 51 | get injected() { 52 | return this.props as IInjectedProps 53 | } 54 | 55 | _index: number | null = null 56 | _forceUpdater: IForceUpdater = {} 57 | 58 | get index(): number | null { 59 | if (this.props.match.params.id != null) { 60 | return this._index 61 | } else { 62 | return null 63 | } 64 | } 65 | 66 | set index(newIndex: number | null) { 67 | this._index = newIndex 68 | } 69 | 70 | get forceUpdater() { 71 | const { location, sortedMatches } = this._forceUpdater 72 | if ( 73 | this.injected.state.sortedMatches !== sortedMatches || 74 | this.props.location !== location 75 | ) { 76 | this._forceUpdater = { 77 | sortedMatches: this.injected.state.sortedMatches, 78 | location: this.props.location 79 | } 80 | } 81 | return this._forceUpdater 82 | } 83 | 84 | goToChat = ({ id, index }: { id: string; index: number }) => { 85 | this.index = index 86 | this.injected.navigator.goToChat(id) 87 | } 88 | 89 | renderContent = ({ 90 | handleListScroll, 91 | scrollTop, 92 | height, 93 | width 94 | }: IMergedProps) => 95 | 105 | 106 | rowRenderer = ({ index, style }: ListRowProps) => { 107 | const match = this.injected.state.sortedMatches[index] 108 | const { params } = this.props.match 109 | const firstVisible = index === 0 110 | let isSelected: boolean, isPreviousSelected: boolean 111 | 112 | if (params.id !== undefined && params.id !== VIEW_PROFILE) { 113 | isSelected = params.id === match._id 114 | if (isSelected) { 115 | this.index = index 116 | isPreviousSelected = false 117 | } else { 118 | if (this.index !== null) { 119 | isPreviousSelected = this.index + 1 === index 120 | } else { 121 | isPreviousSelected = false 122 | } 123 | } 124 | } else { 125 | isSelected = false 126 | isPreviousSelected = false 127 | } 128 | 129 | return ( 130 | 140 | ) 141 | } 142 | 143 | render() { 144 | return ( 145 | 146 | {this.renderContent} 147 | 148 | ) 149 | } 150 | 151 | handleBlock = (_event: Electron.Event, args: { id: string }) => { 152 | const { params } = this.props.match 153 | if (params != null && params.id != null && args.id === params.id) { 154 | this.injected.navigator.goToMatches() 155 | } 156 | } 157 | } 158 | 159 | export default MatchesList 160 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/components/GenericMessage/components/FirstMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import muiThemeable from 'material-ui/styles/muiThemeable' 4 | import { fade } from 'material-ui/utils/colorManipulator' 5 | import Avatar from '~/app/components/Avatar' 6 | import { inject } from 'mobx-react' 7 | import { MuiTheme } from 'material-ui/styles' 8 | import { Navigator } from '~/app/stores/Navigator' 9 | 10 | const OuterWrapper = styled.div` 11 | display: flex; 12 | flex-direction: column; 13 | position: relative; 14 | ` 15 | 16 | const AvatarWrapper = styled.div` 17 | position: absolute; 18 | left: 43px; 19 | top: 10px; 20 | cursor: pointer; 21 | ` 22 | 23 | const NameWrapper = styled.span` 24 | position: absolute; 25 | left: 92px; 26 | top: 11px; 27 | color: ${props => fade(props.theme.palette.primary1Color, 0.87)}; 28 | font-weight: 500; 29 | font-size: 14px; 30 | line-height: 15.5px; 31 | cursor: pointer; 32 | z-index: 2; 33 | ` 34 | 35 | export interface IFirstMessageProps { 36 | me?: Object 37 | matchId: string 38 | user: { 39 | smallPhoto: string 40 | name: string 41 | } 42 | } 43 | 44 | interface IInjectedProps extends IFirstMessageProps { 45 | muiTheme: MuiTheme 46 | navigator: Navigator 47 | } 48 | 49 | @inject('navigator') 50 | @muiThemeable() 51 | class FirstMessage extends React.Component { 52 | get injected() { 53 | return this.props as IInjectedProps 54 | } 55 | 56 | handleClick = () => { 57 | if (!this.props.me) { 58 | this.injected.navigator.goToUser(this.props.matchId) 59 | } else { 60 | this.injected.navigator.goToProfile() 61 | } 62 | } 63 | 64 | render() { 65 | const { children, user } = this.props 66 | 67 | return ( 68 | 69 | 70 | 71 | 72 | 76 | {user.name} 77 | 78 | {children} 79 | 80 | ) 81 | } 82 | } 83 | 84 | export default FirstMessage 85 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/components/GenericMessage/components/Message/components/GIFMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import CircularProgress from 'material-ui/CircularProgress' 3 | import { getNormalizedSizeOfGIPHY } from '~/shared/utils' 4 | import { observable, action } from 'mobx' 5 | import { inject, observer } from 'mobx-react' 6 | import linkref, { ILinkedRefs } from '~/app/shims/linkref' 7 | import styled from 'styled-components' 8 | import * as Waypoint from 'react-waypoint' 9 | import { SUCCESS, PENDING, FAILURE } from '~/shared/constants' 10 | import { Caches } from '~/app/stores/Caches' 11 | 12 | const OuterWrapper = styled.div` 13 | padding-top: 10px; 14 | padding-bottom: 10px; 15 | ` 16 | 17 | interface IAnimatedGIPHYProps { 18 | height: number 19 | width: number 20 | } 21 | 22 | const AnimatedGIPHY = styled.img` 23 | max-height: 300px; 24 | max-width: 100%; 25 | cursor: pointer; 26 | height: ${(props: IAnimatedGIPHYProps) => props.height}px; 27 | width: ${(props: IAnimatedGIPHYProps) => props.width}px; 28 | ` 29 | 30 | interface IBaseContainerProps { 31 | height: number 32 | width: number 33 | } 34 | 35 | const BaseContainer = styled.div` 36 | height: ${(props: IBaseContainerProps) => props.height}px; 37 | width: ${(props: IBaseContainerProps) => props.width}px; 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | ` 42 | 43 | const CanvasWrapper = styled(BaseContainer)` 44 | position: relative; 45 | cursor: pointer; 46 | ` 47 | 48 | const CanvasPreview = styled.canvas` 49 | position: absolute; 50 | left: 0; 51 | top: 0; 52 | z-index: -1; 53 | ` 54 | 55 | const BaseIconButton = styled.div` 56 | width: 50px; 57 | height: 50px; 58 | position: absolute; 59 | top: 50%; 60 | left: 50%; 61 | margin-left: -25px; 62 | margin-top: -25px; 63 | border-radius: 50%; 64 | z-index: 1; 65 | cursor: pointer; 66 | ` 67 | 68 | const PlayButton = styled(BaseIconButton)` 69 | background-color: rgba(0, 0, 0, .5); 70 | &:hover { 71 | background-color: rgba(0, 0, 0, .6); 72 | } 73 | ` 74 | 75 | const PlayIcon = styled.i` 76 | font-size: 24px; 77 | line-height: 24px; 78 | color: white; 79 | position: absolute; 80 | top: 13px; 81 | left: 18px; 82 | ` 83 | 84 | const ReloadButton = styled(BaseIconButton)` 85 | background-color: rgba(255, 255, 255, .5); 86 | &:hover { 87 | background-color: rgba(255, 255, 255, .6); 88 | } 89 | ` 90 | 91 | const ReloadIcon = styled.i` 92 | font-size: 30px; 93 | line-height: 30px; 94 | color: white; 95 | position: absolute; 96 | top: 10px; 97 | left: 13px; 98 | ` 99 | 100 | const LoaderWrapper = styled(BaseContainer)` 101 | background-color: black; 102 | position: relative; 103 | ` 104 | 105 | export interface IGIFMessageProps { 106 | caches?: Caches 107 | formattedMessage: string 108 | } 109 | 110 | @inject('caches') 111 | @observer 112 | class GIFMessage extends React.Component { 113 | _linkedRefs: ILinkedRefs 114 | blob: Blob 115 | giphy: null | HTMLImageElement = null 116 | req: XMLHttpRequest | null = null 117 | canvas: null | HTMLCanvasElement 118 | @observable progress: number | string = 'none' 119 | @observable height: number 120 | @observable width: number 121 | @observable animated = false 122 | 123 | get loadStatus() { 124 | const { _gifs } = this.props.caches! 125 | const key = this.props.formattedMessage 126 | 127 | if (!_gifs.has(key)) { 128 | _gifs.set(key, PENDING) 129 | } 130 | return _gifs.get(key) 131 | } 132 | 133 | @action 134 | startAnimation = () => { 135 | this.animated = true 136 | } 137 | 138 | @action 139 | stopAnimation = () => { 140 | this.animated = false 141 | } 142 | 143 | @action 144 | setProgress = (event: ProgressEvent) => { 145 | if (event.lengthComputable) { 146 | this.progress = 100 * event.loaded / event.total 147 | } else { 148 | this.progress = 'none' 149 | } 150 | } 151 | 152 | @action 153 | setLoadStatus = (status: string) => { 154 | this.props.caches!.setGifStatus(this.props.formattedMessage, status) 155 | } 156 | 157 | @action 158 | setDimensions = () => { 159 | const calculated = getNormalizedSizeOfGIPHY(this.props.formattedMessage) 160 | this.height = Math.floor(calculated.height) 161 | this.width = Math.floor(calculated.width) 162 | } 163 | 164 | handleLoad = (e: ProgressEvent) => { 165 | this.blob = (e.currentTarget as XMLHttpRequest).response 166 | this.setLoadStatus(SUCCESS) 167 | } 168 | 169 | handleError = () => { 170 | this.setLoadStatus(FAILURE) 171 | } 172 | 173 | drawOnCanvas = () => { 174 | if (this.canvas !== null && this.giphy !== null) { 175 | this.canvas.getContext('2d')!.drawImage( 176 | this.giphy, 177 | 0, 178 | 0, 179 | this.width, 180 | this.height 181 | ) 182 | } 183 | } 184 | 185 | loadGif = () => { 186 | if (this.req !== null) { 187 | this.req.abort() 188 | this.req = null 189 | } 190 | 191 | this.setLoadStatus(PENDING) 192 | 193 | const req = new XMLHttpRequest() 194 | req.addEventListener('progress', this.setProgress) 195 | req.addEventListener('load', this.handleLoad) 196 | req.addEventListener('error', this.handleError) 197 | req.open('GET', this.props.formattedMessage, true) 198 | req.responseType = 'blob' 199 | req.send() 200 | this.req = req 201 | } 202 | 203 | constructor(props: IGIFMessageProps) { 204 | super(props) 205 | this.setDimensions() 206 | } 207 | 208 | componentDidMount() { 209 | this.loadGif() 210 | } 211 | 212 | componentDidUpdate() { 213 | const { canvas } = this 214 | if (canvas) { 215 | canvas.height = this.height 216 | canvas.width = this.width 217 | if (this.giphy === null) { 218 | this.giphy = new Image() 219 | this.giphy.addEventListener('load', () => { 220 | this.drawOnCanvas() 221 | URL.revokeObjectURL(this.giphy!.src) 222 | }) 223 | this.giphy.src = URL.createObjectURL(this.blob) 224 | } else { 225 | this.drawOnCanvas() 226 | } 227 | } 228 | } 229 | 230 | componentWillUnmount() { 231 | if (this.req !== null) { 232 | this.req.abort() 233 | this.req = null 234 | } 235 | } 236 | 237 | renderLoader = () => { 238 | const loaderDiameter = 0.3 * Math.min(this.height, this.width) 239 | let loader 240 | if (typeof this.progress === 'string') { 241 | loader = 242 | } else { 243 | loader = ( 244 | 249 | ) 250 | } 251 | return ( 252 | 253 | {loader} 254 | 255 | ) 256 | } 257 | 258 | renderCanvas = (): JSX.Element => { 259 | return ( 260 | 265 | 266 | 267 | 268 | 269 | 270 | ) 271 | } 272 | 273 | renderFailure = () => { 274 | return ( 275 | 276 | 277 | 278 | 279 | 280 | ) 281 | } 282 | 283 | renderGIPHY = () => { 284 | return ( 285 | // Using inner div here because Waypoint requires it 286 | 287 |
288 | 294 |
295 |
296 | ) 297 | } 298 | 299 | render() { 300 | let content 301 | if (this.animated) { 302 | content = this.renderGIPHY() 303 | } else { 304 | switch (this.loadStatus) { 305 | case SUCCESS: 306 | content = this.renderCanvas() 307 | break 308 | case PENDING: 309 | content = this.renderLoader() 310 | break 311 | case FAILURE: 312 | content = this.renderFailure() 313 | break 314 | } 315 | } 316 | 317 | return ( 318 | 319 | {content} 320 | 321 | ) 322 | } 323 | } 324 | 325 | export default GIFMessage 326 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/components/GenericMessage/components/Message/components/StatusIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { SUCCESS, PENDING, FAILURE } from '~/shared/constants' 3 | import muiThemeable from 'material-ui/styles/muiThemeable' 4 | import styled from 'styled-components' 5 | import { CircularProgress } from 'material-ui' 6 | import AlertError from 'material-ui/svg-icons/alert/error' 7 | import { MuiTheme } from 'material-ui/styles' 8 | import { observer } from 'mobx-react' 9 | 10 | interface IWrapperProps { 11 | first: boolean 12 | } 13 | 14 | const Wrapper = styled.div` 15 | position: absolute; 16 | top: ${(props: IWrapperProps) => (props.first ? '19' : '8')}px; 17 | left: -79px; 18 | height: 17px; 19 | width: 17px; 20 | ` 21 | 22 | export interface IStatusIndicatorProps { 23 | resend: React.EventHandler> 24 | status: string 25 | first: boolean 26 | } 27 | 28 | interface IInjectedProps extends IStatusIndicatorProps { 29 | muiTheme: MuiTheme 30 | } 31 | 32 | @muiThemeable() 33 | @observer 34 | class StatusIndicator extends React.Component { 35 | get injected() { 36 | return this.props as IInjectedProps 37 | } 38 | 39 | renderLoader() { 40 | return 41 | } 42 | 43 | renderError = () => { 44 | return ( 45 | 50 | ) 51 | } 52 | 53 | renderContent = (status: string) => { 54 | switch (status) { 55 | case SUCCESS: 56 | return null 57 | case PENDING: 58 | return this.renderLoader() 59 | case FAILURE: 60 | return this.renderError() 61 | default: 62 | throw new Error('Unknown status type for message.status') 63 | } 64 | } 65 | 66 | render() { 67 | return ( 68 | 69 | {this.renderContent(this.props.status)} 70 | 71 | ) 72 | } 73 | } 74 | 75 | export default StatusIndicator 76 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/components/GenericMessage/components/Message/components/TextMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import styled from 'styled-components' 4 | import { MuiTheme } from 'material-ui/styles' 5 | 6 | const TextSpan = styled.span` 7 | display: inline; 8 | color: ${props => props.theme.palette.textColor}; 9 | position: relative; 10 | max-height: 20px; 11 | font-size: 15px; 12 | line-height: 20px; 13 | font-weight: normal; 14 | color: ${props => props.theme.palette.textColor}; 15 | white-space: pre-line; 16 | user-select: text; 17 | cursor: text; 18 | ` 19 | 20 | export interface ITextMessage { 21 | muiTheme?: MuiTheme 22 | formattedMessage: string 23 | } 24 | 25 | @muiThemeable() 26 | class TextMessage extends React.Component { 27 | render() { 28 | return ( 29 | 35 | ) 36 | } 37 | } 38 | 39 | export default TextMessage 40 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/components/GenericMessage/components/Message/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import TextMessage from './components/TextMessage' 4 | import GIFMessage from './components/GIFMessage' 5 | import StatusIndicator from './components/StatusIndicator' 6 | import styled from 'styled-components' 7 | import { MuiTheme } from 'material-ui/styles' 8 | import { observer } from 'mobx-react' 9 | 10 | interface IMessageWrapperProps { 11 | first: boolean 12 | } 13 | 14 | const MessageWrapper = styled.div` 15 | margin-left: 92px; 16 | padding-right: 92px; 17 | padding-top: ${(props: IMessageWrapperProps) => 18 | props.first ? '29' : '6'}px; 19 | padding-bottom: 7px; 20 | position: relative; 21 | ` 22 | 23 | interface ITimestampProps { 24 | first: boolean 25 | theme: MuiTheme 26 | } 27 | 28 | const Timestamp = styled.span` 29 | color: ${(props: ITimestampProps) => 30 | props.first 31 | ? props.theme.palette!.textColor! 32 | : props.theme.palette!.secondaryTextColor!}; 33 | font-size: 14px; 34 | position: absolute; 35 | top: ${props => (props.first ? '11' : '6')}px; 36 | right: 20px; 37 | cursor: default; 38 | ` 39 | 40 | export interface IMessageProps { 41 | message: { 42 | first: boolean 43 | sentTime: string 44 | formattedMessage: string 45 | status: string 46 | isGIPHY: boolean 47 | } 48 | resend: React.EventHandler> 49 | } 50 | 51 | interface IInjectedProps extends IMessageProps { 52 | muiTheme: MuiTheme 53 | } 54 | 55 | @muiThemeable() 56 | @observer 57 | class Message extends React.Component { 58 | get injected() { 59 | return this.props as IInjectedProps 60 | } 61 | 62 | render() { 63 | const { 64 | first, 65 | sentTime, 66 | formattedMessage, 67 | status, 68 | isGIPHY 69 | } = this.props.message 70 | 71 | const AppropriateWrapper = isGIPHY ? GIFMessage : TextMessage 72 | return ( 73 | 74 | 79 | 80 | 81 | {sentTime} 82 | 83 | 84 | ) 85 | } 86 | } 87 | 88 | export default Message 89 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/components/GenericMessage/components/NewDayMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { computed } from 'mobx' 4 | import muiThemeable from 'material-ui/styles/muiThemeable' 5 | import styled from 'styled-components' 6 | import * as isToday from 'date-fns/is_today' 7 | import * as isYesterday from 'date-fns/is_yesterday' 8 | import { MuiTheme } from 'material-ui/styles' 9 | import { Time } from '~/app/stores/Time' 10 | 11 | const Wrapper = styled.div` 12 | display: flex; 13 | flex-direction: column; 14 | z-index: 1; 15 | ` 16 | 17 | const DateBanner = styled.div` 18 | margin: auto; 19 | margin-top: 10px; 20 | ` 21 | 22 | const DateWrapper = styled.div` 23 | font-size: 14px; 24 | padding: 5px 10px; 25 | color: ${props => props.theme.palette.secondaryTextColor}; 26 | cursor: default; 27 | ` 28 | 29 | export interface INewDayMessageProps { 30 | message: { 31 | sentDate: string 32 | sentDay: string 33 | } 34 | } 35 | 36 | interface IInjectedProps extends INewDayMessageProps { 37 | muiTheme: MuiTheme 38 | time: Time 39 | } 40 | 41 | @inject('time') 42 | @muiThemeable() 43 | @observer 44 | class NewDayMessage extends React.Component { 45 | get injected() { 46 | return this.props as IInjectedProps 47 | } 48 | 49 | @computed 50 | get formattedDay() { 51 | // For auto-recalc 52 | const { message } = this.props 53 | this.injected.time.now 54 | if (isToday(message.sentDate)) { 55 | return 'Today' 56 | } else if (isYesterday(message.sentDate)) { 57 | return 'Yesterday' 58 | } else { 59 | return message.sentDay 60 | } 61 | } 62 | 63 | render() { 64 | return ( 65 | 66 | 67 | 68 | {this.formattedDay} 69 | 70 | 71 | {this.props.children} 72 | 73 | ) 74 | } 75 | } 76 | 77 | export default NewDayMessage 78 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/components/GenericMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Message from './components/Message' 3 | import FirstMessage from './components/FirstMessage' 4 | import NewDayMessage from './components/NewDayMessage' 5 | import styled from 'styled-components' 6 | import { inject, observer } from 'mobx-react' 7 | import { AbstractAPI, MessageType, PersonType } from '~/shared/definitions' 8 | 9 | const GenericMessageContainer = styled.div`overflow-anchor: auto;` 10 | 11 | interface IGenericMessageProps { 12 | style: {} 13 | matchId: string 14 | message: MessageType 15 | user: PersonType 16 | me: boolean 17 | } 18 | 19 | interface IInjectedProps extends IGenericMessageProps { 20 | api: AbstractAPI 21 | } 22 | 23 | @inject('api') 24 | @observer 25 | class GenericMessage extends React.Component { 26 | get injected() { 27 | return this.props as IInjectedProps 28 | } 29 | 30 | handleClick = () => { 31 | this.injected.api.resendMessage(this.props.message._id) 32 | } 33 | 34 | renderContent = () => { 35 | const { message, user, me, matchId } = this.props 36 | 37 | if (message.first) { 38 | if (message.firstInNewDay) { 39 | return ( 40 | 41 | 42 | 46 | 47 | 48 | ) 49 | } else { 50 | return ( 51 | 52 | 53 | 54 | ) 55 | } 56 | } else { 57 | return 58 | } 59 | } 60 | 61 | render() { 62 | return ( 63 | 64 | {this.renderContent()} 65 | 66 | ) 67 | } 68 | } 69 | 70 | export default GenericMessage 71 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/components/NoMessages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import muiThemeable from 'material-ui/styles/muiThemeable' 4 | import { MuiTheme } from 'material-ui/styles' 5 | 6 | const Container = styled.div` 7 | width: 100%; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | margin-top: 34px; 12 | ` 13 | 14 | const Wrapper = styled.div` 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; 18 | color: ${props => props.theme.palette.secondaryTextColor}; 19 | ` 20 | 21 | export interface IInjectedProps { 22 | muiTheme: MuiTheme 23 | } 24 | 25 | @muiThemeable() 26 | class NoMessages extends React.Component { 27 | get injected() { 28 | return this.props as IInjectedProps 29 | } 30 | 31 | render() { 32 | return ( 33 | 34 | 35 | You do not have any messages with this user yet 36 | Be brave and fix it ;) 37 | 38 | 39 | ) 40 | } 41 | } 42 | 43 | export default NoMessages 44 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/MessagesFeed/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { 4 | CellMeasurer, 5 | List, 6 | ListProps, 7 | CellMeasurerCache, 8 | OnScrollParams, 9 | ListRowProps 10 | } from 'react-virtualized' 11 | import GenericMessage from './components/GenericMessage' 12 | import styled from 'styled-components' 13 | import NoMessages from './components/NoMessages' 14 | import { Caches } from '~/app/stores/Caches' 15 | import { Navigator } from '~/app/stores/Navigator' 16 | import { StateType, MessageType } from '~/shared/definitions' 17 | import SimpleBarRV, { IMergedProps } from '~/app/components/SimpleBarRV' 18 | import { getSnapshot } from 'mobx-state-tree' 19 | 20 | const MessagesList = styled(List as any)` 21 | overflow-anchor: auto; 22 | &::-webkit-scrollbar { 23 | display: none 24 | } 25 | ` 26 | 27 | interface ICacheProp { 28 | cache: CellMeasurerCache 29 | } 30 | 31 | export interface IMessagesFeedProps { 32 | id: string 33 | } 34 | 35 | export interface IInjectedProps extends IMessagesFeedProps { 36 | caches: Caches 37 | navigator: Navigator 38 | state: StateType 39 | } 40 | 41 | interface ISizes { 42 | clientHeight?: number 43 | scrollHeight?: number 44 | scrollTop?: number 45 | } 46 | 47 | type RendererProps = ICacheProp & ListRowProps 48 | 49 | @inject('caches', 'navigator', 'state') 50 | @observer 51 | class MessagesFeed extends React.Component { 52 | get injected() { 53 | return this.props as IInjectedProps 54 | } 55 | 56 | sizes: ISizes = {} 57 | 58 | handleScroll = (event: OnScrollParams) => { 59 | this.sizes = event 60 | } 61 | 62 | getCache = (width: number) => { 63 | return this.injected.caches.getMessagesCache(this.props.id, width) 64 | } 65 | 66 | get match() { 67 | return this.injected.state.matches.get(this.props.id)! 68 | } 69 | 70 | get scrollToIndex() { 71 | const end = this.match.messages.length - 1 72 | const savePosition = undefined 73 | const { clientHeight, scrollTop, scrollHeight } = this.sizes 74 | 75 | if ( 76 | typeof clientHeight === 'undefined' || 77 | this.sizes.clientHeight === 0 78 | ) { 79 | return end 80 | } else { 81 | if ( 82 | typeof scrollTop !== 'undefined' && 83 | typeof scrollHeight !== 'undefined' && 84 | clientHeight + scrollTop === scrollHeight 85 | ) { 86 | return end 87 | } else { 88 | return savePosition 89 | } 90 | } 91 | } 92 | 93 | getOverscanRowCount = (width: number) => { 94 | const { id } = this.props 95 | const maxLength = this.match.messages.length 96 | const cacheLength = Object.keys(this.getCache(width)._rowHeightCache) 97 | .length 98 | 99 | if (!this.injected.caches.getShouldMeasureEverything(id, width)) { 100 | if (cacheLength === maxLength) { 101 | this.injected.caches.forbidMeasureEverything(id, width) 102 | return 10 103 | } 104 | return maxLength 105 | } else { 106 | return Math.max(maxLength - cacheLength, 10) 107 | } 108 | } 109 | 110 | get forceUpdaterGetter() { 111 | // Method for triggering updates in PureComponent 112 | return getSnapshot(this.match.messages) 113 | } 114 | 115 | rowRenderer = ({ cache, index, parent, style }: RendererProps) => { 116 | const message = this.match.messages[index] as MessageType 117 | const me = !(message.from === this.match.person._id) 118 | const user = me ? this.injected.state.defaults!.user : this.match.person 119 | 120 | return ( 121 | 128 | 135 | 136 | ) 137 | } 138 | 139 | renderMessages = (props: IMergedProps): JSX.Element => { 140 | const { height, width, handleListScroll, scrollTop } = props 141 | const cache = this.getCache(width) 142 | const overscanRowCount = this.getOverscanRowCount(width) 143 | 144 | return ( 145 | 150 | this.rowRenderer({ ...props, cache })} 151 | rowHeight={cache.rowHeight} 152 | deferredMeasurementCache={cache} 153 | onScroll={handleListScroll} 154 | scrollTop={scrollTop} 155 | scrollToIndex={this.scrollToIndex} 156 | forceUpdater={this.forceUpdaterGetter} 157 | overscanRowCount={overscanRowCount} 158 | /> 159 | ) 160 | } 161 | 162 | renderNoMessages = () => 163 | 164 | render() { 165 | const isEmpty = this.match.messages.length === 0 166 | return ( 167 | 172 | {isEmpty ? this.renderNoMessages : this.renderMessages} 173 | 174 | ) 175 | } 176 | 177 | componentWillReceiveProps(nextProps: IMessagesFeedProps) { 178 | if (nextProps.id !== this.props.id) { 179 | this.sizes = {} 180 | } 181 | } 182 | } 183 | 184 | export default MessagesFeed 185 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/ProfileHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import { inject } from 'mobx-react' 4 | import { KEYCODE_ESC } from '~/shared/constants' 5 | import GenericHeader from '~/app/components/GenericHeader' 6 | import GenericIconWrapper from '~/app/components/GenericIconWrapper' 7 | import GenericNameSpan from '~/app/components/GenericNameSpan' 8 | import NavigationClose from 'material-ui/svg-icons/navigation/close' 9 | import { Navigator } from '~/app/stores/Navigator' 10 | import { MuiTheme } from 'material-ui/styles' 11 | 12 | export interface IInjectedProps { 13 | navigator: Navigator 14 | muiTheme: MuiTheme 15 | } 16 | 17 | @inject('navigator') 18 | @muiThemeable() 19 | class ProfileHeader extends React.Component { 20 | get injected() { 21 | return this.props as IInjectedProps 22 | } 23 | 24 | handleClose = () => { 25 | this.injected.navigator.goToMatches() 26 | } 27 | 28 | handleKeydown = (event: KeyboardEvent) => { 29 | if (event.keyCode === KEYCODE_ESC) { 30 | this.handleClose() 31 | } 32 | } 33 | 34 | componentDidMount() { 35 | document.addEventListener('keydown', this.handleKeydown) 36 | } 37 | 38 | componentWillUnmount() { 39 | document.removeEventListener('keydown', this.handleKeydown) 40 | } 41 | 42 | render() { 43 | return ( 44 | 45 | 46 | 47 | It is you! 48 | 49 | 50 | 53 | 54 | 55 | ) 56 | } 57 | } 58 | 59 | export default ProfileHeader 60 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/ProfileSection/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import UserPhotos from '../UserSection/components/UserPhotos' 3 | import UserTitle from '../UserSection/components/UserTitle' 4 | import UserBio from '../UserSection/components/UserBio' 5 | // import { graphql } from 'react-apollo' 6 | import muiThemeable from 'material-ui/styles/muiThemeable' 7 | import styled from 'styled-components' 8 | import SimpleBarWrapper from '~/app/components/SimpleBarWrapper' 9 | import { observable, action } from 'mobx' 10 | import { inject, observer } from 'mobx-react' 11 | // import * as query from './query.graphql' 12 | // import * as mutation from './mutation.graphql' 13 | // import * as logoutMutation from './logoutMutation.graphql' 14 | // import LoadingStub from '~/app/components/LoadingStub' 15 | import { RaisedButton } from 'material-ui' 16 | import { AbstractAPI, StateType } from '~/shared/definitions' 17 | import { MuiTheme } from 'material-ui/styles' 18 | 19 | const ProfileInfoContainer = styled.div` 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: flex-start; 23 | align-items: flex-start; 24 | padding: 20px; 25 | margin-left: auto; 26 | margin-right: auto; 27 | max-width: 500px; 28 | min-height: 100%; 29 | ` 30 | 31 | const Line = styled.hr` 32 | width: 100%; 33 | margin-top: 15px; 34 | margin-bottom: 15px; 35 | border: none; 36 | height: 1px; 37 | background-color: ${props => props.theme.palette.borderColor}; 38 | ` 39 | 40 | interface IInjectedProps { 41 | api: AbstractAPI 42 | muiTheme: MuiTheme 43 | state: StateType 44 | } 45 | 46 | @inject('api', 'state') 47 | @muiThemeable() 48 | @observer 49 | class ProfileSection extends React.Component { 50 | get injected() { 51 | return this.props as IInjectedProps 52 | } 53 | 54 | shouldRequestUpdates = false 55 | @observable isUpdatePending = true 56 | 57 | get person() { 58 | return this.injected.state.defaults!.user 59 | // return this.props.data.profile.user 60 | } 61 | 62 | renderPhotos = () => { 63 | return 64 | } 65 | 66 | renderTitle = () => { 67 | const { muiTheme } = this.injected 68 | 69 | return [ 70 | , 71 | 81 | ] 82 | } 83 | 84 | renderBio = () => { 85 | if ( 86 | this.person.formattedBio === '' || 87 | this.person.formattedBio === null 88 | ) { 89 | return null 90 | } else { 91 | return [ 92 | , 93 | 94 | ] 95 | } 96 | } 97 | 98 | renderLogout = () => { 99 | return [ 100 | , 101 | 107 | ] 108 | } 109 | 110 | renderContent = () => 111 | 112 | {this.renderPhotos()} 113 | {this.renderTitle()} 114 | {this.renderBio()} 115 | {this.renderLogout()} 116 | 117 | 118 | render() { 119 | return ( 120 | 121 | {this.renderContent()} 122 | 123 | ) 124 | } 125 | 126 | @action 127 | setUpdateStatus = (status: boolean) => { 128 | this.isUpdatePending = status 129 | } 130 | 131 | async componentDidMount() { 132 | await this.injected.api.updateProfile() 133 | this.setUpdateStatus(false) 134 | // if (this.props.data.loading) { 135 | // this.shouldRequestUpdates = true 136 | // } else { 137 | // await this.requestUpdates() 138 | // this.setUpdateStatus(false) 139 | // } 140 | } 141 | 142 | // async componentDidUpdate() { 143 | // if (!this.props.data.loading && this.shouldRequestUpdates) { 144 | // this.shouldRequestUpdates = false 145 | // this.setUpdateStatus(true) 146 | // await this.requestUpdates() 147 | // this.setUpdateStatus(false) 148 | // } 149 | // } 150 | } 151 | 152 | export default ProfileSection 153 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/Stub/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import styled from 'styled-components' 4 | import { MuiTheme } from 'material-ui/styles' 5 | 6 | export interface IStubWrapperProps { 7 | theme: MuiTheme 8 | } 9 | 10 | const StubWrapper = styled.div` 11 | height: 100%; 12 | width: 100%; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | color: ${(props: IStubWrapperProps) => props.theme.palette!.textColor!}; 17 | ` 18 | 19 | export interface IStubProps { 20 | muiTheme?: MuiTheme 21 | } 22 | 23 | @muiThemeable() 24 | class Stub extends React.Component { 25 | render() { 26 | return ( 27 | 28 | Select your match 29 | 30 | ) 31 | } 32 | } 33 | 34 | export default Stub 35 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import NavigationArrowBack from 'material-ui/svg-icons/navigation/arrow-back' 4 | import { inject } from 'mobx-react' 5 | import { KEYCODE_ESC } from '~/shared/constants' 6 | import GenericHeader from '~/app/components/GenericHeader' 7 | import GenericIconWrapper from '~/app/components/GenericIconWrapper' 8 | import GenericNameSpan from '~/app/components/GenericNameSpan' 9 | import { Navigator } from '~/app/stores/Navigator' 10 | import { MuiTheme } from 'material-ui/styles' 11 | 12 | export interface IUserHeaderProps { 13 | navigator?: Navigator 14 | muiTheme?: MuiTheme 15 | } 16 | 17 | @inject('navigator') 18 | @muiThemeable() 19 | class UserHeader extends React.Component { 20 | handleClick = () => { 21 | this.props.navigator!.goBack() 22 | } 23 | 24 | handleKeydown = (event: KeyboardEvent) => { 25 | if (event.keyCode === KEYCODE_ESC) { 26 | this.handleClick() 27 | } 28 | } 29 | 30 | componentDidMount() { 31 | document.addEventListener('keydown', this.handleKeydown) 32 | } 33 | 34 | componentWillUnmount() { 35 | document.removeEventListener('keydown', this.handleKeydown) 36 | } 37 | 38 | render() { 39 | return ( 40 | 41 | 42 | 45 | 46 | 47 | Info 48 | 49 | 50 | 51 | ) 52 | } 53 | } 54 | 55 | export default UserHeader 56 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserSection/components/UserBio/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import styled from 'styled-components' 4 | import { MuiTheme } from 'material-ui/styles' 5 | 6 | const BioWrapper = styled.div` 7 | white-space: pre-wrap; 8 | max-width: 100%; 9 | color: ${props => props.theme.palette.textColor}; 10 | user-select: text; 11 | cursor: text; 12 | ` 13 | 14 | export interface IUserBioProps { 15 | muiTheme?: MuiTheme 16 | formattedBio: string 17 | } 18 | 19 | @muiThemeable() 20 | class UserBio extends React.Component { 21 | render() { 22 | return ( 23 | 27 | ) 28 | } 29 | } 30 | 31 | export default UserBio 32 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserSection/components/UserCommonConnections/components/CommonConnection/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import styled from 'styled-components' 4 | import Avatar from '~/app/components/Avatar' 5 | import { MuiTheme } from 'material-ui/styles' 6 | 7 | const Container = styled.div` 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | width: 100%; 12 | max-width: 110px; 13 | padding: 5px; 14 | ` 15 | 16 | const NameSpan = styled.span` 17 | color: ${props => props.theme.palette.secondaryTextColor}; 18 | max-width: 100%; 19 | overflow: hidden; 20 | text-overflow: ellipsis; 21 | white-space: nowrap; 22 | font-size: 14px; 23 | margin-top: 5px; 24 | ` 25 | 26 | export interface ICommonConnectionData { 27 | photo: { 28 | small: string 29 | } 30 | name: string 31 | } 32 | 33 | export interface ICommonConnectionProps { 34 | muiTheme?: MuiTheme 35 | connection: ICommonConnectionData 36 | } 37 | 38 | @muiThemeable() 39 | class CommonConnection extends React.Component { 40 | render() { 41 | return ( 42 | 43 | 44 | 45 | {this.props.connection.name} 46 | 47 | 48 | ) 49 | } 50 | } 51 | 52 | export default CommonConnection 53 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserSection/components/UserCommonConnections/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import CommonConnection, { 3 | ICommonConnectionData 4 | } from './components/CommonConnection' 5 | import styled from 'styled-components' 6 | import { AutoSizer } from 'react-virtualized' 7 | import muiThemeable from 'material-ui/styles/muiThemeable' 8 | import { observable, action } from 'mobx' 9 | import { observer } from 'mobx-react' 10 | import { FlatButton } from 'material-ui' 11 | import { MuiTheme } from 'material-ui/styles' 12 | 13 | interface IContentContainerProps { 14 | width: number 15 | } 16 | const ContentContainer = styled.div` 17 | display: flex; 18 | flex-direction: column; 19 | width: ${(props: IContentContainerProps) => props.width}px; 20 | cursor: default; 21 | ` 22 | 23 | const ConnectionCount = styled.span` 24 | color: ${props => props.theme.palette.textColor}; 25 | ` 26 | 27 | interface IConnectionsContainerProps { 28 | stick: boolean 29 | } 30 | 31 | const ConnectionsContainer = styled.div` 32 | width: 100%; 33 | display: flex; 34 | flex-direction: row; 35 | flex-wrap: wrap; 36 | justify-content: ${(props: IConnectionsContainerProps) => 37 | props.stick ? 'flex-start' : 'space-between'}; 38 | margin: 15px 0; 39 | ` 40 | 41 | interface IUserCommonConnectionData extends ICommonConnectionData { 42 | id: string 43 | } 44 | 45 | export interface IUserCommonConnections { 46 | connectionCount: number 47 | commonConnections: Array 48 | } 49 | 50 | interface IInjectedProps extends IUserCommonConnections { 51 | muiTheme: MuiTheme 52 | } 53 | 54 | @muiThemeable() 55 | @observer 56 | class UserCommonConnections extends React.Component { 57 | get injected() { 58 | return this.props as IInjectedProps 59 | } 60 | 61 | @observable open: boolean = false 62 | 63 | @action 64 | toggleOpen = () => { 65 | this.open = !this.open 66 | } 67 | 68 | renderConnection(connection: IUserCommonConnectionData) { 69 | return 70 | } 71 | 72 | renderContent = ({ width }: { width: number }) => { 73 | const { connectionCount, commonConnections } = this.props 74 | 75 | let connections, stick 76 | 77 | if (this.open) { 78 | connections = commonConnections 79 | } else { 80 | if (width < 440) { 81 | if (connectionCount <= 3) { 82 | connections = commonConnections 83 | } else { 84 | connections = commonConnections.slice(0, 3) 85 | } 86 | } else { 87 | if (connectionCount <= 4) { 88 | connections = commonConnections 89 | } else { 90 | connections = commonConnections.slice(0, 4) 91 | } 92 | } 93 | } 94 | 95 | if (width < 440) { 96 | if (connectionCount < 3) { 97 | stick = true 98 | } else { 99 | stick = false 100 | } 101 | } else { 102 | if (connectionCount < 4) { 103 | stick = true 104 | } else { 105 | stick = false 106 | } 107 | } 108 | 109 | return ( 110 | 111 | 112 | Common connections: {connectionCount} 113 | 114 | 115 | {connections.map(this.renderConnection)} 116 | 117 | {!stick && 118 | } 123 | 124 | ) 125 | } 126 | 127 | render() { 128 | return ( 129 | 134 | {this.renderContent} 135 | 136 | ) 137 | } 138 | } 139 | 140 | export default UserCommonConnections 141 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserSection/components/UserCommonInterests/components/CommonInterest/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import muiThemeable from 'material-ui/styles/muiThemeable' 3 | import styled from 'styled-components' 4 | import { MuiTheme } from 'material-ui/styles' 5 | 6 | const Wrapper = styled.span` 7 | max-width: 100%; 8 | overflow: hidden; 9 | text-overflow: ellipsis; 10 | color: ${props => props.theme.palette.primary1Color}; 11 | border: 1px solid ${props => props.theme.palette.primary1Color}; 12 | border-radius: 5px; 13 | font-size: 14px; 14 | padding: 2px; 15 | margin-right: 5px; 16 | ` 17 | export interface ICommonInterestData { 18 | name: string 19 | } 20 | 21 | export interface ICommonInterestProps { 22 | muiTheme?: MuiTheme 23 | interest: ICommonInterestData 24 | } 25 | 26 | @muiThemeable() 27 | class CommonInterest extends React.Component { 28 | render() { 29 | return ( 30 | 31 | {this.props.interest.name} 32 | 33 | ) 34 | } 35 | } 36 | 37 | export default CommonInterest 38 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserSection/components/UserCommonInterests/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import CommonInterest, { 4 | ICommonInterestData 5 | } from './components/CommonInterest' 6 | import muiThemeable from 'material-ui/styles/muiThemeable' 7 | import { MuiTheme } from 'material-ui/styles' 8 | 9 | const ContentContainer = styled.div` 10 | display: flex; 11 | flex-direction: column; 12 | cursor: default; 13 | ` 14 | 15 | const InterestsCount = styled.span` 16 | color: ${props => props.theme.palette.textColor}; 17 | margin-bottom: 15px; 18 | ` 19 | 20 | const InterestsContainer = styled.div` 21 | display: flex; 22 | flex-direction: row; 23 | flex-wrap: wrap; 24 | width: 100%; 25 | ` 26 | 27 | interface IUserCommonInterestData extends ICommonInterestData { 28 | id: string 29 | } 30 | 31 | export interface IUserCommonInterestsProps { 32 | muiTheme?: MuiTheme 33 | commonInterests: Array 34 | } 35 | 36 | @muiThemeable() 37 | class UserCommonInterests extends React.Component { 38 | renderInterest(interest: IUserCommonInterestData) { 39 | return 40 | } 41 | 42 | render() { 43 | const { muiTheme, commonInterests } = this.props 44 | 45 | return ( 46 | 47 | 48 | Common interests: {commonInterests.length} 49 | 50 | 51 | {commonInterests.map(this.renderInterest)} 52 | 53 | 54 | ) 55 | } 56 | } 57 | 58 | export default UserCommonInterests 59 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserSection/components/UserPhotos/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ImageGallery, { Item, ClickHandler } from 'react-image-gallery' 3 | import styled from 'styled-components' 4 | import muiThemeable from 'material-ui/styles/muiThemeable' 5 | import { MuiTheme } from 'material-ui/styles' 6 | 7 | const GalleryNav = styled.button` 8 | &:hover::before { 9 | color: ${props => props.theme.palette.primary1Color}; 10 | } 11 | ` 12 | 13 | interface IrenderGenericNavArgs { 14 | className: string 15 | disabled: boolean 16 | onClick: ClickHandler 17 | } 18 | 19 | export interface IUserPhotosProps { 20 | muiTheme?: MuiTheme 21 | photos: Item[] 22 | } 23 | 24 | @muiThemeable() 25 | class UserPhotos extends React.Component { 26 | renderGenericNav = ({ 27 | className, 28 | onClick, 29 | disabled 30 | }: IrenderGenericNavArgs) => { 31 | return ( 32 | 38 | ) 39 | } 40 | 41 | renderLeftNav = (onClick: ClickHandler, disabled: boolean) => { 42 | return this.renderGenericNav({ 43 | onClick, 44 | disabled, 45 | className: 'image-gallery-left-nav' 46 | }) 47 | } 48 | 49 | renderRightNav = (onClick: ClickHandler, disabled: boolean) => { 50 | return this.renderGenericNav({ 51 | onClick, 52 | disabled, 53 | className: 'image-gallery-right-nav' 54 | }) 55 | } 56 | 57 | render() { 58 | return ( 59 | 69 | ) 70 | } 71 | } 72 | 73 | export default UserPhotos 74 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserSection/components/UserTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { computed } from 'mobx' 3 | import { inject, observer } from 'mobx-react' 4 | import muiThemeable from 'material-ui/styles/muiThemeable' 5 | import styled from 'styled-components' 6 | import * as distanceInWordsStrict from 'date-fns/distance_in_words_strict' 7 | import * as addYears from 'date-fns/add_years' 8 | import { CircularProgress } from 'material-ui' 9 | import ToggleStar from 'material-ui/svg-icons/toggle/star' 10 | import ActionWork from 'material-ui/svg-icons/action/work' 11 | import ActionAccountBalance from 'material-ui/svg-icons/action/account-balance' 12 | import CommunicationLocationOn from 'material-ui/svg-icons/communication/location-on' 13 | import { Time } from '~/app/stores/Time' 14 | import { MuiTheme } from 'material-ui/styles' 15 | 16 | const GenericColumn = styled.div` 17 | display: flex; 18 | flex-direction: column; 19 | color: inherit; 20 | ` 21 | 22 | const TitleContainer = styled(GenericColumn)` 23 | color: ${props => props.theme.palette.textColor}; 24 | width: 100%; 25 | cursor: default; 26 | ` 27 | 28 | const GenericRow = styled.div` 29 | display: flex; 30 | flex-direction: row; 31 | color: inherit; 32 | min-height: 20px; 33 | width: 100%; 34 | ` 35 | 36 | const iconStyle = { height: 19, width: 19 } 37 | const IconWrapper = styled.div` 38 | display: flex; 39 | justify-content: center; 40 | align-items: center; 41 | height: ${iconStyle.height}px; 42 | min-width: ${iconStyle.width}px; 43 | margin-right: 5px; 44 | ` 45 | 46 | const nameRowHeight = 25 47 | const NameRow = styled(GenericRow)` 48 | justify-content: space-between; 49 | align-items: center; 50 | height: ${nameRowHeight}px; 51 | ` 52 | 53 | const NameSpan = styled.span`color: inherit;` 54 | 55 | const AgeSpan = styled.span`margin-left: 1ex;` 56 | 57 | const PaleRow = styled(GenericRow)` 58 | color: ${props => props.theme.palette.secondaryTextColor}; 59 | ` 60 | 61 | const GenericSpan = styled.span`color: inherit;` 62 | 63 | const JobRow = PaleRow 64 | const SchoolRow = PaleRow 65 | const LocationRow = PaleRow 66 | 67 | interface ISchool { 68 | id: string | null 69 | name: string 70 | } 71 | 72 | interface IJob { 73 | company: null | { 74 | name: string 75 | } 76 | title: null | { 77 | name: string 78 | } 79 | } 80 | 81 | export interface IUserTitleProps { 82 | time?: Time 83 | muiTheme?: MuiTheme 84 | birthDate: string 85 | isSuperLike: boolean 86 | formattedName: string 87 | isUpdatePending: boolean 88 | distanceKm: null | number 89 | schools: null | Array 90 | jobs: null | Array 91 | } 92 | 93 | @inject('time') 94 | @muiThemeable() 95 | @observer 96 | class UserTitle extends React.Component { 97 | @computed 98 | get age() { 99 | return distanceInWordsStrict( 100 | this.props.time!.now, 101 | addYears(this.props.birthDate, 1), 102 | { unit: 'Y' } 103 | ) 104 | } 105 | 106 | renderSuperLike = () => 107 | 108 | 112 | 113 | 114 | renderNameAndAge = () => 115 | 116 | 117 | {this.props.isSuperLike && this.renderSuperLike()} 118 | 123 | , 124 | {this.age} 125 | 126 | {this.props.isUpdatePending && 127 | } 128 | 129 | 130 | renderJob(job: IJob, index: number) { 131 | let text 132 | if (job.company !== null && job.title !== null) { 133 | text = `${job.title.name} in company ${job.company.name}` 134 | } else if (job.company !== null && job.title === null) { 135 | text = job.company.name 136 | } else if (job.company === null && job.title !== null) { 137 | text = job.title.name 138 | } else { 139 | text = '' 140 | } 141 | 142 | return ( 143 | 144 | {text} 145 | 146 | ) 147 | } 148 | 149 | renderJobs = () => { 150 | const { jobs, muiTheme } = this.props 151 | if (jobs === null || typeof jobs === 'undefined' || jobs.length === 0) { 152 | return null 153 | } 154 | 155 | return ( 156 | 157 | 158 | 162 | 163 | 164 | {jobs.map(this.renderJob)} 165 | 166 | 167 | ) 168 | } 169 | 170 | renderSchool(school: ISchool, index: number) { 171 | const key = school.id !== null ? school.id : `${school.name}_${index}` 172 | return ( 173 | 174 | {school.name} 175 | 176 | ) 177 | } 178 | 179 | renderSchools = () => { 180 | const { schools, muiTheme } = this.props 181 | if (schools === null || schools.length === 0) { 182 | return null 183 | } 184 | 185 | return ( 186 | 187 | 188 | 192 | 193 | 194 | {schools.map(this.renderSchool)} 195 | 196 | 197 | ) 198 | } 199 | 200 | renderLocation = () => { 201 | const { distanceKm, muiTheme } = this.props 202 | if (distanceKm === null) { 203 | return null 204 | } 205 | 206 | return ( 207 | 208 | 209 | 213 | 214 | Km from you: {distanceKm} 215 | 216 | ) 217 | } 218 | 219 | render() { 220 | return ( 221 | 222 | {this.renderNameAndAge()} 223 | {this.renderJobs()} 224 | {this.renderSchools()} 225 | {this.renderLocation()} 226 | 227 | ) 228 | } 229 | } 230 | 231 | export default UserTitle 232 | -------------------------------------------------------------------------------- /src/app/scenes/App/scenes/Main/scenes/UserSection/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import UserPhotos from './components/UserPhotos' 3 | import UserTitle from './components/UserTitle' 4 | import UserBio from './components/UserBio' 5 | import UserCommonConnections from './components/UserCommonConnections' 6 | import UserCommonInterests from './components/UserCommonInterests' 7 | import muiThemeable from 'material-ui/styles/muiThemeable' 8 | import styled from 'styled-components' 9 | import SimpleBarWrapper from '~/app/components/SimpleBarWrapper' 10 | import { observable, action } from 'mobx' 11 | import { inject, observer } from 'mobx-react' 12 | import { AbstractAPI, StateType } from '~/shared/definitions' 13 | import { MuiTheme } from 'material-ui/styles' 14 | 15 | const UserInfoContainer = styled.div` 16 | display: flex; 17 | flex-direction: column; 18 | justify-content: flex-start; 19 | align-items: flex-start; 20 | padding: 20px; 21 | margin-left: auto; 22 | margin-right: auto; 23 | max-width: 500px; 24 | min-height: 100%; 25 | ` 26 | 27 | const Line = styled.hr` 28 | width: 100%; 29 | margin-top: 15px; 30 | margin-bottom: 15px; 31 | border: none; 32 | height: 1px; 33 | background-color: ${props => props.theme.palette.borderColor}; 34 | ` 35 | 36 | interface IUserSectionProps { 37 | id: string 38 | } 39 | 40 | interface IInjectedProps extends IUserSectionProps { 41 | state: StateType 42 | muiTheme: MuiTheme 43 | api: AbstractAPI 44 | } 45 | 46 | @inject('api', 'state') 47 | @muiThemeable() 48 | @observer 49 | export class UserSection extends React.Component { 50 | get injected() { 51 | return this.props as IInjectedProps 52 | } 53 | 54 | @observable isUpdatePending = true 55 | 56 | get match() { 57 | return this.injected.state.matches.get(this.props.id)! 58 | } 59 | 60 | get person() { 61 | return this.match.person 62 | } 63 | 64 | renderPhotos = () => { 65 | return 66 | } 67 | 68 | renderTitle = () => { 69 | const { is_super_like } = this.match 70 | const isSuperLike = is_super_like === null ? false : is_super_like 71 | 72 | return [ 73 | , 74 | 84 | ] 85 | } 86 | 87 | renderBio = () => { 88 | if ( 89 | this.person.formattedBio === '' || 90 | this.person.formattedBio === null 91 | ) { 92 | return null 93 | } 94 | 95 | return [ 96 | , 97 | 98 | ] 99 | } 100 | 101 | renderConnections = () => { 102 | const { connection_count, common_connections } = this.person 103 | if ( 104 | connection_count === null || 105 | connection_count === 0 || 106 | common_connections === null 107 | ) { 108 | return null 109 | } 110 | 111 | return [ 112 | , 113 | 118 | ] 119 | } 120 | 121 | renderInterests = () => { 122 | const { common_interests } = this.person 123 | if (common_interests === null || common_interests.length === 0) { 124 | return null 125 | } 126 | 127 | return [ 128 | , 129 | 133 | ] 134 | } 135 | 136 | renderContent = () => { 137 | return ( 138 | 139 | {this.renderPhotos()} 140 | {this.renderTitle()} 141 | {this.renderBio()} 142 | {this.renderConnections()} 143 | {this.renderInterests()} 144 | 145 | ) 146 | } 147 | 148 | render() { 149 | return ( 150 | 151 | {this.renderContent()} 152 | 153 | ) 154 | } 155 | 156 | @action 157 | setUpdateStatus = (status: boolean) => { 158 | this.isUpdatePending = status 159 | } 160 | 161 | async componentDidMount() { 162 | await this.injected.api.updatePerson(this.person) 163 | this.setUpdateStatus(false) 164 | } 165 | } 166 | 167 | export default UserSection 168 | -------------------------------------------------------------------------------- /src/app/shims/emojione.ts: -------------------------------------------------------------------------------- 1 | import * as emojione from 'emojione' 2 | 3 | const emj = emojione 4 | emj.imageType = 'png' 5 | emj.sprites = false 6 | emj.imagePathPNG = 'emoji/' 7 | 8 | export default emj 9 | -------------------------------------------------------------------------------- /src/app/shims/jquery.ts: -------------------------------------------------------------------------------- 1 | import * as $ from 'jquery' 2 | import emojione from './emojione' 3 | import * as packageJSON from 'emojione/package.json' 4 | 5 | function shimJQueryPlugins() { 6 | global.jQuery = $ 7 | global.emojione = emojione 8 | global.emojioneVersion = packageJSON.version 9 | require('emojionearea') 10 | return global.jQuery 11 | } 12 | 13 | export default shimJQueryPlugins() 14 | -------------------------------------------------------------------------------- /src/app/shims/linkref.ts: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | 3 | export type LinkRefArgumentType = HTMLElement | Component | null 4 | 5 | export interface ILinkedRefs { 6 | [key: string]: (element: LinkRefArgumentType) => any 7 | } 8 | 9 | export interface ILinkedComponent extends Component { 10 | _linkedRefs: ILinkedRefs 11 | } 12 | 13 | export default function linkRef( 14 | component: ILinkedComponent, 15 | name: string 16 | ) { 17 | let cache = component._linkedRefs || (component._linkedRefs = {}) 18 | return ( 19 | cache[name] || 20 | (cache[name] = (element: LinkRefArgumentType) => { 21 | component[name] = element 22 | }) 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/app/stores/API/API.ts: -------------------------------------------------------------------------------- 1 | // import { ApolloClient } from 'apollo-client' 2 | // import { DocumentNode } from 'graphql' 3 | import { 4 | AbstractAPI, 5 | StateType, 6 | AbstractTinderAPI, 7 | IAPIGenericReturn, 8 | PersonType, 9 | IAPISendMessage, 10 | AbstractFB 11 | } from '~/shared/definitions' 12 | 13 | // import * as logoutMutation from './logout.graphql' 14 | // import * as getFBQuery from './getFB.graphql' 15 | // import * as showWindow from './showWindow.graphql' 16 | // import * as loginFB from './loginFB.graphql' 17 | 18 | // import { 19 | // GetFbQuery, 20 | // ShowWindowMutation, 21 | // LoginFbMutation, 22 | // LogoutMutation 23 | // } from '~/schema' 24 | import { 25 | VIEW_MATCHES, 26 | VIEW_AUTH, 27 | routes, 28 | success, 29 | PENDING, 30 | FAILURE, 31 | SUCCESS, 32 | IPC_LOGOUT, 33 | IPC_SHOW_WINDOW 34 | } from '~/shared/constants' 35 | import { ipcRenderer } from 'electron' 36 | import { isOnline } from './utils' 37 | import * as uuid from 'uuid' 38 | import { MessageType } from '~/shared/definitions' 39 | 40 | export interface IAPIProps { 41 | state: StateType 42 | tinder: AbstractTinderAPI 43 | fb: AbstractFB 44 | } 45 | 46 | export class API implements AbstractAPI { 47 | // private client: ApolloClient 48 | private reloginPromise: Promise | null = null 49 | private state: StateType 50 | private tinder: AbstractTinderAPI 51 | private fb: AbstractFB 52 | 53 | constructor(props: IAPIProps) { 54 | Object.assign(this, props) 55 | } 56 | 57 | // mutate = async (mutation: DocumentNode, variables?: Object) => { 58 | // return (await this.client.mutate({ mutation, variables })).data as R 59 | // } 60 | 61 | // query = async (query: DocumentNode, variables?: Object) => { 62 | // return (await this.client.query({ query, variables })).data 63 | // } 64 | 65 | public login = async (silent: boolean): Promise => { 66 | this.tinder.resetClient() 67 | // let fb = (await this.query(getFBQuery)).fb 68 | try { 69 | if (this.fb.token === undefined || this.fb.id === undefined) { 70 | throw new Error('fbToken or fbId is not present') 71 | } 72 | await this.tinder.authorize({ 73 | fbToken: this.fb.token, 74 | fbId: this.fb.id 75 | }) 76 | return success 77 | } catch (err) {} 78 | 79 | try { 80 | await this.fb.login(silent) 81 | // fb = (await this.mutate(loginFB, { 82 | // silent 83 | // })).loginFB 84 | 85 | await this.tinder.authorize( 86 | { fbToken: this.fb.token, fbId: this.fb.id } as { 87 | fbToken: string 88 | fbId: string 89 | } 90 | ) 91 | return success 92 | } catch (err) { 93 | return { status: 'Unauthorized' } 94 | } 95 | } 96 | 97 | public checkDoMatchesExist = async (): Promise => { 98 | const matchesCount = this.state.matches.size 99 | console.log('checkDoMatchesExist', matchesCount) 100 | 101 | if (matchesCount !== 0) { 102 | return true 103 | } else { 104 | const history: any = await new Promise(async resolve => { 105 | let resolved = false 106 | let history: any | null = null 107 | 108 | while (!resolved || history === null) { 109 | try { 110 | history = await this.tinder.getHistory() 111 | resolved = true 112 | } catch (err) { 113 | await this.relogin() 114 | } 115 | } 116 | 117 | resolve(history) 118 | }) 119 | 120 | if (history.matches.length === 0) { 121 | return false 122 | } else { 123 | if (history.matches.length !== 0) { 124 | this.state.mergeUpdates(history, true) 125 | return true 126 | } else { 127 | return false 128 | } 129 | } 130 | } 131 | } 132 | 133 | private getUpdates = async () => { 134 | if (this.tinder.subscriptionPromise === null) { 135 | this.tinder.subscriptionPromise = new Promise(async resolve => { 136 | let resolved = false 137 | let updates: any | null = null 138 | 139 | while (!resolved || updates === null) { 140 | try { 141 | updates = await this.tinder.getUpdates() 142 | resolved = true 143 | } catch (err) { 144 | await this.relogin() 145 | } 146 | } 147 | 148 | resolve(updates) 149 | }) 150 | 151 | const updates = await this.tinder.subscriptionPromise 152 | this.tinder.subscriptionPromise = null 153 | this.state.mergeUpdates(updates, false) 154 | } 155 | } 156 | 157 | public subscribeToUpdates = async (): Promise => { 158 | if (this.tinder.subscriptionInterval !== null) { 159 | clearInterval(this.tinder.subscriptionInterval) 160 | this.tinder.subscriptionInterval = null 161 | } 162 | 163 | if (this.state.defaults === null) { 164 | while (this.tinder.getDefaults() === null) { 165 | await this.relogin() 166 | } 167 | this.state.setDefaults(this.tinder.getDefaults()) 168 | } 169 | const { defaults } = this.state 170 | 171 | const interval = defaults!.globals.updates_interval 172 | this.tinder.subscriptionInterval = window.setInterval( 173 | () => this.getUpdates(), 174 | interval 175 | ) 176 | 177 | return success 178 | } 179 | 180 | public logout = () => { 181 | ipcRenderer.send(IPC_LOGOUT) 182 | // const res = await this.mutate(logoutMutation) 183 | // return res.logout 184 | } 185 | 186 | // public getFB = () => { 187 | // return this.query(getFBQuery) 188 | // } 189 | 190 | public getInitialRoute = async (): Promise => { 191 | const matchesCount = this.state.matches.size 192 | console.log({ matchesCount }) 193 | if (matchesCount !== 0) { 194 | return routes[VIEW_MATCHES] 195 | } else { 196 | const { token, id } = this.fb 197 | console.log({ token, id }) 198 | if (token !== undefined && id !== undefined) { 199 | return routes[VIEW_MATCHES] 200 | } else { 201 | return routes[VIEW_AUTH] 202 | } 203 | } 204 | } 205 | 206 | public showWindow = () => { 207 | ipcRenderer.send(IPC_SHOW_WINDOW) 208 | // return this.mutate(showWindow) 209 | } 210 | 211 | public relogin = async () => { 212 | if (this.reloginPromise === null) { 213 | this.reloginPromise = new Promise(async resolve => { 214 | let loggedIn = false 215 | 216 | while (!loggedIn) { 217 | const online = await isOnline() 218 | if (online) { 219 | const res = await this.login(true) 220 | if (res.status === success.status) { 221 | loggedIn = true 222 | this.reloginPromise!.then(() => { 223 | this.reloginPromise = null 224 | }) 225 | } else { 226 | await new Promise(ok => setTimeout(ok, 5000)) 227 | } 228 | } 229 | } 230 | 231 | resolve() 232 | }) 233 | } 234 | 235 | return this.reloginPromise 236 | } 237 | 238 | public updateProfile = async () => { 239 | const profile = await this.tinder.getProfile() 240 | this.state.defaults!.user.update(profile) 241 | } 242 | 243 | public updatePerson = async (person: PersonType) => { 244 | const newPerson = await this.tinder.getPerson(person._id) 245 | person.update(newPerson) 246 | } 247 | 248 | public sendMessage = async ({ message, matchId }: IAPISendMessage) => { 249 | const match = this.state.matches.get(matchId)! 250 | const rawMessage = { 251 | _id: uuid.v1(), 252 | from: this.state.defaults!.user._id, 253 | to: matchId, 254 | message, 255 | status: PENDING 256 | } 257 | const newMessage = match.addMessage(rawMessage, match.lastMessage) 258 | this.state.addMessageToPending(newMessage) 259 | try { 260 | await this.tinder.sendMessage(matchId, message) 261 | newMessage.changeStatus(SUCCESS) 262 | this.state.addMessageToSent(newMessage) 263 | } catch (err) { 264 | newMessage.changeStatus(FAILURE) 265 | } 266 | 267 | this.state.addMessageToPending(newMessage) 268 | } 269 | 270 | public resendMessage = async (messageId: string) => { 271 | const pendingMessage = this.state.pendingMessages.get( 272 | messageId 273 | ) as MessageType 274 | 275 | pendingMessage.changeStatus(PENDING) 276 | try { 277 | await this.tinder.sendMessage( 278 | pendingMessage.to, 279 | pendingMessage.message 280 | ) 281 | pendingMessage.changeStatus(SUCCESS) 282 | this.state.addMessageToSent(pendingMessage) 283 | } catch (err) { 284 | pendingMessage.changeStatus(FAILURE) 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/app/stores/API/getFB.graphql: -------------------------------------------------------------------------------- 1 | query getFB { 2 | fb { 3 | id 4 | token 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/app/stores/API/index.ts: -------------------------------------------------------------------------------- 1 | export { API } from './API' 2 | -------------------------------------------------------------------------------- /src/app/stores/API/loginFB.graphql: -------------------------------------------------------------------------------- 1 | mutation loginFB { 2 | loginFB { 3 | id 4 | token 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/app/stores/API/logout.graphql: -------------------------------------------------------------------------------- 1 | mutation logout { 2 | logout { 3 | status 4 | } 5 | } -------------------------------------------------------------------------------- /src/app/stores/API/showWindow.graphql: -------------------------------------------------------------------------------- 1 | mutation showWindow { 2 | showWindow { 3 | status 4 | } 5 | } -------------------------------------------------------------------------------- /src/app/stores/API/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { isOnline } from './isOnline' 2 | -------------------------------------------------------------------------------- /src/app/stores/API/utils/isOnline.ts: -------------------------------------------------------------------------------- 1 | const isReachable = require('is-reachable') 2 | import TinderClient from 'tinder-modern' 3 | 4 | export async function isOnline(): Promise { 5 | const [fb, tinder] = await Promise.all([ 6 | isReachable('https://www.facebook.com/'), 7 | TinderClient.isOnline() 8 | ]) 9 | return fb && tinder 10 | } 11 | -------------------------------------------------------------------------------- /src/app/stores/Caches/Caches.ts: -------------------------------------------------------------------------------- 1 | import { CellMeasurerCache } from 'react-virtualized' 2 | import { observable, action } from 'mobx' 3 | 4 | export class Caches { 5 | _messages = new Map() 6 | _ids = new Map() 7 | @observable _gifs = new Map() 8 | 9 | generateKey(id: string, width: number) { 10 | return `${id}_${width}` 11 | } 12 | 13 | getMessagesCache = (id: string, width: number) => { 14 | const key = this.generateKey(id, width) 15 | 16 | if (this._messages.has(key)) { 17 | return this._messages.get(key) 18 | } else { 19 | const cache = new CellMeasurerCache({ 20 | fixedWidth: true, 21 | defaultHeight: 33, 22 | defaultWidth: width 23 | }) 24 | if (width !== 0) { 25 | this._messages.set(key, cache) 26 | } 27 | return cache 28 | } 29 | } 30 | 31 | getShouldMeasureEverything = (id: string, width: number) => { 32 | const key = this.generateKey(id, width) 33 | 34 | return this._ids.has(key) 35 | } 36 | 37 | forbidMeasureEverything = (id: string, width: number) => { 38 | const key = this.generateKey(id, width) 39 | 40 | this._ids.set(key, true) 41 | } 42 | 43 | @action 44 | setGifStatus = (key: string, status: string) => { 45 | this._gifs.set(key, status) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/stores/Caches/index.ts: -------------------------------------------------------------------------------- 1 | export { Caches } from './Caches' 2 | -------------------------------------------------------------------------------- /src/app/stores/FB/FB.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractFB, 3 | AbstractFBParams, 4 | AbstractFBSaved, 5 | IGetFBTokenFailure, 6 | IGetFBTokenSuccess, 7 | GetFBTokenType 8 | } from '~/shared/definitions' 9 | import fetch from 'node-fetch' 10 | import { ipcRenderer } from 'electron' 11 | import { IPC_GET_FB_TOKEN_REQ, IPC_GET_FB_TOKEN_RES } from '~/shared/constants' 12 | // import getIdFactory from './getIdFactory' 13 | // import getToken from './getToken' 14 | // import loginForceFactory from './loginForceFactory' 15 | // import loginFactory from './loginFactory' 16 | // import { fromCallback } from '~/shared/utils' 17 | // import * as fs from 'fs' 18 | 19 | export class FB extends AbstractFB implements AbstractFB { 20 | constructor(params: AbstractFBParams) { 21 | super() 22 | Object.assign(this, params) 23 | } 24 | 25 | save = () => { 26 | const data: AbstractFBSaved = { 27 | token: this.token, 28 | expiresAt: this.expiresAt, 29 | id: this.id 30 | } 31 | 32 | return this.storage.save('fb', data) 33 | // return fromCallback(callback => 34 | // fs.writeFile(this.fbPath, JSON.stringify(data), callback) 35 | // ) 36 | } 37 | 38 | clear = () => { 39 | return this.storage.save('fb', {}) 40 | // return fromCallback(callback => fs.unlink(this.fbPath, callback)) 41 | } 42 | 43 | setToken = (token: string) => { 44 | this.token = token 45 | return this.save() 46 | } 47 | 48 | setExpiration = (expiresAt: number) => { 49 | this.expiresAt = expiresAt 50 | return this.save() 51 | } 52 | 53 | setId = (id: string) => { 54 | this.id = id 55 | return this.save() 56 | } 57 | 58 | getId = async () => { 59 | if (typeof this.token === 'undefined') { 60 | throw new Error('fb token is not present!') 61 | } 62 | 63 | if (this.expiresAt === undefined || this.expiresAt <= Date.now()) { 64 | throw new Error('fb token has expired!') 65 | } 66 | 67 | const res = await fetch( 68 | `https://graph.facebook.com/me?fields=id&access_token=${this.token}` 69 | ) 70 | const json = await res.json() 71 | if (json.error) { 72 | throw new Error(json.error) 73 | } 74 | if (!res.ok) { 75 | throw new Error(`request failed with status ${res.status}`) 76 | } 77 | 78 | return json.id as string 79 | } 80 | // getId = getIdFactory(this) 81 | // getToken = getToken 82 | getToken = (silent: boolean) => { 83 | const promise = new Promise((resolve, reject) => { 84 | ipcRenderer.once( 85 | IPC_GET_FB_TOKEN_RES, 86 | (_event: Electron.IpcMessageEvent, res: GetFBTokenType) => { 87 | if ((res as IGetFBTokenFailure).err) { 88 | reject((res as IGetFBTokenFailure).err) 89 | } else { 90 | resolve(res as IGetFBTokenSuccess) 91 | } 92 | } 93 | ) 94 | }) 95 | ipcRenderer.send(IPC_GET_FB_TOKEN_REQ, silent) 96 | return promise 97 | } 98 | 99 | // loginForce = loginForceFactory(this) 100 | loginForce = async (silent: boolean) => { 101 | const { token, expiresIn } = await this.getToken(silent) 102 | this.setToken(token) 103 | this.setExpiration(Date.now() + 1000 * expiresIn) 104 | const id = await this.getId() 105 | this.setId(id) 106 | } 107 | 108 | // login = loginFactory(this) 109 | login = async (silent: boolean) => { 110 | try { 111 | await this.getId() 112 | } catch (err) { 113 | return this.loginForce(silent) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/app/stores/FB/index.ts: -------------------------------------------------------------------------------- 1 | export { FB } from './FB' 2 | -------------------------------------------------------------------------------- /src/app/stores/Navigator/Navigator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VIEW_AUTH, 3 | VIEW_MATCHES, 4 | VIEW_CHAT, 5 | VIEW_USER, 6 | VIEW_LOADING, 7 | VIEW_PROFILE 8 | } from '~/shared/constants' 9 | import { nameToPath } from '~/shared/utils' 10 | import { AbstractAPI } from '~/shared/definitions' 11 | import { History as CustomHistory } from 'history' 12 | 13 | export interface IGQLResponce { 14 | initialRoute: string 15 | } 16 | 17 | export class Navigator { 18 | history: CustomHistory 19 | 20 | setHistory(history: CustomHistory) { 21 | this.history = history 22 | } 23 | 24 | start = async ({ api }: { api: AbstractAPI }) => { 25 | const initialRoute = await api.getInitialRoute() 26 | history.replaceState( 27 | {}, 28 | 'Chatinder', 29 | `${location.pathname}#${initialRoute}` 30 | ) 31 | await api.showWindow() 32 | } 33 | 34 | push(node: string, params?: string) { 35 | const hash = nameToPath(node, params) 36 | if (`#${hash}` !== location.hash) { 37 | this.history.push(hash) 38 | } 39 | } 40 | 41 | goToAuth() { 42 | this.push(VIEW_AUTH) 43 | } 44 | 45 | goToLoading(title: string) { 46 | this.push(VIEW_LOADING, title) 47 | } 48 | 49 | goToMatches() { 50 | this.push(VIEW_MATCHES) 51 | } 52 | 53 | goToChat(id: string) { 54 | this.push(VIEW_CHAT, id) 55 | } 56 | 57 | goToUser(id: string) { 58 | this.push(VIEW_USER, id) 59 | } 60 | 61 | goToProfile() { 62 | this.push(VIEW_PROFILE) 63 | } 64 | 65 | goBack() { 66 | this.history.goBack() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/stores/Navigator/index.ts: -------------------------------------------------------------------------------- 1 | export { Navigator } from './Navigator' 2 | -------------------------------------------------------------------------------- /src/app/stores/Notifier/Notifier.ts: -------------------------------------------------------------------------------- 1 | import { NotificationMessageType } from '~/shared/definitions' 2 | 3 | export class Notifier { 4 | notify({ title, body }: NotificationMessageType) { 5 | const notification = new Notification(title, { 6 | body 7 | }) 8 | setTimeout(notification.close.bind(notification), 5000) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/stores/Notifier/index.ts: -------------------------------------------------------------------------------- 1 | export { Notifier } from './Notifier' 2 | -------------------------------------------------------------------------------- /src/app/stores/State/Connection.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | 3 | export const Connection = types.model('Connection', { 4 | id: types.string, 5 | name: types.string, 6 | photo: types.model('ConnectionPhoto', { 7 | small: types.string 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /src/app/stores/State/Defaults.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | import { Person, Globals } from '.' 3 | 4 | export const Defaults = types.model('Defaults', { 5 | token: types.string, 6 | user: Person, 7 | globals: Globals 8 | }) 9 | -------------------------------------------------------------------------------- /src/app/stores/State/Globals.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | 3 | export const Globals = types.model('Globals', { 4 | updates_interval: types.number 5 | }) 6 | -------------------------------------------------------------------------------- /src/app/stores/State/Interest.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | 3 | export const Interest = types.model('Interest', { 4 | name: types.string, 5 | id: types.string 6 | }) 7 | -------------------------------------------------------------------------------- /src/app/stores/State/Job.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | 3 | export const Job = types.model('Job', { 4 | company: types.maybe( 5 | types.model('JobCompany', { 6 | name: types.string 7 | }) 8 | ), 9 | title: types.maybe( 10 | types.model('JobTitle', { 11 | name: types.string 12 | }) 13 | ) 14 | }) 15 | -------------------------------------------------------------------------------- /src/app/stores/State/Match.ts: -------------------------------------------------------------------------------- 1 | import { types, getEnv } from 'mobx-state-tree' 2 | import { Message, Person } from '.' 3 | import { MessageType } from '~/shared/definitions' 4 | 5 | type LastMessageType = MessageType | null 6 | 7 | interface IRawMatch { 8 | last_activity_date: string 9 | messages: any[] 10 | } 11 | 12 | export const Match = types.model( 13 | 'Match', 14 | { 15 | _id: types.identifier(types.string), 16 | last_activity_date: types.string, 17 | messages: types.array(Message), 18 | is_super_like: types.maybe(types.boolean), 19 | person: Person, 20 | 21 | get lastActivityDate(): string { 22 | return this.last_activity_date 23 | }, 24 | get lastMessage(): LastMessageType { 25 | if (this.messages.length === 0) { 26 | return null 27 | } else { 28 | const message = this.messages[this.messages.length - 1] 29 | return message as MessageType 30 | } 31 | } 32 | }, 33 | { 34 | addMessage( 35 | message: any, 36 | previousMessage: LastMessageType 37 | ): MessageType { 38 | let newMessage: MessageType 39 | if (previousMessage === null) { 40 | newMessage = Message.create(message) 41 | } else { 42 | newMessage = Message.create({ 43 | ...message, 44 | previous: previousMessage._id 45 | }) 46 | previousMessage.setNext(newMessage) 47 | } 48 | this.messages.push(newMessage) 49 | return newMessage 50 | }, 51 | update(match: IRawMatch) { 52 | this.last_activity_date = match.last_activity_date 53 | match.messages.forEach(message => { 54 | const newMessage = this.addMessage(message, this.lastMessage) 55 | if (newMessage.from === this.person._id) { 56 | getEnv(this).notifier.notify({ 57 | title: this.person.name, 58 | body: newMessage.message 59 | }) 60 | } 61 | }) 62 | } 63 | } 64 | ) 65 | -------------------------------------------------------------------------------- /src/app/stores/State/Message.ts: -------------------------------------------------------------------------------- 1 | import { types, destroy } from 'mobx-state-tree' 2 | import { SUCCESS } from '~/shared/constants' 3 | import { isGIPHY, emojify } from '.' 4 | import * as format from 'date-fns/format' 5 | 6 | // Have to do it manually because TS is not smart enough for recursive types 7 | export interface IMessage { 8 | _id: string 9 | from: string 10 | to: string 11 | sent_date: string 12 | message: string 13 | status: string 14 | previous: IMessage | null 15 | next: IMessage | null 16 | 17 | isGIPHY: boolean 18 | formattedMessage: string 19 | sentDay: string 20 | sentTime: string 21 | sentDate: string 22 | first: boolean 23 | firstInNewDay: boolean 24 | 25 | changeStatus(status: string): void 26 | setPrevious(previous: IMessage | null): void 27 | setNext(next: IMessage | null): void 28 | destroy(): void 29 | } 30 | 31 | export const Message: any = types.model( 32 | 'Message', 33 | { 34 | _id: types.identifier(types.string), 35 | from: types.string, 36 | to: types.string, 37 | sent_date: types.optional(types.string, () => new Date().toISOString()), 38 | message: types.string, 39 | status: types.optional(types.string, SUCCESS), 40 | previous: types.maybe( 41 | types.reference(types.late(() => Message)) 42 | ), 43 | next: types.maybe( 44 | types.reference(types.late(() => Message)) 45 | ), 46 | 47 | get isGIPHY() { 48 | return isGIPHY(this.message) 49 | }, 50 | get formattedMessage() { 51 | if (!this.isGIPHY) { 52 | return emojify(this.message) 53 | } else { 54 | return this.message 55 | } 56 | }, 57 | get sentDay() { 58 | return format(this.sent_date, 'MMMM D') 59 | }, 60 | get sentTime() { 61 | return format(this.sent_date, 'H:mm') 62 | }, 63 | get sentDate() { 64 | return this.sent_date 65 | }, 66 | get first() { 67 | if (this.firstInNewDay) { 68 | return true 69 | } else { 70 | if (this.previous!.from !== this.from) { 71 | return true 72 | } else { 73 | return false 74 | } 75 | } 76 | }, 77 | get firstInNewDay() { 78 | if (this.previous === null) { 79 | return true 80 | } else { 81 | if (this.previous.sentDay !== this.sentDay) { 82 | return true 83 | } else { 84 | return false 85 | } 86 | } 87 | } 88 | }, 89 | { 90 | changeStatus(status: string) { 91 | this.status = status 92 | }, 93 | setPrevious(previous: IMessage) { 94 | this.previous = previous 95 | }, 96 | setNext(next: IMessage | null) { 97 | this.next = next 98 | }, 99 | destroy() { 100 | if (this.previous !== null) { 101 | this.previous.setNext(this.next) 102 | } 103 | if (this.next !== null) { 104 | this.next.setPrevious(this.previous) 105 | } 106 | destroy(this) 107 | } 108 | } 109 | ) 110 | -------------------------------------------------------------------------------- /src/app/stores/State/Person.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | import { Photo, Job, School, Interest, Connection, emojify } from '.' 3 | import { PhotoType } from '~/shared/definitions' 4 | 5 | interface IGalleryPhoto { 6 | original: string 7 | } 8 | 9 | export const Person = types.model( 10 | 'Person', 11 | { 12 | _id: types.identifier(types.string), 13 | birth_date: types.string, 14 | name: types.string, 15 | photos: types.array(Photo), 16 | bio: types.maybe(types.string), 17 | jobs: types.maybe(types.array(Job)), 18 | schools: types.maybe(types.array(School)), 19 | distance_mi: types.maybe(types.number), 20 | common_interests: types.maybe(types.array(Interest)), 21 | common_connections: types.maybe(types.array(Connection)), 22 | connection_count: types.maybe(types.number), 23 | 24 | get formattedName() { 25 | return emojify(this.name) 26 | }, 27 | get formattedBio() { 28 | if (this.bio === null) { 29 | return null 30 | } else { 31 | return emojify(this.bio) 32 | } 33 | }, 34 | get smallPhoto(): string { 35 | return this.photos[0].processedFiles[3].url 36 | }, 37 | get galleryPhotos(): IGalleryPhoto[] { 38 | return this.photos.map((photo: PhotoType) => ({ 39 | original: photo.processedFiles[0].url 40 | })) 41 | }, 42 | get distanceKm() { 43 | if (this.distance_mi !== null) { 44 | return Math.round(1.60934 * this.distance_mi) 45 | } else { 46 | return null 47 | } 48 | } 49 | }, 50 | { 51 | update(person: any) { 52 | Object.assign(this, person) 53 | } 54 | } 55 | ) 56 | -------------------------------------------------------------------------------- /src/app/stores/State/Photo.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | import { ProcessedFile } from '.' 3 | 4 | export const Photo = types.model('Photo', { 5 | id: types.identifier(types.string), 6 | url: types.string, 7 | processedFiles: types.array(ProcessedFile) 8 | }) 9 | -------------------------------------------------------------------------------- /src/app/stores/State/ProcessedFile.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | 3 | export const ProcessedFile = types.model('ProcessedFile', { 4 | width: types.number, 5 | url: types.string, 6 | height: types.number 7 | }) 8 | -------------------------------------------------------------------------------- /src/app/stores/State/Profile.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | 3 | export const Profile = types.model('Profile', {}) 4 | -------------------------------------------------------------------------------- /src/app/stores/State/School.ts: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree' 2 | 3 | export const School = types.model('School', { 4 | name: types.string, 5 | id: types.maybe(types.string) 6 | }) 7 | -------------------------------------------------------------------------------- /src/app/stores/State/State.ts: -------------------------------------------------------------------------------- 1 | import { types, getEnv, destroy } from 'mobx-state-tree' 2 | import { Match, Defaults, Message } from '.' 3 | import { Notifier } from '../Notifier' 4 | import { MatchType, MessageType } from '~/shared/definitions' 5 | import * as Raven from 'raven-js' 6 | import { FAILURE } from '~/shared/constants' 7 | 8 | interface IMatchRaw { 9 | _id: string 10 | messages: any[] 11 | last_activity_date: string 12 | } 13 | 14 | export interface IUpdate { 15 | matches: IMatchRaw[] 16 | blocks: string[] 17 | } 18 | 19 | interface ISorter { 20 | lastActivityDate: string 21 | } 22 | 23 | function sorter(a: ISorter, b: ISorter) { 24 | return Date.parse(b.lastActivityDate) - Date.parse(a.lastActivityDate) 25 | } 26 | 27 | export const State = types.model( 28 | 'State', 29 | { 30 | matches: types.map(Match), 31 | defaults: types.maybe(Defaults), 32 | get sortedMatches(): MatchType[] { 33 | return [...this.matches.values()].sort(sorter) 34 | }, 35 | pendingMessages: types.map(types.reference(Message)), 36 | sentMessages: types.map(types.reference(Message)) 37 | }, 38 | { 39 | setDefaults(defaults: any) { 40 | this.defaults = Defaults.create(defaults) 41 | }, 42 | mergeUpdates(updates: IUpdate, silent: boolean) { 43 | const { notifier } = getEnv(this) as { notifier: Notifier } 44 | 45 | this.sentMessages.values().forEach((sentMessage: MessageType) => { 46 | this.sentMessages.delete(sentMessage._id) 47 | sentMessage.destroy() 48 | }) 49 | 50 | updates.matches.forEach(match => { 51 | const oldMatch = this.matches.get(match._id) 52 | 53 | if (!oldMatch) { 54 | const { messages } = match 55 | try { 56 | const newMatch = Match.create({ 57 | ...match, 58 | messages: [] 59 | }) 60 | 61 | messages.forEach(message => { 62 | const newMessage = newMatch.addMessage( 63 | message, 64 | newMatch.lastMessage 65 | ) 66 | if ( 67 | !silent && 68 | newMessage.from === newMatch.person._id 69 | ) { 70 | notifier.notify({ 71 | title: newMatch.person.name, 72 | body: message.message 73 | }) 74 | } 75 | }) 76 | 77 | this.matches.set(newMatch._id, newMatch) 78 | if (!silent) { 79 | notifier.notify({ 80 | title: newMatch.person.name, 81 | body: 'You have a new match!' 82 | }) 83 | } 84 | } catch (err) { 85 | Raven.captureException(err) 86 | } 87 | } else { 88 | oldMatch.update(match) 89 | } 90 | }) 91 | 92 | const stop = '\uD83D\uDEAB' 93 | 94 | updates.blocks.forEach(id => { 95 | const blocked = this.matches.get(id) 96 | 97 | if (blocked) { 98 | if (!silent) { 99 | notifier.notify({ 100 | title: blocked.person.name, 101 | body: `${stop} BLOCKED ${stop}` 102 | }) 103 | } 104 | destroy(blocked) 105 | } 106 | }) 107 | }, 108 | addMessageToPending(message: MessageType) { 109 | this.pendingMessages.put(message) 110 | }, 111 | addMessageToSent(message: MessageType) { 112 | this.pendingMessages.delete(message._id) 113 | this.sentMessages.put(message) 114 | }, 115 | markAllPendingAsFailed() { 116 | this.pendingMessages.values().forEach((message: MessageType) => { 117 | message.changeStatus(FAILURE) 118 | }) 119 | } 120 | } 121 | ) 122 | -------------------------------------------------------------------------------- /src/app/stores/State/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils' 2 | 3 | export { Message, IMessage } from './Message' 4 | export { ProcessedFile } from './ProcessedFile' 5 | export { Photo } from './Photo' 6 | export { Job } from './Job' 7 | export { School } from './School' 8 | export { Interest } from './Interest' 9 | export { Connection } from './Connection' 10 | export { Person } from './Person' 11 | export { Globals } from './Globals' 12 | export { Defaults } from './Defaults' 13 | export { Match } from './Match' 14 | 15 | export { State } from './State' 16 | -------------------------------------------------------------------------------- /src/app/stores/State/utils.ts: -------------------------------------------------------------------------------- 1 | import emojione from '~/app/shims/emojione' 2 | import * as he from 'he' 3 | 4 | export function emojify(text: string) { 5 | return emojione.unicodeToImage(he.escape(text)) 6 | } 7 | 8 | export function isGIPHY(message: string) { 9 | return /^https?:\/\/(.*)giphy.com/.test(message) 10 | } 11 | -------------------------------------------------------------------------------- /src/app/stores/Storage/Storage.ts: -------------------------------------------------------------------------------- 1 | import { AbstractStorage } from '~/shared/definitions' 2 | 3 | export class Storage implements AbstractStorage { 4 | async save(key: string, value: any) { 5 | localStorage.setItem(key, JSON.stringify(value)) 6 | } 7 | 8 | async get(key: string) { 9 | const rawData = localStorage.getItem(key) 10 | let data: Object = {} 11 | 12 | if (rawData !== null) { 13 | data = JSON.parse(rawData) 14 | } 15 | 16 | return data 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/stores/Storage/index.ts: -------------------------------------------------------------------------------- 1 | export { Storage } from './Storage' 2 | -------------------------------------------------------------------------------- /src/app/stores/Time/Time.ts: -------------------------------------------------------------------------------- 1 | import * as utils from 'mobx-utils' 2 | import { computed } from 'mobx' 3 | 4 | export class Time { 5 | @computed 6 | get now() { 7 | return utils.now(5000) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/stores/Time/index.ts: -------------------------------------------------------------------------------- 1 | export { Time } from './Time' 2 | -------------------------------------------------------------------------------- /src/app/stores/TinderAPI/TinderAPI.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractTinderAPI, 3 | AbstractTinderAPIParams 4 | } from '~/shared/definitions' 5 | import TinderClient from 'tinder-modern' 6 | 7 | export class TinderAPI extends AbstractTinderAPIParams 8 | implements AbstractTinderAPI { 9 | client: TinderClient 10 | subscriptionInterval: number | null = null 11 | subscriptionPromise: Promise | null = null 12 | authPromise: Promise | null = null 13 | authPromiseExternalResolve: ((arg: true) => void) | null = null 14 | 15 | constructor(params: AbstractTinderAPIParams) { 16 | super() 17 | Object.assign(this, params) 18 | this.resetClient() 19 | } 20 | 21 | resetClient = () => { 22 | this.client = new TinderClient({ 23 | lastActivityDate: this.lastActivityDate 24 | }) 25 | } 26 | 27 | setLastActivityTimestamp = async (lastActivityDate: Date) => { 28 | this.lastActivityDate = lastActivityDate 29 | await this.storage.save('tinder', { 30 | lastActivityTimestamp: lastActivityDate.getTime() 31 | }) 32 | } 33 | 34 | isAuthorized = async () => { 35 | try { 36 | await this.getProfile() 37 | return true 38 | } catch (err) { 39 | return false 40 | } 41 | } 42 | 43 | getDefaults = (): any => { 44 | return this.client.getDefaults() 45 | } 46 | 47 | getPerson = (id: string): Promise => { 48 | return this.client.getUser({ userId: id }).then(res => res.results) 49 | } 50 | 51 | getProfile = (): Promise => { 52 | return this.client.getAccount() 53 | } 54 | 55 | sendMessage = async (id: string, message: string): Promise => { 56 | return this.client.sendMessage({ 57 | matchId: id, 58 | message 59 | }) 60 | } 61 | 62 | authorize = async ({ 63 | fbToken, 64 | fbId 65 | }: { 66 | fbToken: string 67 | fbId: string 68 | }) => { 69 | await this.client.authorize({ fbToken, fbId }) 70 | } 71 | 72 | getHistory = (): Promise => { 73 | return this.client.getHistory() 74 | } 75 | 76 | getUpdates = async (): Promise => { 77 | const updates = await this.client.getUpdates() 78 | await this.setLastActivityTimestamp(this.client.lastActivity) 79 | 80 | return updates 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/app/stores/TinderAPI/index.ts: -------------------------------------------------------------------------------- 1 | export { TinderAPI } from './TinderAPI' 2 | -------------------------------------------------------------------------------- /src/app/stores/configureStores.ts: -------------------------------------------------------------------------------- 1 | import { Navigator } from './Navigator' 2 | import { Time } from './Time' 3 | import { Caches } from './Caches' 4 | import { Notifier } from './Notifier' 5 | import { API } from './API' 6 | import { TinderAPI } from './TinderAPI' 7 | import { Storage } from './Storage' 8 | import { State } from './State' 9 | import { FB } from './FB' 10 | import { MST_SNAPSHOT } from '~/shared/constants' 11 | import { AbstractTinderAPISaved, AbstractFBSaved } from '~/shared/definitions' 12 | import { onSnapshot } from 'mobx-state-tree' 13 | 14 | async function getMSTSnapshot(storage: Storage) { 15 | const snapshot = (await storage.get(MST_SNAPSHOT)) as any 16 | if (snapshot.matches == null) { 17 | snapshot.matches = {} 18 | } 19 | if (snapshot.pendingMessages == null) { 20 | snapshot.pendingMessages = {} 21 | } 22 | if (snapshot.sentMessages == null) { 23 | snapshot.sentMessages = {} 24 | } 25 | 26 | return snapshot 27 | } 28 | 29 | async function getTinderSnapshot(storage: Storage) { 30 | const { lastActivityTimestamp } = (await storage.get( 31 | 'tinder' 32 | )) as AbstractTinderAPISaved 33 | let lastActivityDate: Date 34 | if (lastActivityTimestamp != null) { 35 | lastActivityDate = new Date(lastActivityTimestamp) 36 | } else { 37 | lastActivityDate = new Date() 38 | } 39 | 40 | return { lastActivityDate } 41 | } 42 | 43 | function getFBSnapshot(storage: Storage): Promise { 44 | return storage.get('fb') 45 | } 46 | 47 | export async function configureStores() { 48 | const storage = new Storage() 49 | const time = new Time() 50 | const navigator = new Navigator() 51 | const caches = new Caches() 52 | const notifier = new Notifier() 53 | 54 | const snapshot = await getMSTSnapshot(storage) 55 | const state = State.create(snapshot, { notifier }) 56 | state.markAllPendingAsFailed() 57 | onSnapshot(state, snapshot => { 58 | storage.save(MST_SNAPSHOT, snapshot) 59 | }) 60 | 61 | const tinderProps = await getTinderSnapshot(storage) 62 | const tinder = new TinderAPI({ storage, ...tinderProps }) 63 | 64 | const fbProps = await getFBSnapshot(storage) 65 | const fb = new FB({ storage, ...fbProps }) 66 | 67 | const api = new API({ state, tinder, fb }) 68 | await navigator.start({ api }) 69 | 70 | return { navigator, time, caches, api, state } 71 | } 72 | -------------------------------------------------------------------------------- /src/app/stores/index.ts: -------------------------------------------------------------------------------- 1 | export { configureStores } from './configureStores' 2 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { ravenSetupRenderer } from './app/ravenSetupRenderer' 2 | ravenSetupRenderer() 3 | import './app/index' 4 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Chatinder 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/AppManager.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAppManager } from '~/shared/definitions' 2 | import createWindowFactory from './createWindowFactory' 3 | import installExtensions from './installExtensions' 4 | import logoutFactory from './logoutFactory' 5 | import reloadFactory from './reloadFactory' 6 | import showFactory from './showFactory' 7 | import startFactory from './startFactory' 8 | 9 | export class AppManager extends AbstractAppManager 10 | implements AbstractAppManager { 11 | _window: Electron.BrowserWindow | null = null 12 | 13 | get window() { 14 | return this._window 15 | } 16 | 17 | start = startFactory(this) 18 | reload = reloadFactory(this) 19 | createWindow = createWindowFactory(this) 20 | show = showFactory(this) 21 | logout = logoutFactory(this) 22 | installExtensions = installExtensions 23 | 24 | constructor() { 25 | super() 26 | if (process.platform === 'darwin') { 27 | this.forceQuit = false 28 | } else { 29 | this.forceQuit = true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/createWindowFactory.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAppManager } from '~/shared/definitions' 2 | import { BrowserWindow } from 'electron' 3 | import { resolveRoot } from '~/shared/utils' 4 | 5 | export default function createWindowFactory(instance: AbstractAppManager) { 6 | return function createWindow() { 7 | instance._window = new BrowserWindow({ 8 | show: false, 9 | width: 1024, 10 | height: 728, 11 | minWidth: 660, 12 | minHeight: 340, 13 | webPreferences: { 14 | nodeIntegration: true, 15 | blinkFeatures: 16 | 'CSSScrollSnapPoints,CSSSnapSize,ScrollAnchoring,CSSOMSmoothScroll', 17 | experimentalFeatures: true 18 | }, 19 | icon: `${resolveRoot()}/icons/icon.png` 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/index.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | export { AppManager } from './AppManager' 3 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/installExtensions.ts: -------------------------------------------------------------------------------- 1 | export default async function installExtensions() { 2 | const installer = require('electron-devtools-installer') 3 | 4 | const extensions = [ 5 | 'REACT_DEVELOPER_TOOLS', 6 | 'jdkknkkbebbapilgoeccciglkfbmbnfm' // apollo-devtools 7 | ] 8 | const forceDownload = !!process.env.UPGRADE_EXTENSIONS 9 | if (forceDownload) { 10 | for (const name of extensions) { 11 | try { 12 | const extension = installer[name] || name 13 | await installer.default(extension, forceDownload) 14 | } catch (e) {} 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/logoutFactory.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAppManager } from '~/shared/definitions' 2 | import { fromCallback } from '~/shared/utils' 3 | 4 | export default function logoutFactory(instance: AbstractAppManager) { 5 | return async function logout() { 6 | if (instance._window !== null) { 7 | const { session } = instance._window.webContents 8 | await fromCallback(callback => 9 | session.clearCache(() => callback(null)) 10 | ) 11 | 12 | await fromCallback(callback => 13 | session.clearStorageData({}, callback) 14 | ) 15 | 16 | instance._window.destroy() 17 | instance._window = null 18 | } else { 19 | throw new Error('Window was undefined when trying to log out') 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/reloadFactory.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAppManager } from '~/shared/definitions' 2 | import { resolveRoot } from '~/shared/utils' 3 | 4 | export default function reloadFactory(instance: AbstractAppManager) { 5 | return function reload() { 6 | const url = `file://${resolveRoot()}/dist/index.html` 7 | if (instance.window !== null) { 8 | instance.window.loadURL(url) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/showFactory.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAppManager } from '~/shared/definitions' 2 | 3 | export default function showFactory(instance: AbstractAppManager) { 4 | return function show() { 5 | if (instance.window !== null) { 6 | instance.window.show() 7 | instance.window.focus() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/startFactory.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAppManager } from '~/shared/definitions' 2 | import { app } from 'electron' 3 | import { updateApp, buildMenu } from './utils' 4 | 5 | export function onBeforeQuitFactory(instance: AbstractAppManager) { 6 | return function onBeforeQuit() { 7 | instance.forceQuit = true 8 | } 9 | } 10 | 11 | export function onCloseFactory(instance: AbstractAppManager) { 12 | return function onClose(event: Event) { 13 | if (!instance.forceQuit) { 14 | event.preventDefault() 15 | if (instance.window != null) { 16 | instance.window.hide() 17 | } 18 | } 19 | } 20 | } 21 | 22 | export function onActivateFactory(instance: AbstractAppManager) { 23 | return function onActivate() { 24 | if (instance.window != null) { 25 | instance.window.restore() 26 | } 27 | } 28 | } 29 | 30 | export default function startFactory(instance: AbstractAppManager) { 31 | return async function start() { 32 | if (!app.isReady()) { 33 | await new Promise(resolve => app.on('ready', resolve)) 34 | } 35 | 36 | if (process.env.NODE_ENV === 'development') { 37 | await instance.installExtensions() 38 | require('electron-debug')({ showDevTools: true }) 39 | require('electron-context-menu')() 40 | } 41 | 42 | instance.createWindow() 43 | buildMenu() 44 | 45 | const { platform, env } = process 46 | const isWinOrMac = platform === 'win32' || platform === 'darwin' 47 | if ( 48 | isWinOrMac && 49 | env.NODE_ENV !== 'development' && 50 | instance.window !== null 51 | ) { 52 | instance.window.webContents.once('did-frame-finish-load', () => { 53 | updateApp(instance) 54 | }) 55 | } 56 | 57 | app.on('before-quit', onBeforeQuitFactory(instance)) 58 | if (instance.window != null) { 59 | instance.window.on('close', onCloseFactory(instance)) 60 | } 61 | app.on('activate', onActivateFactory(instance)) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/utils/buildMenu.ts: -------------------------------------------------------------------------------- 1 | import { app, Menu } from 'electron' 2 | import openAboutWindow from 'about-window' 3 | import { resolveRoot } from '~/shared/utils' 4 | 5 | export function buildMenu() { 6 | const template = [ 7 | { 8 | label: app.getName(), 9 | submenu: [ 10 | { 11 | label: 'About', 12 | click: () => { 13 | openAboutWindow({ 14 | icon_path: `${resolveRoot()}/icons/icon.png`, 15 | package_json_dir: resolveRoot(), 16 | bug_report_url: 17 | 'https://github.com/wasd171/chatinder/issues', 18 | homepage: 'https://github.com/wasd171/chatinder', 19 | use_inner_html: true, 20 | adjust_window_size: true, 21 | copyright: ` 22 |

23 | Created by Konstantin Nesterov (wasd171) 24 |
25 | Application logo by Liubov Ruseeva 26 |
27 | Emoji icons supplied by EmojiOne 28 |
29 | Distributed under MIT license 30 |

31 | ` 32 | }) 33 | } 34 | }, 35 | { 36 | type: 'separator' as 'separator' 37 | }, 38 | { 39 | role: 'quit' as 'quit' 40 | } 41 | ] 42 | }, 43 | { 44 | label: 'Edit', 45 | submenu: [ 46 | { 47 | role: 'undo' as 'undo' 48 | }, 49 | { 50 | role: 'redo' as 'redo' 51 | }, 52 | { 53 | type: 'separator' as 'separator' 54 | }, 55 | { 56 | role: 'cut' as 'cut' 57 | }, 58 | { 59 | role: 'copy' as 'copy' 60 | }, 61 | { 62 | role: 'paste' as 'paste' 63 | } 64 | ] 65 | } 66 | ] 67 | 68 | Menu.setApplicationMenu(Menu.buildFromTemplate(template)) 69 | } 70 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { updateApp } from './updateApp' 2 | export { buildMenu } from './buildMenu' 3 | -------------------------------------------------------------------------------- /src/main/ServerAPI/AppManager/utils/updateApp.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAppManager } from '~/shared/definitions' 2 | import * as os from 'os' 3 | import { app, autoUpdater, dialog } from 'electron' 4 | 5 | const version = app.getVersion() 6 | const platform = `${os.platform()}_${os.arch()}` 7 | const updateURL = `https://chatinder.herokuapp.com/update/${platform}/${version}` 8 | 9 | export function updateApp(instance: AbstractAppManager) { 10 | autoUpdater.setFeedURL(updateURL) 11 | 12 | // Ask the user if update is available 13 | autoUpdater.on('update-downloaded', (_event, releaseNotes, releaseName) => { 14 | let message = `${app.getName()} ${releaseName} is now available. It will be installed the next time you restart the application.` 15 | if (releaseNotes) { 16 | const splitNotes = releaseNotes.split(/[^\r]\n/) 17 | message += '\n\nRelease notes:\n' 18 | splitNotes.forEach(notes => { 19 | message += notes + '\n\n' 20 | }) 21 | } 22 | // Ask user to update the app 23 | dialog.showMessageBox( 24 | { 25 | type: 'question', 26 | buttons: ['Install and Relaunch', 'Later'], 27 | defaultId: 0, 28 | message: `A new version of ${app.getName()} has been downloaded`, 29 | detail: message 30 | }, 31 | response => { 32 | if (response === 0) { 33 | instance.forceQuit = true 34 | setTimeout(() => autoUpdater.quitAndInstall(), 1) 35 | } 36 | } 37 | ) 38 | }) 39 | // init for updates 40 | autoUpdater.checkForUpdates() 41 | } 42 | -------------------------------------------------------------------------------- /src/main/ServerAPI/ServerAPI.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractServerAPI, 3 | AbstractAppManager, 4 | GetFBTokenType 5 | } from '~/shared/definitions' 6 | import { ipcMain, app } from 'electron' 7 | import { 8 | IPC_GET_FB_TOKEN_REQ, 9 | IPC_GET_FB_TOKEN_RES, 10 | IPC_SHOW_WINDOW, 11 | IPC_LOGOUT 12 | } from '~/shared/constants' 13 | import getToken from './getToken' 14 | import { AppManager } from './AppManager' 15 | 16 | export class ServerAPI implements AbstractServerAPI { 17 | private app: AbstractAppManager = new AppManager() 18 | 19 | public start = async () => { 20 | ipcMain.on(IPC_GET_FB_TOKEN_REQ, this.getFBToken) 21 | ipcMain.on(IPC_SHOW_WINDOW, this.showWindow) 22 | ipcMain.on(IPC_LOGOUT, this.logout) 23 | await this.app.start() 24 | this.app.reload() 25 | } 26 | 27 | private async getFBToken(event: Electron.IpcMessageEvent, silent: boolean) { 28 | let res: GetFBTokenType 29 | try { 30 | res = await getToken(silent) 31 | } catch (err) { 32 | res = { err } 33 | } 34 | event.sender.send(IPC_GET_FB_TOKEN_RES, res) 35 | } 36 | 37 | private showWindow = () => { 38 | this.app.show() 39 | } 40 | 41 | private logout = async () => { 42 | await this.app.logout() 43 | app.relaunch() 44 | app.exit(0) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/ServerAPI/getToken.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | import { URL } from 'url' 3 | import { FBGetTokenType } from '~/shared/definitions' 4 | 5 | export type WindowType = Electron.BrowserWindow | null 6 | 7 | export default function getToken(silent: boolean): Promise { 8 | return new Promise((resolve, reject) => { 9 | let userAgent = 10 | 'Mozilla/5.0 (Linux; U; en-gb; KFTHWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.16 Safari/535.19' 11 | 12 | let authUrl = `` 13 | authUrl += `https://www.facebook.com/v2.6/dialog/oauth?redirect_uri=fb464891386855067://authorize/&` 14 | authUrl += `state={"challenge":"q1WMwhvSfbWHvd8xz5PT6lk6eoA%3D","com.facebook.sdk_client_state":true,` 15 | authUrl += `"3_method":"sfvc_auth"}&scope=user_birthday,user_photos,user_education_history,email,` 16 | authUrl += `user_relationship_details,user_friends,user_work_history,user_likes&response_type=token,` 17 | authUrl += `signed_request&default_audience=friends&return_scopes=true&auth_type=rerequest&` 18 | authUrl += `client_id=464891386855067&ret=login&sdk=ios` 19 | 20 | let win: WindowType = new BrowserWindow({ 21 | width: 640, 22 | height: 640, 23 | show: !silent, 24 | webPreferences: { 25 | nodeIntegration: false 26 | } 27 | }) 28 | 29 | if (win != null) { 30 | win.on('closed', () => { 31 | if (!silent) { 32 | reject() 33 | } 34 | win = null 35 | }) 36 | 37 | win.webContents.on('will-navigate', (_e, url) => { 38 | const raw = /access_token=([^&]*)/.exec(url) || null 39 | const token = raw && raw.length > 1 ? raw[1] : null 40 | const error = /\?error=(.+)$/.exec(url) 41 | 42 | if (!error) { 43 | if (token) { 44 | const expiresStringRegex = /expires_in=(.*)/.exec(url) 45 | let expiresIn 46 | if ( 47 | expiresStringRegex !== null && 48 | expiresStringRegex.length >= 2 49 | ) { 50 | expiresIn = parseInt(expiresStringRegex[1]) 51 | } else { 52 | reject( 53 | new Error( 54 | 'Unable to retrieve expiration date from Facebook' 55 | ) 56 | ) 57 | return 58 | } 59 | // Way to handle Electron bug https://github.com/electron/electron/issues/4374 60 | setImmediate(() => { 61 | if (win !== null) { 62 | win.close() 63 | } 64 | }) 65 | resolve({ token, expiresIn }) 66 | } 67 | } else { 68 | reject(error) 69 | } 70 | }) 71 | 72 | win.webContents.on('did-finish-load', async () => { 73 | if (win !== null) { 74 | let form, action 75 | 76 | const script = `document.getElementById('platformDialogForm')` 77 | form = await asyncExecute(win, script) 78 | if (typeof form !== 'undefined') { 79 | action = await asyncExecute(win, `${script}.action`) 80 | try { 81 | const url = new URL(action) 82 | action = `${url.origin}${url.pathname}` 83 | } catch (err) { 84 | action = null 85 | } 86 | } 87 | 88 | if ( 89 | action === 90 | 'https://m.facebook.com/v2.6/dialog/oauth/confirm' 91 | ) { 92 | asyncExecute(win, `${script}.submit()`) 93 | } else { 94 | if (silent) { 95 | reject() 96 | win = null 97 | } 98 | } 99 | } 100 | }) 101 | 102 | win.webContents.on('did-fail-load', () => { 103 | if (silent) { 104 | reject() 105 | win = null 106 | } 107 | 108 | if (win !== null) { 109 | win.loadURL(authUrl, { userAgent: userAgent }) 110 | } 111 | }) 112 | 113 | win.loadURL(authUrl, { userAgent: userAgent }) 114 | } 115 | }) 116 | } 117 | 118 | export function asyncExecute(win: WindowType, script: string) { 119 | if (win !== null) { 120 | return win.webContents.executeJavaScript(script, false) 121 | } else { 122 | return Promise.resolve() 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/ServerAPI/index.ts: -------------------------------------------------------------------------------- 1 | export { ServerAPI } from './ServerAPI' 2 | -------------------------------------------------------------------------------- /src/main/ravenSetupMain.ts: -------------------------------------------------------------------------------- 1 | import * as Raven from 'raven' 2 | 3 | export function ravenSetupMain() { 4 | Raven.config( 5 | 'https://da10ea27ad724a2bbd826e378e1c389b:00489345c287416fad67161c62002221@sentry.io/183877' 6 | ).install() 7 | } 8 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | if (require('electron-squirrel-startup')) { 3 | app.quit() 4 | } 5 | import { ravenSetupMain } from './main/ravenSetupMain' 6 | ravenSetupMain() 7 | 8 | import { ServerAPI } from './main/ServerAPI' 9 | const api = new ServerAPI() 10 | api.start() 11 | // async function main() { 12 | // // const params = await ServerAPI.getInitialProps() 13 | // const api = new ServerAPI() 14 | // api.start() 15 | // } 16 | 17 | // main() 18 | -------------------------------------------------------------------------------- /src/shared/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const GRAPHQL = 'GRAPHQL' 2 | export const GRAPHQL_SUBSCRIPTIONS = 'GRAPHQL_SUBSCRIPTIONS' //TODO: implement proper subscriptions 3 | 4 | export const SUBSCRIPTION_MATCHES_ALL = 'SUBSCRIPTION_MATCHES_ALL' 5 | export const SUBSCRIPTION_MATCH = 'SUBSCRIPTION_MATCH' 6 | export const SUBSCRIPTION_MATCH_BLOCKED = 'SUBSCRIPTION_MATCH_BLOCKED' 7 | 8 | export { 9 | VIEW_MATCHES, 10 | VIEW_CHAT, 11 | VIEW_USER, 12 | VIEW_PROFILE, 13 | VIEW_AUTH, 14 | VIEW_LOADING 15 | } from './view' 16 | export { routes } from './routes' 17 | 18 | export const SUCCESS = 'SUCCESS' 19 | export const PENDING = 'PENDING' 20 | export const FAILURE = 'FAILURE' 21 | export const PSEUDO = 'PSEUDO' 22 | 23 | export const success = { 24 | status: 'OK' 25 | } 26 | 27 | export const NOTIFICATION = 'NOTIFICATION' 28 | export const KEYCODE_ESC = 27 29 | 30 | export const MST_SNAPSHOT = 'MST_SNAPSHOT' 31 | export * from './ipc' 32 | -------------------------------------------------------------------------------- /src/shared/constants/ipc.ts: -------------------------------------------------------------------------------- 1 | export const IPC_SHOW_WINDOW = 'IPC_SHOW_WINDOW' 2 | export const IPC_LOGOUT = 'IPC_LOGOUT' 3 | export const IPC_GET_FB_TOKEN_REQ = 'IPC_GET_FB_TOKEN_REQ' 4 | export const IPC_GET_FB_TOKEN_RES = 'IPC_GET_FB_TOKEN_RES' 5 | -------------------------------------------------------------------------------- /src/shared/constants/routes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VIEW_MATCHES, 3 | VIEW_CHAT, 4 | VIEW_USER, 5 | VIEW_PROFILE, 6 | VIEW_AUTH, 7 | VIEW_LOADING 8 | } from './view' 9 | import { nameToPath } from '~/shared/utils' 10 | 11 | export const routes = [ 12 | VIEW_MATCHES, 13 | VIEW_CHAT, 14 | VIEW_USER, 15 | VIEW_PROFILE, 16 | VIEW_AUTH, 17 | VIEW_LOADING 18 | ].reduce((obj, name) => { 19 | obj[name] = nameToPath(name) 20 | return obj 21 | }, {}) 22 | -------------------------------------------------------------------------------- /src/shared/constants/view.ts: -------------------------------------------------------------------------------- 1 | export const VIEW_MATCHES = 'VIEW_MATCHES' 2 | export const VIEW_CHAT = 'VIEW_CHAT' 3 | export const VIEW_USER = 'VIEW_USER' 4 | export const VIEW_PROFILE = 'VIEW_PROFILE' 5 | export const VIEW_AUTH = 'VIEW_AUTH' 6 | export const VIEW_LOADING = 'VIEW_LOADING' 7 | -------------------------------------------------------------------------------- /src/shared/definitions/AbstractAPI.ts: -------------------------------------------------------------------------------- 1 | // import { ShowWindowMutation } from '~/schema' 2 | import { PersonType } from '.' 3 | 4 | export interface IAPIGenericReturn { 5 | status: string 6 | } 7 | 8 | export interface IAPISendMessage { 9 | matchId: string 10 | message: string 11 | } 12 | 13 | export abstract class AbstractAPI { 14 | public abstract login(silent: boolean): Promise 15 | public abstract checkDoMatchesExist(): Promise 16 | public abstract subscribeToUpdates(): Promise 17 | public abstract logout(): void 18 | public abstract getInitialRoute(): Promise 19 | public abstract showWindow(): void 20 | public abstract relogin(): Promise 21 | public abstract updateProfile(): Promise 22 | public abstract updatePerson(person: PersonType): Promise 23 | public abstract sendMessage(props: IAPISendMessage): Promise 24 | public abstract resendMessage(messageId: string): Promise 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/definitions/AbstractAppManager.ts: -------------------------------------------------------------------------------- 1 | export abstract class AbstractAppManager { 2 | _window: Electron.BrowserWindow | null 3 | forceQuit: boolean 4 | abstract get window(): Electron.BrowserWindow | null 5 | 6 | abstract start: () => Promise 7 | abstract reload: () => void 8 | abstract createWindow: () => void 9 | abstract show: () => void 10 | abstract logout: () => Promise 11 | abstract installExtensions: () => Promise 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/definitions/AbstractFB.ts: -------------------------------------------------------------------------------- 1 | import { FBGetTokenType, AbstractStorage } from '.' 2 | 3 | export abstract class AbstractFBSaved { 4 | token?: string 5 | expiresAt?: number 6 | id?: string 7 | } 8 | 9 | export abstract class AbstractFBParams extends AbstractFBSaved { 10 | storage: AbstractStorage 11 | } 12 | 13 | export abstract class AbstractFB extends AbstractFBParams { 14 | abstract save: () => Promise<{}> 15 | abstract setToken: (token: string) => Promise<{}> 16 | abstract setExpiration: (expiresAt: number) => Promise<{}> 17 | abstract setId: (id: string) => Promise<{}> 18 | 19 | abstract getId: () => Promise 20 | abstract getToken: (silent: boolean) => Promise 21 | abstract loginForce: (silent: boolean) => Promise 22 | abstract login: (silent: boolean) => Promise 23 | abstract clear: () => Promise<{}> 24 | } 25 | -------------------------------------------------------------------------------- /src/shared/definitions/AbstractServerAPI.ts: -------------------------------------------------------------------------------- 1 | export interface IGetFBTokenFailure { 2 | err: Error 3 | } 4 | 5 | export interface IGetFBTokenSuccess { 6 | token: string 7 | expiresIn: number 8 | } 9 | 10 | export type GetFBTokenType = IGetFBTokenFailure | IGetFBTokenSuccess 11 | 12 | export abstract class AbstractServerAPI { 13 | abstract start: () => Promise 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/definitions/AbstractStorage.ts: -------------------------------------------------------------------------------- 1 | export abstract class AbstractStorage { 2 | abstract save(key: string, value: any): Promise 3 | abstract get(key: string): Promise 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/definitions/AbstractTinderAPI.ts: -------------------------------------------------------------------------------- 1 | import { AbstractStorage } from '.' 2 | import TinderClient from 'tinder-modern' 3 | 4 | export abstract class AbstractTinderAPISaved { 5 | lastActivityTimestamp?: number 6 | } 7 | 8 | export abstract class AbstractTinderAPIParams { 9 | lastActivityDate: Date 10 | storage: AbstractStorage 11 | } 12 | 13 | export abstract class AbstractTinderAPI extends AbstractTinderAPIParams { 14 | client: TinderClient 15 | subscriptionInterval: number | null 16 | subscriptionPromise: Promise | null 17 | authPromise: Promise | null 18 | authPromiseExternalResolve: ((arg: true) => void) | null 19 | 20 | abstract resetClient(): void 21 | abstract setLastActivityTimestamp(lastActivityDate: Date): Promise 22 | abstract isAuthorized: () => Promise 23 | abstract getDefaults: () => any 24 | abstract getPerson: (id: string) => Promise 25 | abstract getProfile: () => Promise 26 | abstract sendMessage: (id: string, message: string) => Promise 27 | abstract authorize: ( 28 | { fbToken, fbId }: { fbToken: string; fbId: string } 29 | ) => Promise 30 | abstract getHistory: () => Promise 31 | abstract getUpdates: () => Promise 32 | } 33 | -------------------------------------------------------------------------------- /src/shared/definitions/FBGetTokenType.ts: -------------------------------------------------------------------------------- 1 | export type FBGetTokenType = { 2 | token: string 3 | expiresIn: number 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/definitions/IGraphQLElectronMessage.ts: -------------------------------------------------------------------------------- 1 | import { PrintedRequest } from 'apollo-client/transport/networkInterface' 2 | 3 | export interface IGraphQLElectronMessage { 4 | id: string 5 | payload: PrintedRequest 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/definitions/NotificationMessageType.ts: -------------------------------------------------------------------------------- 1 | export type NotificationMessageType = { 2 | title: string 3 | body: string 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/definitions/State.ts: -------------------------------------------------------------------------------- 1 | import { 2 | State, 3 | Match, 4 | IMessage, 5 | Photo, 6 | Interest, 7 | Person 8 | } from '~/app/stores/State' 9 | 10 | export type StateType = typeof State.Type 11 | export type MatchType = typeof Match.Type 12 | export type MessageType = IMessage 13 | export type PhotoType = typeof Photo.Type 14 | export type InterestType = typeof Interest.Type 15 | export type PersonType = typeof Person.Type 16 | -------------------------------------------------------------------------------- /src/shared/definitions/index.ts: -------------------------------------------------------------------------------- 1 | export { NotificationMessageType } from './NotificationMessageType' 2 | export { FBGetTokenType } from './FBGetTokenType' 3 | 4 | export { IGraphQLElectronMessage } from './IGraphQLElectronMessage' 5 | 6 | export * from './AbstractServerAPI' 7 | export * from './AbstractAppManager' 8 | export * from './AbstractFB' 9 | export * from './AbstractTinderAPI' 10 | export * from './AbstractAPI' 11 | export * from './AbstractStorage' 12 | export * from './State' 13 | -------------------------------------------------------------------------------- /src/shared/utils/fromCallback.ts: -------------------------------------------------------------------------------- 1 | type ErrorType = Error | undefined | null 2 | export type IfromCallbackInnerCallback = (err: ErrorType, result?: any) => any 3 | export type IfromCallbackParams = (done: IfromCallbackInnerCallback) => any 4 | 5 | export function fromCallback(func: IfromCallbackParams) { 6 | return new Promise((resolve, reject) => { 7 | func((err, result) => { 8 | if (err != null) { 9 | reject(err) 10 | } else { 11 | resolve(result) 12 | } 13 | }) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url' 2 | 3 | export function getNormalizedSizeOfGIPHY(message: string) { 4 | const maxHeight = 170 5 | const maxWidth = 255 6 | const url = new URL(message) 7 | 8 | const strHeight = url.searchParams.get('height') 9 | const strWidth = url.searchParams.get('width') 10 | 11 | if (strHeight === null || strWidth === null) { 12 | throw new Error( 13 | `Unable to get width and/or height for the following giphy: ${message}` 14 | ) 15 | } 16 | 17 | let height: number, width: number 18 | height = parseInt(strHeight, 10) 19 | width = parseInt(strWidth, 10) 20 | 21 | if (height > maxHeight) { 22 | width = width * (maxHeight / height) 23 | height = maxHeight 24 | } 25 | if (width > maxWidth) { 26 | height = height * (maxWidth / width) 27 | width = maxWidth 28 | } 29 | 30 | return { height, width } 31 | } 32 | 33 | export { nameToPath } from './nameToPath' 34 | export { resolveRoot } from './resolveRoot' 35 | export { fromCallback } from './fromCallback' 36 | -------------------------------------------------------------------------------- /src/shared/utils/nameToPath.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VIEW_CHAT, 3 | VIEW_USER, 4 | VIEW_LOADING, 5 | VIEW_MATCHES, 6 | VIEW_PROFILE 7 | } from '~/shared/constants/view' 8 | 9 | export function nameToPath(name: string, param?: string): string { 10 | switch (name) { 11 | case VIEW_CHAT: 12 | return `${nameToPath(VIEW_MATCHES)}/${param || ':id'}/${VIEW_CHAT}` 13 | case VIEW_USER: 14 | return `${nameToPath(VIEW_MATCHES)}/${param || ':id'}/${VIEW_USER}` 15 | case VIEW_LOADING: 16 | return `/${VIEW_LOADING}/${param || ':title'}` 17 | case VIEW_PROFILE: 18 | return `${nameToPath(VIEW_MATCHES)}/${VIEW_PROFILE}` 19 | default: 20 | return `/${name}` 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/utils/resolveRoot.ts: -------------------------------------------------------------------------------- 1 | export function resolveRoot(): string { 2 | const { path } = require('app-root-path') 3 | return path 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "removeComments": false, 5 | "preserveConstEnums": true, 6 | "sourceMap": true, 7 | "declaration": false, 8 | "noImplicitAny": true, 9 | "noImplicitReturns": true, 10 | "suppressImplicitAnyIndexErrors": true, 11 | "strictNullChecks": true, 12 | "noUnusedLocals": true, 13 | "noImplicitThis": true, 14 | "noUnusedParameters": true, 15 | "importHelpers": true, 16 | "noEmitHelpers": true, 17 | "module": "es6", 18 | "moduleResolution": "node", 19 | "pretty": true, 20 | "target": "ES2016", 21 | "jsx": "react", 22 | "experimentalDecorators": true, 23 | "paths": { 24 | "~/*": ["*"] 25 | } 26 | }, 27 | "formatCodeOptions": { 28 | "indentSize": 4, 29 | "tabSize": 4 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'is-reachable' { 2 | function isReachable(url: string): Promise 3 | export = isReachable 4 | } 5 | 6 | declare module '*.graphql' { 7 | import { DocumentNode, Location, DefinitionNode } from 'graphql' 8 | export const kind: 'Document' 9 | export const loc: Location | undefined 10 | export const definitions: Array 11 | } 12 | 13 | declare module 'emojione/package.json' { 14 | export const version: string 15 | } 16 | 17 | declare namespace NodeJS { 18 | export interface Global { 19 | jQuery: any 20 | emojione: any 21 | emojioneVersion: string 22 | Perf: any 23 | } 24 | } 25 | 26 | declare module 'react-image-gallery' { 27 | import * as React from 'react' 28 | 29 | export type ClickHandler = React.MouseEventHandler 30 | type CustomRenderer = ( 31 | onClick: ClickHandler, 32 | disabled: boolean 33 | ) => JSX.Element 34 | 35 | export interface Item { 36 | original: string 37 | } 38 | 39 | interface Props { 40 | items: Item[] 41 | showPlayButton: boolean 42 | showBullets: boolean 43 | showThumbnails: boolean 44 | showFullscreenButton: boolean 45 | infinite: boolean 46 | renderLeftNav: CustomRenderer 47 | renderRightNav: CustomRenderer 48 | } 49 | 50 | class ImageGallery extends React.Component {} 51 | export default ImageGallery 52 | } 53 | 54 | declare module 'material-ui/TextField/TextFieldHint' { 55 | import * as React from 'react' 56 | import { MuiTheme } from 'material-ui/styles' 57 | 58 | interface Props { 59 | muiTheme: MuiTheme 60 | show: boolean 61 | text: string 62 | } 63 | 64 | class TextFieldHint extends React.Component {} 65 | 66 | export default TextFieldHint 67 | } 68 | 69 | declare module 'material-ui/TextField/TextFieldUnderline' { 70 | import * as React from 'react' 71 | import { MuiTheme } from 'material-ui/styles' 72 | 73 | interface Props { 74 | muiTheme: MuiTheme 75 | disabled: boolean 76 | focus: boolean 77 | } 78 | 79 | class TextFieldUnderline extends React.Component {} 80 | 81 | export default TextFieldUnderline 82 | } 83 | 84 | declare module 'react-content-loader' { 85 | import * as React from 'react' 86 | 87 | interface ILoaderProps { 88 | style?: Object 89 | type?: string 90 | speed?: number 91 | width?: number 92 | height?: number 93 | primaryColor?: string 94 | secondaryColor?: string 95 | } 96 | 97 | export default class ContentLoader extends React.Component {} 98 | 99 | interface ICircleProps { 100 | x: number 101 | y: number 102 | radius: number 103 | } 104 | 105 | export class Circle extends React.Component {} 106 | 107 | interface IRectProps extends ICircleProps { 108 | width: number 109 | height: number 110 | } 111 | 112 | export class Rect extends React.Component {} 113 | } 114 | 115 | declare module 'electron-is-dev' { 116 | const isDev: boolean 117 | export default isDev 118 | } 119 | 120 | declare module 'app-root-path' { 121 | const path: string 122 | export = { path } 123 | } 124 | -------------------------------------------------------------------------------- /webpack/base.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | // const { TsConfigPathsPlugin } = require('awesome-typescript-loader') 4 | const BabiliPlugin = require('babili-webpack-plugin') 5 | const isDev = require('./isDev') 6 | const HappyPack = require('happypack') 7 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin') 8 | 9 | const commonPlugins = [ 10 | new webpack.DefinePlugin({ 11 | 'process.env.NODE_ENV': JSON.stringify( 12 | process.env.NODE_ENV || 'production' 13 | ) 14 | }), 15 | new HappyPack({ 16 | id: 'ts', 17 | threads: 2, 18 | loaders: [ 19 | { 20 | path: 'ts-loader', 21 | query: { happyPackMode: true } 22 | } 23 | ] 24 | }) 25 | ] 26 | const devPlugins = [new ForkTsCheckerWebpackPlugin()] 27 | const productionPlugins = [new BabiliPlugin()] 28 | const plugins = isDev 29 | ? [...commonPlugins, ...devPlugins] 30 | : [...productionPlugins, ...commonPlugins] 31 | 32 | module.exports = { 33 | output: { 34 | path: path.join(__dirname, '..', 'dist') 35 | }, 36 | 37 | // Enable sourcemaps for debugging webpack's output. 38 | devtool: isDev ? 'source-map' : undefined, 39 | 40 | resolve: { 41 | // Add '.ts' and '.tsx' as resolvable extensions. 42 | extensions: ['.ts', '.tsx', '.js', '.json'], 43 | // plugins: [new TsConfigPathsPlugin()] 44 | alias: { 45 | '~': path.resolve(__dirname, '..', 'src') 46 | } 47 | }, 48 | 49 | module: { 50 | rules: [ 51 | // All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'. 52 | { 53 | test: /\.tsx?$/, 54 | loader: 'happypack/loader?id=ts', 55 | exclude: /node_modules/ 56 | }, 57 | 58 | // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'. 59 | { 60 | enforce: 'pre', 61 | test: /\.js$/, 62 | loader: 'source-map-loader', 63 | exclude: [ 64 | new RegExp(`node_modules\\${path.sep}apollo-client`), 65 | new RegExp(`node_modules\\${path.sep}graphql-tools`), 66 | new RegExp(`node_modules\\${path.sep}deprecated-decorator`) 67 | ] 68 | }, 69 | 70 | // Handle .graphql 71 | { test: /\.graphql$/, loader: 'graphql-tag/loader' } 72 | ] 73 | }, 74 | plugins: plugins, 75 | watch: isDev, 76 | watchOptions: { 77 | ignored: /node_modules/ 78 | }, 79 | externals: (context, request, callback) => { 80 | if (/(about-window|app-root-path)/.test(request)) { 81 | callback(null, 'commonjs ' + request) 82 | } else { 83 | callback() 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /webpack/dll.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const base = require('./base') 3 | const merge = require('webpack-merge') 4 | const path = require('path') 5 | 6 | const dll = { 7 | entry: { 8 | vendor: [ 9 | 'date-fns', 10 | 'emojione', 11 | 'emojionearea', 12 | 'he', 13 | 'jquery', 14 | 'lodash.trim', 15 | 'material-ui', 16 | 'mobx', 17 | 'mobx-react', 18 | 'mobx-utils', 19 | 'mobx-state-tree', 20 | 'raven-js', 21 | 'react', 22 | 'react-dom', 23 | 'react-image-gallery', 24 | 'react-router', 25 | 'react-router-dom', 26 | 'react-tap-event-plugin', 27 | 'react-virtualized', 28 | 'react-waypoint', 29 | 'simplebar', 30 | 'styled-components', 31 | 'tinder-modern', 32 | 'is-reachable' 33 | ] 34 | }, 35 | output: { 36 | filename: '[name].js', 37 | libraryTarget: 'commonjs2' 38 | }, 39 | target: 'electron-renderer', 40 | plugins: [ 41 | new webpack.DllPlugin({ 42 | path: path.join(base.output.path, '[name]-manifest.json') 43 | }) 44 | ] 45 | } 46 | 47 | module.exports = merge(base, dll) 48 | -------------------------------------------------------------------------------- /webpack/isDev.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV } = process.env 2 | const isDev = NODE_ENV === 'development' 3 | module.exports = isDev 4 | -------------------------------------------------------------------------------- /webpack/main.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const base = require('./base') 3 | const merge = require('webpack-merge') 4 | const nodeExternals = require('webpack-node-externals') 5 | const { StatsWriterPlugin } = require('webpack-stats-plugin') 6 | const isDev = require('./isDev') 7 | const path = require('path') 8 | 9 | const devPlugins = [ 10 | new StatsWriterPlugin({ 11 | filename: 'stats-main.json', 12 | fields: null 13 | }) 14 | ] 15 | 16 | const plugins = isDev ? devPlugins : [] 17 | 18 | const main = { 19 | entry: './src/server.ts', 20 | output: { 21 | filename: 'main.js' 22 | }, 23 | target: 'electron', 24 | plugins 25 | } 26 | 27 | module.exports = merge(base, main) 28 | -------------------------------------------------------------------------------- /webpack/renderer.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const base = require('./base') 4 | const merge = require('webpack-merge') 5 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 6 | const CopyWebpackPlugin = require('copy-webpack-plugin') 7 | const { StatsWriterPlugin } = require('webpack-stats-plugin') 8 | const isDev = require('./isDev') 9 | 10 | const commonPlugins = [ 11 | new webpack.DllReferencePlugin({ 12 | context: '.', 13 | manifest: require(path.join(base.output.path, 'vendor-manifest.json')), 14 | sourceType: 'commonjs2', 15 | name: './vendor.js' 16 | }), 17 | new ExtractTextPlugin('styles.css'), 18 | new CopyWebpackPlugin([ 19 | { 20 | from: 'src/index.html', 21 | to: 'index.html' 22 | }, 23 | { 24 | from: 'node_modules/emojione/assets/png', 25 | to: 'emoji' 26 | } 27 | ]) 28 | ] 29 | const plugins = isDev 30 | ? [ 31 | ...commonPlugins, 32 | new StatsWriterPlugin({ 33 | filename: 'stats-renderer.json', 34 | fields: null 35 | }) 36 | ] 37 | : commonPlugins 38 | 39 | const renderer = { 40 | entry: './src/client.ts', 41 | output: { 42 | filename: 'renderer.js' 43 | }, 44 | target: 'electron-renderer', 45 | module: { 46 | rules: [ 47 | { 48 | test: /\.css$/, 49 | use: ExtractTextPlugin.extract({ 50 | use: ['css-loader', 'resolve-url-loader'] 51 | }) 52 | }, 53 | { 54 | test: /\.(woff2)(\?[a-z0-9=&.]+)?$/, 55 | loader: 'file-loader', 56 | options: { 57 | name: 'fonts/[name].[ext]?[hash]' 58 | } 59 | }, 60 | { 61 | test: /\.(ttf|eot|svg|woff)(\?[a-z0-9=&.]+)?$/, // Needs to be used with caution 62 | loader: 'skip-loader' 63 | } 64 | ] 65 | }, 66 | plugins 67 | } 68 | 69 | module.exports = merge(base, renderer) 70 | --------------------------------------------------------------------------------