├── .eslintrc ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .prettierrc ├── .slugignore ├── .travis.yml ├── client ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── assets │ └── air-plane-ding.mp3 │ ├── components │ ├── App.js │ ├── ChatHub.js │ ├── ChatNav.js │ ├── ChoicePicker.js │ ├── ChoiceSlider.js │ ├── Countdown.js │ ├── Header.js │ ├── InCallNavBar.js │ ├── MatchHistory.js │ ├── Meter.js │ ├── NumberSlider.js │ ├── PrefMatcher.js │ ├── ProfileCard.js │ ├── ReportUserDialog.js │ ├── SVGTester.js │ ├── Settings.js │ ├── TextChat.js │ ├── ToggleButton.js │ ├── ToggleButtonWithMeter.js │ ├── UserCreate.js │ ├── UserCreateForm.js │ ├── UserUpdateForm.js │ ├── VideoGrid.js │ ├── VideoPlayer.js │ ├── VideoWindow.js │ ├── common │ │ ├── Button.js │ │ ├── Dialog.js │ │ └── index.js │ └── stats │ │ ├── BarGraph.js │ │ ├── LineGraph.js │ │ └── StatsWindow.js │ ├── helpers │ ├── constants.js │ ├── htmlParse.js │ ├── htmlParse2.js │ ├── htmlParse3.js │ ├── socketHelper.js │ └── themes.js │ ├── hooks │ ├── EnabledWidgetsContext.js │ ├── LocalStreamContext.js │ ├── MyUserContext.js │ ├── NotifyContext.js │ ├── SocketContext.js │ ├── VideoPlayerContext.js │ └── WindowSizeHook.js │ ├── index.js │ └── queries │ ├── mutations.js │ └── queries.js ├── e2e ├── .eslintrc ├── cypress.json ├── cypress │ ├── fixtures │ │ └── example.json │ ├── integration │ │ ├── chat_hub_spec.ts │ │ ├── home_page_spec.ts │ │ ├── in_call_spec.ts │ │ └── matchmaking_spec.ts │ ├── plugins │ │ ├── index.js │ │ └── miss_am_qcif.y4m │ └── support │ │ ├── commands.ts │ │ ├── index.ts │ │ └── socketHelper.ts ├── package-lock.json ├── package.json ├── tsconfig.json └── webpack.config.js ├── package-lock.json ├── package.json └── server ├── package-lock.json ├── package.json ├── prisma ├── datamodel.graphql ├── docker-compose.yml ├── prisma.yml └── seed.js └── src ├── generated └── prisma.graphql ├── index.js ├── prisma.js ├── resolvers ├── Mutation.js ├── Query.js ├── Subscription.js ├── User.js └── index.js ├── schema.graphql ├── server.js ├── socket.js ├── utils ├── generateToken.js └── getUserId.js └── views └── construction.ejs /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "airbnb", 5 | "prettier", 6 | "prettier/react", 7 | "plugin:prettier/recommended", 8 | "plugin:import/errors" 9 | ], 10 | "plugins": [ 11 | "prettier", 12 | "jsx-a11y", 13 | "react", 14 | "react-hooks", 15 | "import" 16 | ], 17 | "ignorePatterns": [ 18 | "build/", 19 | "node_modules/" 20 | ], 21 | "rules": { 22 | "react/jsx-filename-extension": [ 23 | 1, 24 | { 25 | "extensions": [ 26 | ".js", 27 | ".jsx" 28 | ] 29 | } 30 | ], 31 | "import/order": [ 32 | "error", 33 | { 34 | "groups": [ 35 | "builtin", 36 | "external", 37 | "parent", 38 | "sibling", 39 | "index" 40 | ] 41 | } 42 | ], 43 | "import/imports-first": [ 44 | "error", 45 | "absolute-first" 46 | ], 47 | "import/newline-after-import": "error", 48 | "react/jsx-sort-props": [ 49 | "error", 50 | { 51 | "callbacksLast": true, 52 | "shorthandFirst": true, 53 | "reservedFirst": true 54 | } 55 | ], 56 | "prettier/prettier": "error", 57 | "react/prop-types": 0, 58 | "no-underscore-dangle": 0, 59 | "consistent-return": "off", 60 | "no-bitwise": 0, 61 | "no-console": "off", 62 | "no-restricted-syntax": "off", 63 | "no-undef": "off", 64 | "jsx-a11y/media-has-caption": "off", 65 | "jsx-a11y/label-has-for": [ 66 | 2, 67 | { 68 | "components": [ 69 | "Label" 70 | ], 71 | "required": { 72 | "some": [ 73 | "nesting", 74 | "id" 75 | ] 76 | }, 77 | "allowChildren": false 78 | } 79 | ], 80 | "react/no-array-index-key": "off", 81 | "react-hooks/rules-of-hooks": "error", 82 | "react-hooks/exhaustive-deps": "error" 83 | }, 84 | "parser": "babel-eslint" 85 | } -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 13 * * 4' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | # Override language selection by uncommenting this and choosing your languages 34 | # with: 35 | # languages: go, javascript, csharp, python, cpp, java 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v1 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | *.mp4 10 | 11 | # production 12 | build 13 | dist 14 | 15 | # config 16 | /server/config 17 | *.env 18 | *.env.* 19 | 20 | # misc 21 | .DS_Store 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | *.xmind 32 | /.vscode 33 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "parser": "flow", 9 | "printWidth": 120, 10 | "proseWrap": "preserve", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": true, 15 | "tabWidth": 2, 16 | "trailingComma": "all", 17 | "useTabs": false 18 | } -------------------------------------------------------------------------------- /.slugignore: -------------------------------------------------------------------------------- 1 | e2e/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | addons: 5 | apt: 6 | packages: 7 | # Ubuntu 16+ does not install this dependency by defaul, so we need to install it ourselves 8 | - libgconf-2-4 9 | cache: 10 | # Caches $HOME/.npm when npm ci is default script command 11 | # Caches node_modules in all other cases 12 | npm: true 13 | directories: 14 | # we also need to cache folder with Cypress binary 15 | - .cache 16 | - node_modules 17 | env: 18 | - DOMAIN_FULL=http://127.0.0.1 CLIENT_PORT=3000 SOCKET_PORT=4000 GQL_PORT=4000 19 | install: 20 | - npm run heroku-postbuild 21 | - npm ci 22 | before_script: 23 | - npm run start:server & 24 | - npm run start:client & 25 | script: 26 | - cd e2e 27 | - npm i 28 | - npm run cypress:run --record -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat2gether-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/client": "3.0.0-rc.6", 7 | "@material-ui/core": "^4.11.0", 8 | "@material-ui/styles": "^4.10.0", 9 | "@sentry/browser": "^5.20.1", 10 | "apollo-boost": "^0.4.9", 11 | "axios": "^0.19.2", 12 | "moment": "^2.27.0", 13 | "react": "^16.13.1", 14 | "react-dom": "^16.13.1", 15 | "react-scripts": "3.4.1", 16 | "socket.io-client": "^2.3.0", 17 | "styled-components": "^5.1.1", 18 | "whatwg-fetch": "^3.2.0" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ], 32 | "proxy": "http://127.0.0.1:4000", 33 | "devDependencies": {}, 34 | "optionalDependencies": { 35 | "graphql": "^14.6.0", 36 | "graphql-tag": "^2.10.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baconcheese113/chat2gether/77f681b41a02deef4bfbf5a954ce4b849e966d77/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 44 | Live Chat 45 | 46 | 47 | 48 | 49 |
50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /client/src/assets/air-plane-ding.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baconcheese113/chat2gether/77f681b41a02deef4bfbf5a954ce4b849e966d77/client/src/assets/air-plane-ding.mp3 -------------------------------------------------------------------------------- /client/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { useApolloClient } from '@apollo/client' 4 | import { LocalStreamProvider } from '../hooks/LocalStreamContext' 5 | import { EnabledWidgetsProvider } from '../hooks/EnabledWidgetsContext' 6 | import { NotifyProvider } from '../hooks/NotifyContext' 7 | import SocketProvider from '../hooks/SocketContext' 8 | import { GET_ME } from '../queries/queries' 9 | import MyUserProvider from '../hooks/MyUserContext' 10 | import useWindowSize from '../hooks/WindowSizeHook' 11 | import ChatHub from './ChatHub' 12 | import UserCreate from './UserCreate' 13 | import Header from './Header' 14 | 15 | /** 16 | * App just handles passing to UserCreate, and passing to ChatHub 17 | * UserCreate handles registering User or logging in with existing token 18 | * ChatHub handles finding room, sharing audio/video, socket connectivity, settings 19 | * TextChat handles rendering chat and socket transmissions 20 | * VideoWindow handles rendering streams (local and remote) 21 | */ 22 | 23 | const StyledApp = styled.div` 24 | height: ${p => p.height}px; 25 | display: flex; 26 | ` 27 | 28 | export default function App() { 29 | const [user, setUser] = React.useState(null) 30 | const [canRender, setCanRender] = React.useState(false) 31 | 32 | const client = useApolloClient() 33 | const { innerHeight } = useWindowSize() 34 | 35 | React.useEffect(() => { 36 | const fetchData = async () => { 37 | const { data } = await client.query({ query: GET_ME, errorPolicy: 'all' }) 38 | if (data && data.me) { 39 | setUser(data.me) 40 | } 41 | setCanRender(true) 42 | } 43 | fetchData() 44 | }, [client]) 45 | 46 | if (canRender) { 47 | if (user) { 48 | return ( 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ) 63 | } 64 | return ( 65 | <> 66 |
67 | 68 | 69 | ) 70 | } 71 | return '' 72 | } 73 | -------------------------------------------------------------------------------- /client/src/components/ChatHub.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { useLocalStream } from '../hooks/LocalStreamContext' 4 | import { useEnabledWidgets } from '../hooks/EnabledWidgetsContext' 5 | import { useSocket } from '../hooks/SocketContext' 6 | import { useMyUser } from '../hooks/MyUserContext' 7 | import useWindowSize from '../hooks/WindowSizeHook' 8 | import { VideoPlayerProvider } from '../hooks/VideoPlayerContext' 9 | import VideoWindow from './VideoWindow' 10 | import TextChat from './TextChat' 11 | import Settings from './Settings' 12 | import InCallNavBar from './InCallNavBar' 13 | import VideoPlayer from './VideoPlayer' 14 | import UserUpdateForm from './UserUpdateForm' 15 | import Countdown from './Countdown' 16 | import ProfileCard from './ProfileCard' 17 | import MatchHistory from './MatchHistory' 18 | import LineGraph from './stats/LineGraph' 19 | import ChatNav from './ChatNav' 20 | import { Button } from './common' 21 | 22 | const StyledChatHub = styled.div` 23 | display: flex; 24 | flex: 1; 25 | flex-direction: ${p => p.flowDirection}; 26 | justify-content: center; 27 | overflow: hidden; 28 | ` 29 | const ConnectingText = styled.div` 30 | padding: 0 1rem; 31 | white-space: pre-wrap; 32 | height: ${p => p.height}; 33 | display: flex; 34 | align-items: center; 35 | ` 36 | const PageContainer = styled.div` 37 | display: flex; 38 | flex: 1; 39 | flex-direction: column; 40 | justify-content: space-evenly; 41 | align-items: center; 42 | font-size: 2rem; 43 | height: 100%; 44 | ` 45 | const CountdownSpan = styled.span` 46 | width: 100%; 47 | ` 48 | // When user presses Share Video, request camera 49 | // When user presses Next Match, Initialize socket and Find Room 50 | // When connection is established, alert user to countdown 51 | // Start Call 52 | // On connection end or Find Next -> Find Room() 53 | 54 | export default function ChatHub() { 55 | const { user } = useMyUser() 56 | const { localStream, requestDevices, determineEfficiencyMode } = useLocalStream() 57 | const { enabledWidgets } = useEnabledWidgets() 58 | const { socketHelper, connectionMsg, remoteStream, roomId, otherUser, matchCountdown, endCall } = useSocket() 59 | const { flowDirection } = useWindowSize() 60 | 61 | const logWindowError = e => console.error(e) 62 | React.useEffect(() => { 63 | window.addEventListener('error', logWindowError) 64 | return () => { 65 | window.removeEventListener('error', logWindowError) 66 | } 67 | }, []) 68 | 69 | const onBeforeUnload = React.useCallback( 70 | e => { 71 | if (!otherUser) return null 72 | e.returnValue = 'Are you sure you want to end your call?' 73 | return 'Are you sure you want to end your call?' 74 | }, 75 | [otherUser], 76 | ) 77 | 78 | const onUnload = React.useCallback(() => { 79 | console.log('unloading with ', endCall) 80 | endCall('REFRESH') 81 | }, [endCall]) 82 | 83 | React.useEffect(() => { 84 | window.addEventListener('beforeunload', onBeforeUnload) 85 | window.addEventListener('unload', onUnload) 86 | window.addEventListener('pagehide', onUnload) 87 | return () => { 88 | window.removeEventListener('beforeunload', onBeforeUnload) 89 | window.removeEventListener('unload', onUnload) 90 | window.removeEventListener('pagehide', onUnload) 91 | } 92 | }, [onBeforeUnload, onUnload]) 93 | 94 | React.useEffect(() => { 95 | determineEfficiencyMode(!!otherUser) 96 | }, [determineEfficiencyMode, otherUser]) 97 | 98 | const renderBackground = () => { 99 | if (remoteStream) { 100 | return ( 101 | <> 102 | 103 | 104 | 105 | 106 | 107 | 108 | 111 | 112 | 113 | ) 114 | } 115 | if (!localStream) { 116 | return ( 117 | 118 | 206 | 207 | 208 | 209 | ) 210 | })} 211 | 212 | 213 | 214 | 215 | 216 | 217 | ) 218 | } 219 | -------------------------------------------------------------------------------- /client/src/components/Meter.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | 4 | const rotate = keyframes` 5 | from{transform: rotate(0)} 6 | to{transform: rotate(360deg)} 7 | ` 8 | 9 | const MeterContainer = styled.div` 10 | position: fixed; 11 | top: 0; 12 | right: 5%; 13 | bottom: 0; 14 | width: 3rem; 15 | z-index: 100; 16 | display: flex; 17 | flex-direction: column; 18 | justify-content: center; 19 | align-items: center; 20 | ` 21 | const Bar = styled.div` 22 | background-color: ${p => p.theme.colorGreyLight2}; 23 | border-radius: 500rem; 24 | min-height: 80%; 25 | width: 1rem; 26 | border-radius: 500rem; 27 | position: relative; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | ` 32 | 33 | const Center = styled.div.attrs(({ speed }) => ({ 34 | style: { 35 | animationDuration: `${speed}s`, 36 | }, 37 | }))` 38 | background-color: burlywood; 39 | background-image: linear-gradient(to bottom right, ${p => p.theme.colorPrimaryLight}, ${p => p.theme.colorPrimary}); 40 | border-radius: 50% 0 50% 0; 41 | width: 3rem; 42 | height: 3rem; 43 | position: absolute; 44 | animation-name: ${rotate}; 45 | animation-timing-function: linear; 46 | animation-iteration-count: infinite; 47 | ` 48 | 49 | const MeterLabels = styled.p` 50 | font-size: 1.5rem; 51 | background-color: ${p => p.theme.colorGreyDark1}; 52 | border-radius: 500rem; 53 | border: 1px dashed ${p => p.theme.colorPrimary}; 54 | padding: 0.5rem 1rem; 55 | z-index: -1; 56 | ` 57 | 58 | const BarFill = styled.div.attrs(({ isNewVal }) => ({ 59 | style: { 60 | boxShadow: `0 0 1rem ${isNewVal ? '#fff' : 'transparent'}`, 61 | }, 62 | }))` 63 | position: absolute; 64 | background-color: orchid; 65 | background-image: linear-gradient(to bottom right, ${p => p.theme.colorPrimaryLight}, ${p => p.theme.colorPrimary}); 66 | border-radius: 500rem; 67 | top: ${p => p.top || 0}%; 68 | bottom: ${p => p.bottom || 0}%; 69 | left: 0; 70 | right: 0; 71 | ` 72 | 73 | const Slider = styled.button.attrs(p => ({ 74 | style: { 75 | bottom: `calc(${p.bottom}% - .3rem)`, 76 | }, 77 | }))` 78 | background-color: ${p => p.theme.colorPrimary}; 79 | background-image: linear-gradient(to bottom right, ${p => p.theme.colorPrimaryLight}, ${p => p.theme.colorPrimary}); 80 | position: absolute; 81 | width: 3rem; 82 | height: 2rem; 83 | padding: 0; 84 | cursor: pointer; 85 | border-radius: 500rem; 86 | z-index: 10; 87 | display: flex; 88 | justify-content: center; 89 | transform: ${p => (p.active ? 'scale(1.8)' : 'scale(1)')}; 90 | transition: transform 0.4s; 91 | box-shadow: 0 0 1rem #111; 92 | 93 | & i { 94 | font-size: 1.6rem; 95 | color: ${p => p.theme.colorGreyDark2}; 96 | 97 | &:hover { 98 | text-shadow: none; 99 | } 100 | } 101 | ` 102 | 103 | const BracketText = styled.p.attrs(p => ({ 104 | style: { 105 | transform: `scale(${p.scale})`, 106 | opacity: p.opacity, 107 | }, 108 | }))` 109 | position: absolute; 110 | bottom: ${p => p.bottom}%; 111 | top: ${p => p.top}%; 112 | right: 400%; 113 | font-size: 2rem; 114 | text-shadow: 0 0 0.5rem #000; 115 | text-anchor: start; 116 | white-space: nowrap; 117 | transform-origin: right; 118 | transition: transform 0.4s, opacity 1.2s; 119 | ` 120 | 121 | export default function Meter() { 122 | // const { numbers, change } = props 123 | const [box, setBox] = React.useState(null) // set to mainMeter's bounding box 124 | const [isSliding, setIsSliding] = React.useState(false) 125 | 126 | // Move to props 127 | const [number, setNumber] = React.useState(10) 128 | const [otherNum, setOtherNum] = React.useState(20) 129 | const [isNewVal, setIsNewVal] = React.useState(false) 130 | 131 | const mainMeter = React.useRef() 132 | 133 | const pixelToNumber = y => { 134 | const halfLength = (box.bottom - box.top) / 2 135 | const halfY = halfLength + box.top 136 | const percent = Math.min(Math.max(1 - (y - halfY) / halfLength, 0), 1) 137 | return percent * 100 138 | } 139 | 140 | const startUpdating = e => { 141 | console.log('starting', e) 142 | setIsSliding(true) 143 | } 144 | const stopUpdating = () => { 145 | console.log('stopped') 146 | setIsSliding(false) 147 | } 148 | const followUpdate = e => { 149 | // e.preventDefault() // Not working due to breaking changes in chrome...need to make sure page isn't scrollable 150 | if (!isSliding) { 151 | return 152 | } 153 | let intendedNum = null 154 | if (e.targetTouches) { 155 | intendedNum = pixelToNumber(e.targetTouches[0].clientY) 156 | } else if (e.clientX) { 157 | intendedNum = pixelToNumber(e.clientY) 158 | } else return 159 | // console.log( 160 | // `${e.targetTouches[0].clientY} out of ${box.top} to ${box.bottom} making it ${intendedNum} for ${number}`, 161 | // ) 162 | setNumber(intendedNum) 163 | } 164 | 165 | const changeOther = newVal => { 166 | setTimeout(() => { 167 | setIsNewVal(false) 168 | }, 2000) 169 | setOtherNum(newVal) 170 | setIsNewVal(true) 171 | } 172 | 173 | useEffect(() => { 174 | setBox(mainMeter.current.getBoundingClientRect()) 175 | setInterval(() => { 176 | changeOther(Math.floor(Math.random() * 10) * 10) 177 | }, 4000) 178 | }, []) 179 | 180 | const getSliderDistNormal = (sliderVal, bracketVal) => { 181 | return 1 - Math.min(Math.max(Math.abs(sliderVal - bracketVal) / 40, 0), 1) 182 | } 183 | 184 | const bracketAttrs = [...new Array(5)].map((_, idx) => { 185 | const bottom = idx * 10 186 | const normal = getSliderDistNormal(number, bottom * 2) 187 | return { 188 | scale: normal, 189 | opacity: isSliding ? normal : 0, 190 | bottom, 191 | } 192 | }) 193 | 194 | const otherAttrs = [...new Array(5)].map((_, idx) => { 195 | const top = idx * 10 196 | const normal = getSliderDistNormal(otherNum, top * 2) 197 | return { 198 | scale: normal, 199 | opacity: isNewVal ? normal : 0, 200 | top, 201 | } 202 | }) 203 | 204 | return ( 205 | 206 | Them 207 | 208 |
0 && !isSliding ? 20 / number : 2} /> 209 | 210 | 211 | 212 | Oh, we're starting? 213 | 214 | 215 | I think I like you! 216 | 217 | 218 | This is great! 219 | 220 | 221 | Oh wow, we should be friends! 222 | 223 | 224 | BFF's Forever! 225 | 226 | 227 | 228 | Oh, we're starting? 229 | 230 | 231 | I think I like you! 232 | 233 | 234 | This is great! 235 | 236 | 237 | Oh wow, we should be friends! 238 | 239 | 240 | BFF's Forever! 241 | 242 | startUpdating(e)} 246 | onMouseMove={followUpdate} 247 | onMouseUp={e => stopUpdating(e)} 248 | onTouchEnd={e => stopUpdating(e)} 249 | onTouchMove={followUpdate} 250 | onTouchStart={e => startUpdating(e)} 251 | > 252 | 253 | 254 | 255 | You 256 | 257 | ) 258 | } 259 | -------------------------------------------------------------------------------- /client/src/components/NumberSlider.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Slider from '@material-ui/core/Slider' 4 | 5 | const StyledNumberSlider = styled.div` 6 | width: ${p => p.width || '90'}%; 7 | margin: 3rem auto 1rem; 8 | position: relative; 9 | display: flex; 10 | align-items: center; 11 | ` 12 | 13 | const StyledSlider = styled(Slider)` 14 | color: ${p => p.theme.colorPrimary1}; 15 | 16 | & .MuiSlider-rail { 17 | color: ${p => p.theme.colorWhite1}; 18 | } 19 | 20 | & .slider-label { 21 | font-size: 2rem; 22 | } 23 | ` 24 | 25 | export default function NumberSlider(props) { 26 | const { numbers, onChange, 'data-cy': dataCy } = props 27 | const MIN_AGE = 18 28 | const MAX_AGE = 90 29 | 30 | const handleSliderChange = (e, newValue) => { 31 | onChange(newValue) 32 | } 33 | 34 | return ( 35 | 36 | `${val} years`} 42 | max={MAX_AGE} 43 | min={MIN_AGE} 44 | value={numbers} 45 | valueLabelDisplay="on" 46 | onChange={handleSliderChange} 47 | /> 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /client/src/components/PrefMatcher.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { AUDIO_PREFS } from '../helpers/constants' 4 | 5 | const StyledPrefMatcher = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | flex: 1; 9 | justify-content: flex-start; 10 | align-items: center; 11 | ` 12 | const Container = styled.div` 13 | display: flex; 14 | justify-content: center; 15 | margin-top: 16px; 16 | ` 17 | const MatcherTitle = styled.p` 18 | font-size: 16px; 19 | ` 20 | const Column = styled.div` 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | margin: 8px; 25 | transition: all 0.4s; 26 | ` 27 | const ColumnTitle = styled.h3` 28 | font-size: 14px; 29 | ` 30 | const ColumnSubtitle = styled.h4` 31 | font-size: 12px; 32 | ` 33 | const Chip = styled.div` 34 | display: flex; 35 | justify-content: center; 36 | position: relative; 37 | background-color: ${p => p.theme.colorPrimary}; 38 | filter: ${p => p.grayedOut && 'grayscale(.9)'}; 39 | width: 160px; 40 | max-width: calc((100vw - 8px) * 0.4); 41 | opacity: ${p => p.hiden && 0}; 42 | border: ${p => (p.removed ? 0 : 3)}px dashed ${p => (p.selected ? '#eee' : 'transparent')}; 43 | border-radius: 20px; 44 | padding: ${p => !p.removed && '4px 6px'}; 45 | margin: ${p => !p.removed && '4px'}; 46 | transition: 0.6s opacity, 0.4s border, 0.4s padding, 0.4s margin; 47 | ` 48 | const ChipTitle = styled.span` 49 | font-size: 14px; 50 | ` 51 | const DrawLine = styled.div` 52 | width: ${p => (p.extend ? 32 : 0)}px; 53 | opacity: ${p => (p.extend ? 1 : 0)}; 54 | position: absolute; 55 | top: 50%; 56 | left: 100%; 57 | border: 2px dashed #eee; 58 | transition: 1s width, 0.5s opacity; 59 | ` 60 | 61 | export default function PrefMatcher(props) { 62 | const { myPref, myAccPrefs, myAge, myGender, theirPref, theirAccPrefs, theirAge, theirGender } = props 63 | const [startExtend, setStartExtend] = React.useState(false) 64 | const [startHide, setStartHide] = React.useState(false) 65 | const [startRemove, setStartRemove] = React.useState(false) 66 | // Since prefs are sorted correctly, this should work 67 | const matchPref = AUDIO_PREFS.find(pref => pref === myPref || pref === theirPref) 68 | 69 | // Lets not discuss this 70 | React.useEffect(() => { 71 | const timeoutArr = [] 72 | timeoutArr.push( 73 | setTimeout(() => { 74 | setStartExtend(true) 75 | timeoutArr.push( 76 | setTimeout(() => { 77 | setStartHide(true) 78 | timeoutArr.push( 79 | setTimeout(() => { 80 | setStartRemove(true) 81 | }, 1000), 82 | ) 83 | }, 1000), 84 | ) 85 | }, 1000), 86 | ) 87 | return () => { 88 | timeoutArr.forEach(t => clearTimeout(t)) 89 | } 90 | }, []) 91 | 92 | const getPrefs = (chosenPref, canExtend, accPrefs) => { 93 | return AUDIO_PREFS.map(pref => { 94 | const matchedPref = pref === matchPref 95 | return ( 96 | 104 | {pref.replace(/_/g, ' ')} 105 | {matchedPref && canExtend && } 106 | 107 | ) 108 | }) 109 | } 110 | 111 | return ( 112 | 113 | Audio Preference Decider 114 | 115 | 116 | Me 117 | 118 | {myAge} | {myGender} 119 | 120 | {getPrefs(myPref, true, myAccPrefs)} 121 | 122 | 123 | Them 124 | 125 | {theirAge} | {theirGender} 126 | 127 | {getPrefs(theirPref, false, theirAccPrefs)} 128 | 129 | 130 | 131 | ) 132 | } 133 | -------------------------------------------------------------------------------- /client/src/components/ProfileCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { useEnabledWidgets } from '../hooks/EnabledWidgetsContext' 4 | import { useSocket } from '../hooks/SocketContext' 5 | 6 | const StyledProfileCard = styled.article` 7 | position: absolute; 8 | top: 50%; 9 | bottom: 0; 10 | left: 0; 11 | right: 0; 12 | overflow: hidden; 13 | pointer-events: none; 14 | ` 15 | 16 | const Card = styled.div` 17 | background-color: rgba(0, 0, 0, 0.7); 18 | width: 95%; 19 | max-width: 60rem; 20 | margin: 0 auto; 21 | border-radius: 5rem; 22 | border: 1px solid ${p => p.theme.colorPrimary}; 23 | display: flex; 24 | justify-content: center; 25 | position: relative; 26 | left: ${p => (p.active ? '0%' : '100%')}; 27 | transition: all 0.4s ease-out; 28 | ` 29 | const CardContent = styled.div` 30 | display: flex; 31 | flex-direction: column; 32 | margin: 2rem; 33 | ` 34 | const CardTitle = styled.h3` 35 | font-size: 2.4rem; 36 | margin: 0 auto; 37 | 38 | & > i { 39 | margin: 0 1rem; 40 | } 41 | ` 42 | const PillContainer = styled.div` 43 | display: flex; 44 | flex-wrap: wrap; 45 | margin-top: 1rem; 46 | justify-content: center; 47 | align-items: center; 48 | ` 49 | const Pill = styled.div` 50 | color: ${p => p.theme.colorPrimary}; 51 | background-color: ${p => p.theme.colorGreyDark1}; 52 | border-radius: 500rem; 53 | display: flex; 54 | justify-content: center; 55 | align-items: center; 56 | padding: 4px 1rem; 57 | font-size: 1.8rem; 58 | ` 59 | 60 | export default function ProfileCard() { 61 | const { otherUser } = useSocket() 62 | 63 | const { enabledWidgets } = useEnabledWidgets() 64 | const active = enabledWidgets.profile 65 | 66 | if (!otherUser || !otherUser.lookingFor) return '' 67 | return ( 68 | 69 | 70 | 71 | 72 | 73 | They Are 74 | 75 | 76 | 77 | 78 | 79 | {otherUser.gender} 80 | 81 | 82 | 83 | {otherUser.age} 84 | 85 | 86 | 87 | {otherUser.audioPref} 88 | 89 | 90 | 91 | 92 | They Like 93 | 94 | 95 | 96 | 97 | 98 | {otherUser.lookingFor.map(x => x.name).join(', ')} 99 | 100 | 101 | 102 | {`${otherUser.minAge}-${otherUser.maxAge}`} 103 | 104 | 105 | 106 | {otherUser.accAudioPrefs.map(x => x.name).join(', ')} 107 | 108 | 109 | 110 | 111 | 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /client/src/components/ReportUserDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { useApolloClient } from '@apollo/client' 4 | import { REPORT_TYPES } from '../helpers/constants' 5 | import { CREATE_REPORT } from '../queries/mutations' 6 | import { Dialog } from './common' 7 | import ChoiceSlider from './ChoiceSlider' 8 | 9 | const Subtitle = styled.h3` 10 | font-size: 16px; 11 | ` 12 | 13 | export default function ReportUserDialog(props) { 14 | const { open, onClose, offenderId } = props 15 | 16 | const [isLoading, setIsLoading] = React.useState(false) 17 | const [selectedTypeIdx, setSelectedTypeIdx] = React.useState(0) 18 | 19 | const client = useApolloClient() 20 | 21 | const handleConfirm = async () => { 22 | if (selectedTypeIdx === 0) return 23 | setIsLoading(true) 24 | const type = REPORT_TYPES[selectedTypeIdx].key 25 | await client.mutate({ mutation: CREATE_REPORT, variables: { data: { type, offenderId } } }) 26 | setIsLoading(false) 27 | setSelectedTypeIdx(0) 28 | onClose(true) 29 | } 30 | 31 | return ( 32 | onClose(false)} 39 | onConfirm={handleConfirm} 40 | > 41 | 42 | Only report if the misuse falls into one of the following categories. Select whichever best describes it. 43 | 44 | `${t.name} - ${t.desc}`)} 47 | cur={selectedTypeIdx} 48 | width="100%" 49 | onChange={idx => setSelectedTypeIdx(idx)} 50 | /> 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /client/src/components/SVGTester.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | 4 | const rotateChat = keyframes` 5 | from {transform: rotate(-180deg);} 6 | to {transform: rotate(180deg);} 7 | ` 8 | const rotateLoading = keyframes` 9 | from {transform: rotate(0deg);} 10 | to {transform: rotate(360deg);} 11 | ` 12 | const panWaves = keyframes` 13 | from {transform: translate(3052px, 790px) scale(1.7);} 14 | to {transform: translate(3080px, 790px) scale(1.7);} 15 | ` 16 | 17 | const StyledSVGTester = styled.div` 18 | position: relative; 19 | border-radius: 50%; 20 | overflow: hidden; 21 | display: inline-block; 22 | height: ${p => p.height || '100%'}; 23 | width: ${p => p.width || '100%'}; 24 | margin: 0 auto; 25 | text-align: center; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | 30 | svg { 31 | max-width: 500px; 32 | 33 | transform: scale(1.2); 34 | } 35 | #layer1 { 36 | fill: #9932cc; 37 | fill-opacity: 1; 38 | stroke-width: 3.08812022; 39 | } 40 | 41 | #chat { 42 | transform-origin: center; 43 | transform: rotate(-180deg); 44 | opacity: 0; 45 | &.rotate_text { 46 | opacity: 1; 47 | animation: ${rotateChat} 2s infinite cubic-bezier(0.12, 1.01, 0.82, 0); 48 | } 49 | } 50 | #loading { 51 | transform-origin: center; 52 | opacity: 0; 53 | &.rotate_text { 54 | opacity: 1; 55 | animation: ${rotateLoading} 2s infinite cubic-bezier(0.12, 1.01, 0.82, 0); 56 | } 57 | } 58 | #waves { 59 | transform-origin: center; 60 | animation: ${panWaves} 2s infinite cubic-bezier(0.51, 0.7, 0.61, 0.43); 61 | transform: translate(3050px, 790px) scale(1.7); 62 | transform-box: fill-box; 63 | } 64 | ` 65 | 66 | const ClippingCircle = styled.div` 67 | position: absolute; 68 | overflow: hidden; 69 | border-radius: 50%; 70 | top: 5%; 71 | bottom: 5%; 72 | left: 5%; 73 | right: 5%; 74 | display: flex; 75 | justify-content: center; 76 | align-items: center; 77 | box-shadow: 0 0 3rem #000; 78 | ` 79 | // #2a7fff 80 | 81 | export default function SVGTester(props) { 82 | const { height, width } = props 83 | const [isRotateChat, setIsRotateChat] = React.useState(false) 84 | 85 | const flipRotateChat = () => { 86 | setIsRotateChat(!isRotateChat) 87 | } 88 | 89 | React.useEffect(() => { 90 | const timer = setTimeout(() => { 91 | flipRotateChat() 92 | }, 2000) 93 | return () => clearTimeout(timer) 94 | }) 95 | 96 | return ( 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 115 | 116 | 124 | 125 | 131 | 132 | 133 | 139 | 140 | 141 | 147 | 148 | 149 | 150 | ) 151 | } 152 | -------------------------------------------------------------------------------- /client/src/components/Settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { useApolloClient } from '@apollo/client' 4 | import { CREATE_FEEDBACK } from '../queries/mutations' 5 | import { useEnabledWidgets } from '../hooks/EnabledWidgetsContext' 6 | import { useLocalStream } from '../hooks/LocalStreamContext' 7 | import SVGTester from './SVGTester' 8 | import { Button, Dialog } from './common' 9 | 10 | const StyledSettings = styled.div` 11 | position: absolute; 12 | height: 100%; 13 | width: 100%; 14 | ` 15 | const SettingsList = styled.div` 16 | display: flex; 17 | flex-direction: column; 18 | align-items: stretch; 19 | padding: 1.5rem; 20 | flex-grow: 1; 21 | font-size: 14px; 22 | ` 23 | const DeviceLabel = styled.label` 24 | justify-self: center; 25 | ` 26 | const DeviceSelect = styled.select` 27 | border-radius: 10px 10px 2px 2px; 28 | outline: none; 29 | border: 2px solid #3f3f3f; 30 | margin: 2px; 31 | margin-bottom: 16px; 32 | 33 | font: inherit; 34 | padding: 0; 35 | max-width: 100%; 36 | cursor: pointer; 37 | ` 38 | const DeviceOption = styled.option` 39 | border-radius: 10px 10px 2px 2px; 40 | outline: none; 41 | border: 2px solid #3f3f3f; 42 | margin: 2px; 43 | 44 | font: inherit; 45 | padding: 0; 46 | max-width: 100%; 47 | cursor: pointer; 48 | ` 49 | 50 | const FeedbackForm = styled.div` 51 | background-color: #313131; 52 | border: #555 solid 2px; 53 | padding: 10px; 54 | border-radius: 5px; 55 | margin-top: 16px; 56 | 57 | p { 58 | font-size: 1.4rem; 59 | } 60 | ` 61 | const FeedbackInput = styled.input` 62 | border-radius: 10px 10px 2px 2px; 63 | outline: none; 64 | border: 2px solid #3f3f3f; 65 | margin: 2px; 66 | cursor: pointer; 67 | 68 | font-size: 1.2rem; 69 | border-radius: 1rem; 70 | padding: 0.5rem; 71 | min-height: 4rem; 72 | width: 100%; 73 | ` 74 | 75 | const Modal = styled.div` 76 | position: fixed; 77 | top: 0; 78 | bottom: 0%; 79 | left: 0%; 80 | right: 0%; 81 | background-color: #111; 82 | opacity: 0.9; 83 | 84 | & > * { 85 | position: absolute; 86 | top: 50%; 87 | left: 0; 88 | right: 0%; 89 | transform: translateY(-50%); 90 | } 91 | ` 92 | 93 | export default function Settings() { 94 | const [videoDevices, setVideoDevices] = React.useState([]) 95 | const [audioDevices, setAudioDevices] = React.useState([]) 96 | const [selectedVideo, setSelectedVideo] = React.useState(null) 97 | const [selectedAudio, setSelectedAudio] = React.useState(null) 98 | const [feedbackText, setFeedbackText] = React.useState('') 99 | const [feedbackMsg, setFeedbackMsg] = React.useState('') 100 | const [isLoading, setIsLoading] = React.useState(false) 101 | 102 | const { localStream, requestDevices } = useLocalStream() 103 | const { enabledWidgets, setEnabledWidgets } = useEnabledWidgets() 104 | const client = useApolloClient() 105 | 106 | const getDevices = React.useCallback(async () => { 107 | try { 108 | const allDevices = await navigator.mediaDevices.enumerateDevices() 109 | const { videoArr, audioArr } = allDevices.reduce( 110 | (prev, cur, idx) => { 111 | const option = ( 112 | 113 | {cur.label} 114 | 115 | ) 116 | if (cur.kind === 'videoinput') { 117 | // console.log(cur.getCapabilities()) 118 | return { ...prev, videoArr: [...prev.videoArr, option] } 119 | } 120 | if (cur.kind === 'audioinput') { 121 | return { ...prev, audioArr: [...prev.audioArr, option] } 122 | } 123 | return prev 124 | }, 125 | { videoArr: [], audioArr: [] }, 126 | ) 127 | setVideoDevices(videoArr) 128 | setAudioDevices(audioArr) 129 | } catch (e) { 130 | console.error(e) 131 | alert(e.message) 132 | } 133 | }, []) 134 | 135 | React.useEffect(() => { 136 | if (!localStream) return 137 | const videoId = localStream.getVideoTracks()[0].getSettings().deviceId 138 | setSelectedVideo(videoId) 139 | const audioTracks = localStream.getAudioTracks() 140 | if (audioTracks.length) setSelectedAudio(audioTracks[0].getSettings().deviceId) 141 | getDevices() 142 | }, [getDevices, localStream]) 143 | 144 | const handleClose = (shouldApply = true) => { 145 | if (shouldApply) { 146 | requestDevices({ videoSource: selectedVideo, audioSource: selectedAudio }) 147 | } 148 | setEnabledWidgets({ ...enabledWidgets, menu: false }) 149 | } 150 | 151 | const handleFeedback = async () => { 152 | if (feedbackText.length < 1) return 0 153 | setIsLoading(true) 154 | const { loading, error } = await client.mutate({ 155 | mutation: CREATE_FEEDBACK, 156 | variables: { data: { text: feedbackText } }, 157 | }) 158 | setIsLoading(false) 159 | if (loading || error) console.log(loading, error) 160 | else { 161 | setFeedbackText('') 162 | setFeedbackMsg('Thanks for your feedback!') 163 | } 164 | return null 165 | } 166 | 167 | const handleChangeVideo = e => { 168 | console.log('device change id', e.target.value) 169 | setSelectedVideo(e.target.value) 170 | } 171 | 172 | const handleChangeAudio = e => { 173 | console.log('device change id', e.target.value) 174 | setSelectedAudio(e.target.value) 175 | } 176 | 177 | return ( 178 | 179 | {isLoading && ( 180 | 181 | 182 | 183 | )} 184 | handleClose(false)} onConfirm={handleClose}> 185 | 186 | Video Source 187 | 188 | {videoDevices} 189 | 190 | 191 | Audio Source 192 | 193 | {audioDevices} 194 | 195 | 196 | 197 |

Send Feedback

198 | setFeedbackText(e.target.value)} /> 199 | {feedbackMsg} 200 |
204 |
205 | ) 206 | } 207 | -------------------------------------------------------------------------------- /client/src/components/TextChat.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { useEnabledWidgets } from '../hooks/EnabledWidgetsContext' 4 | import { useNotify } from '../hooks/NotifyContext' 5 | import useWindowSize from '../hooks/WindowSizeHook' 6 | import { Button } from './common' 7 | 8 | const consoleShow = keyframes` 9 | 0% {bottom: -100px;} 10 | 45% {bottom: 0;} 11 | 100% {bottom: 0;} 12 | ` 13 | const historyShow = keyframes` 14 | 0% {transform: scaleY(0);} 15 | 55% {transform: scaleY(0);} 16 | 100% {transform: scaleY(1);} 17 | ` 18 | const StyledTextChat = styled.section` 19 | display: ${p => (p.active ? 'flex' : 'none')}; 20 | justify-content: center; 21 | height: 200px; 22 | position: absolute; 23 | overflow: hidden; 24 | bottom: 6rem; 25 | left: 0; 26 | right: 0; 27 | ` 28 | const TextHistory = styled.div` 29 | position: absolute; 30 | bottom: 38px; 31 | font-size: 1.6rem; 32 | background-image: linear-gradient(#00000000, #000000ff); 33 | filter: opacity(0.4); 34 | max-height: 15rem; 35 | overflow-y: auto; 36 | width: 92%; 37 | display: flex; 38 | flex-direction: column; 39 | padding: 10px; 40 | transform: scaleY(0); 41 | transform-origin: bottom; 42 | animation-fill-mode: forwards; 43 | animation-name: ${historyShow}; 44 | animation-duration: 0.8s; 45 | ` 46 | const TextComment = styled.p` 47 | padding: 2px; 48 | 49 | &.text-local { 50 | text-align: right; 51 | align-self: flex-end; 52 | max-width: 85%; 53 | } 54 | 55 | &.text-remote { 56 | text-align: left; 57 | align-self: flex-start; 58 | max-width: 85%; 59 | color: #e7b6ff; 60 | text-shadow: 0 0 1px #000; 61 | } 62 | ` 63 | const TextConsole = styled.form` 64 | display: flex; 65 | position: absolute; 66 | bottom: 0; 67 | left: 2%; 68 | right: 2%; 69 | opacity: 0.7; 70 | transform-origin: left bottom; 71 | animation-fill-mode: forwards; 72 | animation-name: ${consoleShow}; 73 | animation-duration: 0.8s; 74 | ` 75 | const ConsoleInput = styled.input` 76 | background-color: #313131; 77 | border: 0; 78 | border-bottom: 3px solid ${p => (p.value ? p.theme.colorPrimary : p.theme.colorPrimaryLight)}; 79 | border-radius: 10px 0 0 10px; 80 | padding: 4px; 81 | flex: 1; 82 | font-size: 16px; 83 | outline: none; 84 | color: inherit; 85 | font-family: inherit; 86 | transition: 0.4s all; 87 | ` 88 | const ConsoleButton = styled(Button)` 89 | border-radius: 0 10px 10px 0; 90 | padding: 8px 14px; 91 | ` 92 | 93 | export default function TextChat(props) { 94 | const { user, socketHelper, room } = props 95 | 96 | const [comment, setComment] = React.useState('') 97 | const [textChat, setTextChat] = React.useState([]) 98 | const [lastReadMsg, setLastReadMsg] = React.useState(-1) 99 | 100 | const messagesEnd = React.useRef() 101 | 102 | const { setTextNotify } = useNotify() 103 | const { enabledWidgets, setEnabledWidgets } = useEnabledWidgets() 104 | const { isPC } = useWindowSize() 105 | 106 | const onComment = e => { 107 | setTextChat(prev => [...prev, { comment: e.text, userId: e.userId }]) 108 | } 109 | 110 | React.useEffect(() => { 111 | socketHelper.socket.on('comment', onComment) 112 | return () => { 113 | socketHelper.socket.off('comment', onComment) 114 | } 115 | }, [socketHelper.socket]) 116 | 117 | const scrollToBottom = () => { 118 | messagesEnd.current.scrollIntoView({ behavior: 'smooth' }) 119 | } 120 | 121 | React.useEffect(() => { 122 | if (enabledWidgets.text) { 123 | scrollToBottom() 124 | if (lastReadMsg < textChat.length - 1) { 125 | setLastReadMsg(textChat.length - 1) 126 | setTextNotify(0) 127 | } 128 | } else { 129 | setTextNotify(textChat.length - 1 - lastReadMsg) 130 | } 131 | console.log(lastReadMsg) 132 | }, [enabledWidgets.text, textChat, lastReadMsg, setTextNotify]) 133 | 134 | const handleSubmit = e => { 135 | if (e) e.preventDefault() 136 | if (!socketHelper || !comment) { 137 | console.log(`No sockethelper! ${socketHelper} ${comment}`) 138 | return 139 | } 140 | socketHelper.emit('send', { 141 | userId: user.id, 142 | text: comment, 143 | roomId: room, 144 | }) 145 | 146 | setComment('') 147 | } 148 | 149 | const handleConsoleFocus = isFocus => { 150 | if (isPC) return 151 | setEnabledWidgets({ ...enabledWidgets, localVideo: !isFocus }) 152 | } 153 | 154 | return ( 155 | 156 | 157 | {textChat.map((sentComment, index) => ( 158 | 159 | {sentComment.comment} 160 | 161 | ))} 162 |
163 | 164 | 165 | handleConsoleFocus(false)} 170 | onChange={e => setComment(e.target.value)} 171 | onFocus={() => handleConsoleFocus(true)} 172 | /> 173 | 174 | 175 | 176 | 177 | 178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /client/src/components/ToggleButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { Button } from './common' 4 | 5 | const StyledToggleButton = styled.div` 6 | display: flex; 7 | position: relative; 8 | height: 45px; 9 | min-width: 45px; 10 | color: ${p => p.theme.colorPrimaryLight}; 11 | margin-left: 0.5rem; 12 | ` 13 | const ButtonElem = styled(Button)` 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | padding: 4px 8px; 18 | border-radius: 8px; 19 | color: #fff; 20 | /* background-color: ${p => (p.active ? p.theme.colorPrimary : p.theme.colorGreyDark2)}; */ 21 | background-image: linear-gradient( 22 | to bottom right, 23 | ${p => p.theme.colorPrimary}, 24 | ${p => p.theme.colorGreyLight3} 25 | ); 26 | filter: grayscale(${p => (p.active ? '0%' : '90%')}); 27 | ` 28 | const Title = styled.span` 29 | margin: 4px; 30 | font-size: 12px; 31 | ` 32 | const bounce = p => keyframes` 33 | 80% { transform: translateY(0px) scale(1); background-color: red;} 34 | 90% { transform: translateY(-20px) scale(2); background-color: ${p.theme.colorPrimary};} 35 | 100% {transform: translateY(0px) scale(1); background-color: red;} 36 | ` 37 | const Notification = styled.p` 38 | color: #fff; 39 | position: absolute; 40 | top: 0; 41 | right: 0; 42 | background-color: red; 43 | font-size: 1.3rem; 44 | border-radius: 50%; 45 | width: 2rem; 46 | height: 2rem; 47 | pointer-events: none; 48 | animation: ${bounce} 5s infinite; 49 | ` 50 | 51 | export default function ToggleButton(props) { 52 | const { 53 | children, 54 | title, 55 | importantTitle, 56 | iconClass, 57 | onClick, 58 | notification, 59 | active, 60 | innerWidth, 61 | 'data-cy': dataCy, 62 | } = props 63 | 64 | const showTitle = title && (innerWidth > 480 || (importantTitle && innerWidth > 320)) 65 | return ( 66 | 67 | 68 | {showTitle && {title}} 69 | {children} 70 | {!children && iconClass && } 71 | 72 | {notification > 0 && {notification}} 73 | 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /client/src/components/ToggleButtonWithMeter.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import ToggleButton from './ToggleButton' 4 | 5 | const IconContainer = styled.div` 6 | height: 18px; 7 | width: 100%; 8 | position: absolute; 9 | display: flex; 10 | align-items: flex-end; 11 | ` 12 | const Expander = styled.div.attrs(p => ({ 13 | style: { 14 | height: `${p.percent || 0}%`, 15 | // height: `${p.vol.current}%`, 16 | }, 17 | }))` 18 | display: flex; 19 | justify-content: center; 20 | position: absolute; 21 | left: 0; 22 | width: 100%; 23 | overflow: hidden; 24 | ` 25 | const DynamicIcon = styled.i` 26 | position: absolute; 27 | bottom: 0; 28 | color: black; 29 | ` 30 | 31 | function clamp(number, min, max) { 32 | return Math.max(0, Math.min(number || 0, max)) 33 | } 34 | 35 | const bufferSize = 512 36 | 37 | export default function ToggleButtonWithMeter(props) { 38 | const { stream, iconClass, onClick, notification, active, innerWidth, 'data-cy': dataCy } = props 39 | 40 | const [volume, setVolume] = React.useState(0) 41 | const vol = React.useRef(0) // Use to prevent crazy amounts of re-renders 42 | 43 | const processor = React.useRef() 44 | const mediaStreamSource = React.useRef() 45 | 46 | const processAudioVolume = React.useCallback(event => { 47 | const inputBuffer = event.inputBuffer.getChannelData(0) 48 | // Do a root-mean-square on the samples: sum up the squares... 49 | const sampleSum = inputBuffer.reduce((sum, sample) => sum + sample * sample, 0) 50 | // ... then take the square root of the sum. 51 | const rms = Math.sqrt(sampleSum / inputBuffer.length) 52 | // Now smooth this out with the averaging factor applied to the previous sample 53 | vol.current = Math.max(rms, vol.current * 0.97) 54 | }, []) 55 | 56 | const endProcessor = React.useCallback(() => { 57 | if (processor.current) { 58 | processor.current.disconnect() 59 | processor.current = null 60 | } 61 | if (mediaStreamSource.current) { 62 | mediaStreamSource.current.disconnect() 63 | mediaStreamSource.current = null 64 | } 65 | }, [processor]) 66 | 67 | const connectProcessor = React.useCallback(async () => { 68 | if (processor.current) { 69 | return 70 | } 71 | try { 72 | const AudioContext = window.AudioContext || window.webkitAudioContext // dammit apple 73 | const audioContext = new AudioContext() 74 | mediaStreamSource.current = audioContext.createMediaStreamSource(stream) 75 | 76 | const newProcessor = audioContext.createScriptProcessor(bufferSize) 77 | newProcessor.onaudioprocess = processAudioVolume 78 | 79 | newProcessor.connect(audioContext.destination) 80 | mediaStreamSource.current.connect(newProcessor) 81 | processor.current = newProcessor 82 | } catch (err) { 83 | console.error('audio meter error', err) 84 | } 85 | }, [processAudioVolume, stream]) 86 | 87 | React.useEffect(() => { 88 | if (stream && !processor.current) { 89 | connectProcessor() 90 | } 91 | const refresh = setInterval(() => { 92 | if (!processor.current) return 93 | setVolume(vol.current) 94 | }, 100) // 10fps 95 | return () => { 96 | endProcessor() 97 | clearInterval(refresh) 98 | } 99 | }, [connectProcessor, endProcessor, processor, stream]) 100 | 101 | return ( 102 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /client/src/components/UserCreate.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useApolloClient } from '@apollo/client' 3 | import styled from 'styled-components' 4 | import { CREATE_USER } from '../queries/mutations' 5 | import UserCreateForm from './UserCreateForm' 6 | 7 | const Main = styled.main` 8 | margin: 0 auto; 9 | max-width: 60rem; 10 | ` 11 | 12 | const BackdropImage = styled.img` 13 | position: fixed; 14 | background: url('https://images.unsplash.com/photo-1541980162-4d2fd81f420d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=634&q=80'); 15 | filter: blur(3px) grayscale(0.9) brightness(50%); 16 | transform: translate(-50%, -50%) scale(3); 17 | object-position: center; 18 | transform-origin: center center; 19 | z-index: -1; 20 | ` 21 | 22 | const IntroSection = styled.section` 23 | background-color: #3f3f3f; 24 | border-radius: 20px; 25 | margin: 10px; 26 | padding: 5px; 27 | 28 | display: flex; 29 | flex-direction: column; 30 | ` 31 | const IntroText = styled.h2` 32 | margin: 20px 0; 33 | padding-top: 20px; 34 | ` 35 | const TitleFeature = styled.h3` 36 | font-size: 2rem; 37 | margin: 2rem auto; 38 | text-align: left; 39 | width: 70%; 40 | color: ${p => p.theme.colorPrimary}; 41 | ` 42 | 43 | const UserCreateStats = styled.div` 44 | display: inline-block; 45 | width: 40%; 46 | margin: 2rem auto; 47 | ` 48 | 49 | const UserCreateNumbers = styled.div` 50 | padding: 10px; 51 | display: flex; 52 | min-width: 150px; 53 | justify-content: space-between; 54 | background-color: #555; 55 | border: 3px dashed #9932cc; 56 | border-radius: 20px 0; 57 | ` 58 | 59 | export default function UserCreate(props) { 60 | const { setUser } = props 61 | const [errorMsg, setErrorMsg] = React.useState('') 62 | const [isSubmitting, setIsSubmitting] = React.useState(false) 63 | 64 | const client = useApolloClient() 65 | 66 | const handleUserCreate = async ({ gender, lookingFor, age, minAge, maxAge, audioPref, accAudioPrefs }) => { 67 | console.log(gender, lookingFor, age, minAge, maxAge, audioPref, accAudioPrefs) 68 | if (age && minAge && maxAge) { 69 | setIsSubmitting(true) 70 | const { data, loading, error } = await client.mutate({ 71 | mutation: CREATE_USER, 72 | variables: { 73 | data: { 74 | gender, 75 | lookingFor: { connect: lookingFor }, 76 | age, 77 | minAge, 78 | maxAge, 79 | audioPref, 80 | accAudioPrefs: { connect: accAudioPrefs }, 81 | }, 82 | }, 83 | }) 84 | if (error) { 85 | setErrorMsg(error) 86 | } else if (loading) setErrorMsg(loading) 87 | else { 88 | const user = data.createUser 89 | user.lookingFor = lookingFor.map(x => { 90 | return { name: x } 91 | }) 92 | user.accAudioPrefs = accAudioPrefs.map(x => { 93 | return { name: x } 94 | }) 95 | console.log(user) 96 | setUser(user) 97 | 98 | const fs = window.FS 99 | if (fs) { 100 | fs.setUserVars({ 101 | displayName: `${user.gender} ${user.age} ${user.audioPref}`, 102 | age_int: user.age, 103 | gender_str: user.gender, 104 | lookingFor_str: user.lookingFor, 105 | audioPref_str: user.audioPref, 106 | accAudioPrefs_str: user.accAudioPrefs, 107 | }) 108 | } 109 | } 110 | } else { 111 | setErrorMsg('Please fill out all fields') 112 | } 113 | } 114 | 115 | return ( 116 |
117 | 121 | 122 | 123 | Share video, audio or text* 124 |
125 | Chat together 126 |
127 | 128 | 100% free 129 | 130 | 131 | No account required 132 | 133 | 134 | 135 | 136 |
58
137 |
138 |

Active users today

139 |
140 |
141 | 142 | 143 | 144 |

*You are paired strictly on your preferences and type of sharing (video, audio, text)

145 |
146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /client/src/components/UserCreateForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { GENDERS, AUDIO_PREFS } from '../helpers/constants' 4 | import ChoiceSlider from './ChoiceSlider' 5 | import NumberSlider from './NumberSlider' 6 | import SVGTester from './SVGTester' 7 | import ChoicePicker from './ChoicePicker' 8 | import { Button } from './common' 9 | 10 | const StyledForm = styled.div` 11 | border: #555 solid 2px; 12 | border-radius: 5px; 13 | 14 | background-color: ${p => p.theme.colorGreyDark1}; 15 | padding: 1rem; 16 | margin: 2rem 1rem; 17 | ` 18 | const Row = styled.div` 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | ` 23 | const InputLabel = styled.label` 24 | display: inline-block; 25 | font-size: ${p => p.fontSize || '1.5rem'}; 26 | margin-bottom: 4px; 27 | margin-top: 2rem; 28 | text-transform: uppercase; 29 | color: ${p => p.theme.colorPrimaryLight}; 30 | ` 31 | const Summary = styled.p` 32 | font-size: 10px; 33 | font-style: italic; 34 | ` 35 | const SubmitButton = styled(Button)` 36 | box-shadow: 0 0 5px #ffffff33; 37 | letter-spacing: 1.5px; 38 | margin-top: 1rem; 39 | filter: brightness(0.9); 40 | transition: 0.6s all; 41 | 42 | &:hover { 43 | filter: brightness(1); 44 | box-shadow: 0 0 5px #ffffffaa; 45 | } 46 | ` 47 | const Modal = styled.div` 48 | position: fixed; 49 | top: 0; 50 | bottom: 0%; 51 | left: 0%; 52 | right: 0%; 53 | background-color: #111; 54 | opacity: 0.9; 55 | z-index: 20; 56 | 57 | & > * { 58 | position: absolute; 59 | top: 50%; 60 | left: 0; 61 | right: 0%; 62 | transform: translateY(-50%); 63 | } 64 | ` 65 | 66 | export default function UserCreateForm(props) { 67 | const { error, onSubmit } = props 68 | const [gender, setGender] = React.useState(0) 69 | const [lookingFor, setLookingFor] = React.useState(GENDERS.map(name => ({ name }))) 70 | const [age, setAge] = React.useState(30) 71 | const [minAge, setMinAge] = React.useState(18) 72 | const [maxAge, setMaxAge] = React.useState(90) 73 | const [audioPref, setAudioPref] = React.useState(0) 74 | const [accAudioPrefs, setAccAudioPrefs] = React.useState(AUDIO_PREFS.map(name => ({ name }))) 75 | const [isLoading, setIsLoading] = React.useState(false) 76 | 77 | const changeNumbers = newArr => { 78 | if (newArr.length < 1) { 79 | return 80 | } 81 | if (newArr.length === 1) { 82 | setAge(newArr[0]) 83 | } else if (newArr.length === 2) { 84 | setMinAge(newArr[0]) 85 | setMaxAge(newArr[1]) 86 | } 87 | } 88 | 89 | const handleSubmit = () => { 90 | setIsLoading(true) 91 | onSubmit({ 92 | gender: GENDERS[gender], 93 | lookingFor, 94 | age, 95 | minAge, 96 | maxAge, 97 | audioPref: AUDIO_PREFS[audioPref], 98 | accAudioPrefs, 99 | }) 100 | } 101 | 102 | return ( 103 | 104 | 105 | I'm 106 | {GENDERS[gender]} 107 | 115 | 116 | 117 | I want to chat with 118 | 119 | {lookingFor && 120 | GENDERS.filter(g => lookingFor.some(gObj => gObj.name === g)) 121 | .join(', ') 122 | .replace(/_/g, ' ')} 123 | 124 | 132 | 133 | 134 | My Age 135 | 136 | 137 | 138 | Their age 139 | 140 | 141 | 142 | My Audio Preference 143 | {AUDIO_PREFS[audioPref].replace(/_/g, ' ')} 144 | 152 | 153 | 154 | Preferences I'll do 155 | 156 | {accAudioPrefs && 157 | AUDIO_PREFS.filter(pref => accAudioPrefs.some(pObj => pObj.name === pref)) 158 | .join(', ') 159 | .replace(/_/g, ' ')} 160 | 161 | 170 | 171 | {error} 172 | 173 | 174 | {isLoading && ( 175 | 176 | 177 | 178 | )} 179 | 180 | ) 181 | } 182 | -------------------------------------------------------------------------------- /client/src/components/UserUpdateForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { useApolloClient } from '@apollo/client' 4 | import { UPDATE_USER } from '../queries/mutations' 5 | import { GENDERS, AUDIO_PREFS } from '../helpers/constants' 6 | import { useSocket } from '../hooks/SocketContext' 7 | import { useMyUser } from '../hooks/MyUserContext' 8 | import { useLocalStream } from '../hooks/LocalStreamContext' 9 | import ChoiceSlider from './ChoiceSlider' 10 | import ChoicePicker from './ChoicePicker' 11 | import NumberSlider from './NumberSlider' 12 | import { Button } from './common' 13 | 14 | const StyledForm = styled.div` 15 | border: #555 solid 2px; 16 | padding: 10px; 17 | border-radius: 5px; 18 | 19 | width: 98%; 20 | max-height: 60%; 21 | max-width: 600px; 22 | background-color: ${p => p.theme.colorGreyDark1}; 23 | display: flex; 24 | flex-direction: column; 25 | ` 26 | const Row = styled.div` 27 | display: flex; 28 | flex-direction: column; 29 | align-items: center; 30 | ` 31 | const ScrollContent = styled.div` 32 | overflow-y: auto; 33 | overflow-x: hidden; 34 | flex: 1; 35 | ` 36 | const InputLabel = styled.label` 37 | display: inline-block; 38 | font-size: ${p => p.fontSize || '1.5rem'}; 39 | margin-right: 1rem; 40 | text-transform: uppercase; 41 | color: ${p => p.theme.colorPrimaryLight}; 42 | ` 43 | const SubmitButton = styled(Button)` 44 | margin-top: 10px; 45 | ` 46 | 47 | const stripArr = arr => { 48 | return arr.map(x => { 49 | return { name: x.name } 50 | }) 51 | } 52 | 53 | export default function UserUpdateForm() { 54 | const client = useApolloClient() 55 | const { user, getMe } = useMyUser() 56 | const { localStream } = useLocalStream() 57 | const { nextMatch, roomId } = useSocket() 58 | 59 | const [lookingFor, setLookingFor] = React.useState( 60 | stripArr(user.lookingFor) || 61 | GENDERS.map(x => { 62 | return { name: x } 63 | }), 64 | ) 65 | const [minAge, setMinAge] = React.useState(user.minAge || 18) 66 | const [maxAge, setMaxAge] = React.useState(user.maxAge || 90) 67 | const [audioPref, setAudioPref] = React.useState(AUDIO_PREFS.indexOf(user.audioPref) || 0) 68 | const [accAudioPrefs, setAccAudioPrefs] = React.useState( 69 | stripArr(user.accAudioPrefs) || 70 | AUDIO_PREFS.map(x => { 71 | return { name: x } 72 | }), 73 | ) 74 | const [loading, setLoading] = React.useState(false) 75 | const [hasChanges, setHasChanges] = React.useState(false) 76 | 77 | const changeNumbers = newArr => { 78 | if (newArr.length === 2) { 79 | setMinAge(newArr[0]) 80 | setMaxAge(newArr[1]) 81 | } 82 | } 83 | 84 | const areEqualArr = (arr1, arr2) => { 85 | const temp1 = arr1.map(x => x.name) 86 | const temp2 = arr2.map(x => x.name) 87 | 88 | const inTemp2 = temp1.every(x => temp2.includes(x)) 89 | const inTemp1 = temp2.every(x => temp1.includes(x)) 90 | 91 | return inTemp1 && inTemp2 92 | } 93 | 94 | const handleSubmit = async () => { 95 | console.log(user.lookingFor, lookingFor) 96 | const changes = {} 97 | 98 | // If lookingFor is different, changes should include it 99 | if (!areEqualArr(user.lookingFor, lookingFor)) { 100 | changes.lookingFor = lookingFor 101 | } 102 | // If minAge is different, changes should include it 103 | if (user.minAge !== minAge) { 104 | changes.minAge = minAge 105 | } 106 | // If maxAge is different, changes should include it 107 | if (user.maxAge !== maxAge) { 108 | changes.maxAge = maxAge 109 | } 110 | // If audioPref is different, changes should include it 111 | if (AUDIO_PREFS.indexOf(user.audioPref) !== audioPref) { 112 | changes.audioPref = AUDIO_PREFS[audioPref] 113 | } 114 | // If accAudioPrefs is different, changes should include it 115 | if (!areEqualArr(user.accAudioPrefs, accAudioPrefs)) { 116 | changes.accAudioPrefs = accAudioPrefs 117 | } 118 | 119 | console.log(changes) 120 | // If changes is empty return 121 | if (Object.entries(changes).length === 0) return 122 | 123 | const updatedUser = await getMe() 124 | 125 | // Now change shape to fit update (if lookingFor was changed) 126 | if (updatedUser.lookingFor !== lookingFor) { 127 | changes.lookingFor = { 128 | set: stripArr(lookingFor), 129 | } 130 | } 131 | // Now change shape to fit update (if accAudioPrefs was changed) 132 | if (updatedUser.accAudioPrefs !== accAudioPrefs) { 133 | changes.accAudioPrefs = { 134 | set: stripArr(accAudioPrefs), 135 | } 136 | } 137 | 138 | setLoading(true) 139 | // Send request 140 | const { data } = await client.mutate({ mutation: UPDATE_USER, variables: { data: changes } }) 141 | console.log(data) 142 | 143 | // setUser based off changes 144 | const changedUser = await getMe() 145 | 146 | const fs = window.FS 147 | if (fs) { 148 | await fs.setUserVars({ 149 | displayName: `${user.gender} ${user.age} ${user.audioPref}`, 150 | age_int: changedUser.age, 151 | gender_str: changedUser.gender, 152 | lookingFor_str: changedUser.lookingFor, 153 | audioPref_str: changedUser.audioPref, 154 | accAudioPrefs_str: changedUser.accAudioPrefs, 155 | }) 156 | } 157 | setLoading(false) 158 | if (roomId && localStream) nextMatch(localStream) 159 | } 160 | 161 | React.useEffect(() => { 162 | if ( 163 | !areEqualArr(user.lookingFor, lookingFor) || 164 | minAge !== user.minAge || 165 | maxAge !== user.maxAge || 166 | !areEqualArr(user.accAudioPrefs, accAudioPrefs) || 167 | audioPref !== AUDIO_PREFS.indexOf(user.audioPref) 168 | ) { 169 | if (!hasChanges) setHasChanges(true) 170 | } else if (hasChanges) setHasChanges(false) 171 | }, [lookingFor, user, maxAge, minAge, audioPref, accAudioPrefs, hasChanges]) 172 | 173 | return ( 174 | 175 | 176 | 177 | I want to chat with 178 | 186 | 187 | 188 | Their Age 189 | 190 | 191 | 192 | My Audio Preference 193 | 202 | 203 | 204 | Preferences I'll do 205 | 214 | 215 | 216 | 223 | 224 | ) 225 | } 226 | -------------------------------------------------------------------------------- /client/src/components/VideoGrid.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import useWindowSize from '../hooks/WindowSizeHook' 4 | import { useVideoPlayer } from '../hooks/VideoPlayerContext' 5 | import SVGTester from './SVGTester' 6 | import { Button } from './common' 7 | 8 | const StyledVideoGrid = styled.div` 9 | position: absolute; 10 | width: 100%; 11 | height: 100%; 12 | z-index: 100; 13 | display: ${p => (p.isShown ? 'block' : 'none')}; 14 | ` 15 | const Backdrop = styled.div` 16 | position: absolute; 17 | width: 100%; 18 | height: 100%; 19 | background-color: rgba(0, 0, 0, 0.4); 20 | ` 21 | const Modal = styled.div` 22 | position: absolute; 23 | top: 10%; 24 | bottom: 10%; 25 | left: 5%; 26 | right: 5%; 27 | background-color: rgba(0, 0, 0, 0.8); 28 | overflow-y: hidden; 29 | max-width: 90rem; 30 | margin: 0 auto; 31 | border-radius: 0 0 2rem 2rem; 32 | ` 33 | const CloseButton = styled(Button)` 34 | position: absolute; 35 | top: 5%; 36 | right: 5%; 37 | ` 38 | const SearchContent = styled.div` 39 | height: 100%; 40 | width: 100%; 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | font-size: 2rem; 45 | ` 46 | const ScrollList = styled.div` 47 | display: grid; 48 | grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); 49 | gap: 1rem; 50 | height: 93%; 51 | overflow-y: auto; 52 | overflow-x: hidden; 53 | ` 54 | const Result = styled.figure` 55 | height: 100%; 56 | overflow: hidden; 57 | position: relative; 58 | min-height: 15rem; 59 | cursor: pointer; 60 | 61 | &:hover { 62 | transform: scale(1.05); 63 | } 64 | ` 65 | const ResultTitle = styled.figcaption` 66 | position: absolute; 67 | top: 0; 68 | left: 0; 69 | right: 0; 70 | font-size: 1.6rem; 71 | background-color: rgba(0, 0, 0, 0.4); 72 | ` 73 | const ResultImage = styled.img` 74 | height: 100%; 75 | ` 76 | const ResultDuration = styled.span` 77 | position: absolute; 78 | bottom: 1rem; 79 | right: 1rem; 80 | ` 81 | 82 | const SearchBarForm = styled.form` 83 | background-color: #313131; 84 | border-radius: 5px; 85 | height: 48px; 86 | display: flex; 87 | padding: 0; 88 | border: none; 89 | border-bottom: 1px solid #555; 90 | ` 91 | 92 | const SearchBar = styled.input` 93 | flex: 1; 94 | overflow: hidden; 95 | outline: none; 96 | border: 2px solid #3f3f3f; 97 | display: block; 98 | background-color: #aaa; 99 | border-radius: 1rem; 100 | margin: 0; 101 | font-size: 1.6rem; 102 | padding: 0 4px; 103 | z-index: 10; 104 | ` 105 | const SubmitButton = styled(Button)` 106 | padding: 12px; 107 | background-color: #222; 108 | ` 109 | 110 | export default function VideoGrid(props) { 111 | const { videos, onSubmitSearch, isShown, setIsShown, selectVideo } = props 112 | const { parser } = useVideoPlayer() 113 | 114 | const [query, setQuery] = React.useState(parser ? parser.search : '') 115 | const [submittedQuery, setSubmittedQuery] = React.useState('') 116 | const [isLoading, setIsLoading] = React.useState(false) 117 | 118 | const { innerWidth } = useWindowSize() 119 | const searchBar = React.useRef() 120 | 121 | const handleClose = () => { 122 | setIsShown(false) 123 | } 124 | 125 | const handleSelectVideo = id => { 126 | selectVideo(id) 127 | setIsShown(false) 128 | } 129 | 130 | const handleSearchSubmit = async e => { 131 | if (e) e.preventDefault() 132 | if (query.length < 1 || query === submittedQuery) return 133 | setSubmittedQuery(query) 134 | setIsLoading(true) 135 | try { 136 | await onSubmitSearch(query) 137 | } catch (err) { 138 | console.error(err) 139 | } 140 | setIsLoading(false) 141 | } 142 | 143 | React.useEffect(() => { 144 | searchBar.current.focus() 145 | }) 146 | 147 | const getContent = () => { 148 | if (isLoading) { 149 | console.log('displayed') 150 | const length = `${innerWidth / 3}px` 151 | return ( 152 | 153 | 154 | 155 | ) 156 | } 157 | if (!videos.length && submittedQuery) { 158 | return ( 159 | 160 |

No results found

161 |
162 | ) 163 | } 164 | if (videos.length) { 165 | return ( 166 | 167 | {videos.map(video => { 168 | return ( 169 | handleSelectVideo(video.id)}> 170 | {video.title} 171 | 172 | {video.duration} 173 | 174 | ) 175 | })} 176 | 177 | ) 178 | } 179 | return ( 180 | 181 |

Enter a search above to start!

182 |
183 | ) 184 | } 185 | 186 | return ( 187 | 188 | 189 | 190 | 191 | 192 | 193 | setQuery(e.target.value)} 199 | /> 200 | 201 | {getContent()} 202 | 203 | 204 | ) 205 | } 206 | -------------------------------------------------------------------------------- /client/src/components/VideoWindow.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { useEnabledWidgets } from '../hooks/EnabledWidgetsContext' 4 | import useWindowSize from '../hooks/WindowSizeHook' 5 | import { useLocalStream } from '../hooks/LocalStreamContext' 6 | 7 | const circlingDashes = keyframes` 8 | from { box-shadow: 0 0 0 10px #5e5e5e; } 9 | to { box-shadow: 0 0 0 5px #fff; } 10 | ` 11 | const LocalVideoContainer = styled.div.attrs(p => ({ 12 | style: { 13 | top: `${p.top}px`, 14 | left: `${p.left}px`, 15 | }, 16 | }))` 17 | position: absolute; 18 | overflow: hidden; 19 | display: ${p => !p.isShown && 'none'}; 20 | touch-action: none; 21 | box-shadow: 0 0 2px #949494; 22 | opacity: 0.9; 23 | width: ${p => (p.isExpanded ? p.dimensions.width * 1.5 : p.dimensions.width)}px; 24 | height: ${p => (p.isExpanded ? p.dimensions.height * 1.5 : p.dimensions.height)}px; 25 | border-radius: 20px; 26 | border: 2px solid #555; 27 | transition: border-style 1s, box-shadow 1.2s, filter 0.6s, animation 3s, width 0.6s, height 0.6s; 28 | z-index: 10; 29 | 30 | &:hover { 31 | box-shadow: 0 0 3px #fff; 32 | } 33 | &:active { 34 | border: 4px dotted; 35 | box-shadow: 0 0 0 5px #fff; 36 | filter: opacity(0.6); 37 | animation-name: ${circlingDashes}; 38 | animation-duration: 0.6s; 39 | animation-delay: 0.8s; 40 | animation-iteration-count: infinite; 41 | } 42 | ` 43 | const ExpandIcon = styled.i` 44 | position: absolute; 45 | bottom: 5%; 46 | right: 5%; 47 | opacity: 0.5; 48 | ` 49 | const EfficiencyIcon = styled.i` 50 | position: absolute; 51 | top: 5%; 52 | left: 5%; 53 | opacity: 0.9; 54 | color: #81c91f; 55 | ` 56 | const RemoteVideoContainer = styled.div` 57 | position: relative; 58 | height: 100%; 59 | width: 100%; 60 | ` 61 | const LocalVideo = styled.video` 62 | width: 100%; 63 | transition: width 0.6s, height 0.6s; 64 | ` 65 | const RemoteVideo = styled.video` 66 | position: absolute; 67 | top: 0; 68 | left: 0; 69 | height: ${p => !p.alignTop && '100%'}; 70 | width: 100%; 71 | max-height: 100%; 72 | ` 73 | 74 | function clamp(number, min, max) { 75 | return Math.max(min, Math.min(number, max)) 76 | } 77 | 78 | function getMultiplier(videoWidth, videoHeight) { 79 | const minWidthMult = 100 / videoWidth 80 | const minHeightMult = 100 / videoHeight 81 | 82 | const maxWidthMult = 250 / videoWidth 83 | const maxHeightMult = 250 / videoHeight 84 | 85 | const scaledWidthMult = (window.innerWidth * 0.2) / videoWidth 86 | const scaledHeightMult = (window.innerHeight * 0.3) / videoHeight 87 | 88 | const widthMult = Math.max(minWidthMult, Math.min(scaledWidthMult, maxWidthMult)) 89 | const heightMult = Math.max(minHeightMult, Math.min(scaledHeightMult, maxHeightMult)) 90 | return Math.min(widthMult, heightMult) 91 | } 92 | 93 | export default function VideoWindow(props) { 94 | const { stream, videoType } = props 95 | 96 | const [top, setTop] = React.useState(50) 97 | const [left, setLeft] = React.useState(50) 98 | const [isExpanded, setIsExpanded] = React.useState(false) 99 | const [videoDimensions, setVideoDimensions] = React.useState({ width: 100, height: 100 }) 100 | const [originalDimensions, setOriginalDimensions] = React.useState({ width: 100, height: 100 }) 101 | 102 | const videoRef = React.useRef(null) 103 | const containerRef = React.useRef(null) 104 | const expansionTimerRef = React.useRef() 105 | 106 | const { chatSettings, enabledWidgets } = useEnabledWidgets() 107 | const { flowDirection } = useWindowSize 108 | const { inEfficiencyMode } = useLocalStream() 109 | 110 | const handleDrag = e => { 111 | if (e.dataTransfer) { 112 | e.dataTransfer.dropEffect = 'none' 113 | } 114 | setTop(clamp(e.clientY - 75, 20, 640 - 300)) 115 | setLeft(clamp(e.clientX - 50, 20, 360 - 150)) 116 | } 117 | 118 | const onTouchMove = e => { 119 | handleDrag(e.touches[0]) 120 | } 121 | 122 | React.useEffect(() => { 123 | const container = containerRef.current 124 | container.addEventListener('mousedown', e => { 125 | clearTimeout(expansionTimerRef.current) 126 | setIsExpanded(true) 127 | handleDrag(e) 128 | container.parentElement.addEventListener('mousemove', handleDrag) 129 | }) 130 | container.parentElement.addEventListener('mouseup', () => { 131 | if (!container || !container.parentElement) return 132 | container.parentElement.removeEventListener('mousemove', handleDrag) 133 | expansionTimerRef.current = setTimeout(() => { 134 | setIsExpanded(false) 135 | }, 5000) 136 | }) 137 | return () => { 138 | clearTimeout(expansionTimerRef.current) 139 | container.parentElement.removeEventListener('mousemove', handleDrag) 140 | } 141 | }, []) 142 | 143 | React.useEffect(() => { 144 | if (stream && videoRef.current.srcObject !== stream) { 145 | videoRef.current.srcObject = stream 146 | } 147 | }, [stream]) 148 | 149 | const handleResize = e => { 150 | const { videoWidth, videoHeight } = e.target 151 | if (!videoWidth || !videoHeight) return 152 | setOriginalDimensions({ width: videoWidth, height: videoHeight }) 153 | const multiplier = getMultiplier(videoWidth, videoHeight) 154 | setVideoDimensions({ width: videoWidth * multiplier, height: videoHeight * multiplier }) 155 | } 156 | 157 | const handleWindowResize = React.useCallback(() => { 158 | const { width, height } = originalDimensions 159 | const multiplier = getMultiplier(width, height) 160 | setVideoDimensions({ width: width * multiplier, height: height * multiplier }) 161 | }, [originalDimensions]) 162 | 163 | React.useEffect(() => { 164 | const video = videoRef.current 165 | if (video) { 166 | video.addEventListener('resize', handleResize) 167 | } 168 | window.addEventListener('resize', handleWindowResize) 169 | return () => { 170 | video.removeEventListener('resize', handleResize) 171 | window.removeEventListener('resize', handleWindowResize) 172 | } 173 | }, [handleWindowResize]) 174 | 175 | const getVideo = VideoComponent => { 176 | if (stream) { 177 | return ( 178 | 187 | ) 188 | } 189 | return '' 190 | } 191 | 192 | if (videoType === 'localVideo') { 193 | return ( 194 | 204 | {inEfficiencyMode && } 205 | {!isExpanded && } 206 | {getVideo(LocalVideo)} 207 | 208 | ) 209 | } 210 | return ( 211 | 212 | {getVideo(RemoteVideo)} 213 | 214 | ) 215 | } 216 | -------------------------------------------------------------------------------- /client/src/components/common/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const StyledButton = styled.button` 5 | flex: ${p => p.flex}; 6 | border-radius: ${p => p.borderRadius}; 7 | outline: none; 8 | border: #181818; 9 | text-align: center; 10 | background-color: ${p => p.backgroundColor}; 11 | color: ${p => (p.disabled ? '#888' : '#eee')}; 12 | padding: 5px 10px; 13 | cursor: ${p => (p.disabled ? 'default' : 'pointer')}; 14 | font-size: ${p => p.fontSize}; 15 | filter: ${p => p.disabled && 'brightness(0.8) grayscale(1)'}; 16 | position: relative; 17 | transition: all 0.4s; 18 | 19 | &:hover { 20 | text-shadow: ${p => !p.disabled && '0 0 2px #fff'}; 21 | box-shadow: ${p => !p.disabled && '0 0 4px #555'}; 22 | } 23 | ` 24 | 25 | export default function Button(props) { 26 | const { 27 | className, 28 | label, 29 | flex, 30 | square, 31 | primary, 32 | light, 33 | h1, 34 | h2, 35 | h3, 36 | small, 37 | disabled, 38 | onClick, 39 | 'data-cy': dataCy, 40 | children, 41 | } = props 42 | 43 | const backgroundColor = React.useMemo(() => { 44 | if (primary) return '#aa32cc' 45 | if (light) return '#555' 46 | return '#313131' 47 | }, [light, primary]) 48 | 49 | const flexStyle = React.useMemo(() => { 50 | if (flex) return 1 51 | }, [flex]) 52 | 53 | const borderRadius = React.useMemo(() => { 54 | if (square) return '0px' 55 | return '10px' 56 | }, [square]) 57 | 58 | const fontSize = React.useMemo(() => { 59 | if (h1) return '35px' 60 | if (h2) return '25px' 61 | if (h3) return '20px' 62 | if (small) return '14px' 63 | return '18px' 64 | }, [h1, h2, h3, small]) 65 | 66 | return ( 67 | 78 | {label} 79 | {children} 80 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /client/src/components/common/Dialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Button from './Button' 4 | 5 | const StyledDialog = styled.div` 6 | display: ${p => (p.open ? 'flex' : 'none')}; 7 | position: absolute; 8 | height: 100%; 9 | width: 100%; 10 | justify-content: center; 11 | align-items: center; 12 | z-index: 100; 13 | ` 14 | const Blur = styled.div` 15 | position: absolute; 16 | height: 100%; 17 | width: 100%; 18 | filter: blur(5px) saturate(20%); 19 | background-color: rgba(0, 0, 0, 0.5); 20 | ` 21 | const Container = styled.div` 22 | position: relative; 23 | width: 500px; 24 | max-width: 95%; 25 | max-height: 80%; 26 | background-color: ${p => p.theme.colorGreyDark2}; 27 | border-radius: 5px; 28 | box-shadow: 0 0 10px #000; 29 | display: flex; 30 | flex-direction: column; 31 | ` 32 | const Title = styled.h3` 33 | margin: 10px 16px; 34 | margin-bottom: 16px; 35 | font-size: 2rem; 36 | padding: 1rem; 37 | border-bottom: 2px solid #222; 38 | ` 39 | const Content = styled.div` 40 | display: flex; 41 | padding: 1rem 2rem; 42 | flex-direction: column; 43 | max-height: 20%; 44 | overflow-y: auto; 45 | overflow-x: hidden; 46 | margin-bottom: 16px; 47 | ` 48 | const Actions = styled.div` 49 | display: flex; 50 | justify-content: flex-end; 51 | align-items: center; 52 | ` 53 | const ConfirmButton = styled(Button)` 54 | margin-left: 8px; 55 | ` 56 | const CancelButton = styled(Button)`` 57 | 58 | export default function Dialog(props) { 59 | const { children, confirmText, disabled, isLoading, onCancel, onConfirm, open, title } = props 60 | 61 | return ( 62 | 63 | 64 | 65 | {title && {title}} 66 | {children} 67 | 68 | 69 | 77 | 78 | 79 | 80 | ) 81 | } 82 | 83 | Dialog.defaultProps = { 84 | title: 'Alert', 85 | confirmText: 'Apply', 86 | onCancel: () => {}, 87 | onConfirm: () => {}, 88 | } 89 | -------------------------------------------------------------------------------- /client/src/components/common/index.js: -------------------------------------------------------------------------------- 1 | import Button from './Button' 2 | import Dialog from './Dialog' 3 | 4 | export { Button, Dialog } 5 | -------------------------------------------------------------------------------- /client/src/components/stats/StatsWindow.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const pointerLength = 10 5 | 6 | const StyledStatsWindow = styled.div` 7 | position: absolute; 8 | bottom: ${p => p.bottom + 2 + pointerLength}%; 9 | left: ${p => p.center}%; 10 | transform: translate(-50%, -60%); 11 | border-radius: 1rem; 12 | background-color: white; 13 | padding: 1rem; 14 | font-size: 1.4rem; 15 | box-shadow: 0 0 1rem #000; 16 | ` 17 | const Pointer = styled.div` 18 | position: absolute; 19 | bottom: 0; 20 | left: 50%; 21 | height: 1rem; 22 | width: ${pointerLength}px; 23 | background-color: white; 24 | transform: translate(-50%, 50%) rotate(45deg); 25 | ` 26 | const Text = styled.p` 27 | color: ${p => p.color || '#000'}; 28 | ` 29 | 30 | export default function StatsWindow(props) { 31 | const { bottom, center, values } = props 32 | return ( 33 | 34 | 35 | {values && 36 | values.map(v => ( 37 | 38 | {v.title} : {v.text} 39 | 40 | ))} 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /client/src/helpers/constants.js: -------------------------------------------------------------------------------- 1 | export const GENDERS = ['MALE', 'FEMALE', 'F2M', 'M2F'] 2 | export const AUDIO_PREFS = ['CONVERSATION', 'MOANS', 'NO_AUDIO', 'CHAT_FIRST'] 3 | export const GENDER_COLORS = { MALE: '#1754ed', FEMALE: '#ff32be', F2M: '#18ecf3', M2F: '#ff1a1a' } 4 | export const GENDER_DASHARRAY = { MALE: '800', FEMALE: '20 15', F2M: '10 20', M2F: '5 10' } 5 | export const REPORT_TYPES = [ 6 | { key: 'NONE', name: 'None', desc: "The other categories don't fit" }, 7 | { key: 'UNDERAGE', name: 'Underage', desc: 'Appears younger than 18' }, 8 | { key: 'NO_VIDEO', name: 'No Video', desc: 'Completely unable to see match' }, 9 | { key: 'FALSE_AGE', name: 'False Age', desc: 'Appears a different age than entered, but is 18+' }, 10 | { key: 'FALSE_SEX', name: 'False Sex', desc: 'Appears a different sex than entered' }, 11 | { key: 'FALSE_AUDIO', name: 'False Audio', desc: "Didn't adhere to the agreed upon audio preference" }, 12 | { key: 'ABUSIVE', name: 'Abusive', desc: 'Unwanted verbal threats or hate' }, 13 | ] 14 | -------------------------------------------------------------------------------- /client/src/helpers/htmlParse.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const DOMAIN = process.env.REACT_APP_SEARCH_DOMAIN || 'youtube' 4 | const SEARCH_URL = `https://www.${DOMAIN}.com/video/search?search=` 5 | const PROXY_URL = 'https://cors-anywhere.herokuapp.com/' 6 | 7 | class HtmlParse { 8 | // search query 9 | search = null 10 | 11 | // List of ids that will be pulled out of the parsed response 12 | videos = [] 13 | 14 | constructor(search) { 15 | this.search = search 16 | } 17 | 18 | static getUrl() { 19 | return `https://www.${DOMAIN}.com/embed/` 20 | } 21 | 22 | async parsePage() { 23 | console.log('parsing page') 24 | const response = await axios.get(PROXY_URL + SEARCH_URL + this.search) // not needed due to proxy , {headers: {'Access-Control-Allow-Origin': '*'}}) 25 | const parser = new DOMParser() 26 | const doc = parser.parseFromString(response.data, 'text/html') 27 | // videoSearchResult on full web, videoListSearchResults on mobile 28 | const ulResults = doc.querySelector('#videoSearchResult') || doc.querySelector('#videoListSearchResults') 29 | const isMobile = !!doc.querySelector('#videoListSearchResults') 30 | if (!ulResults) { 31 | console.log('no results') 32 | return 33 | } 34 | const items = ulResults.getElementsByTagName('li') 35 | for (const i of items) { 36 | if (i.hasAttribute('_vkey') || isMobile) { 37 | const newItem = {} 38 | newItem.id = isMobile 39 | ? i.querySelector('.imageLink').getAttribute('href').split('=')[1] 40 | : i.getAttribute('_vkey') 41 | newItem.title = i.querySelector('img').getAttribute('alt') 42 | newItem.img = i.querySelector('img').getAttribute('data-thumb_url') 43 | newItem.duration = isMobile ? i.querySelector('.time').textContent : i.querySelector('.duration').textContent 44 | this.videos.push(newItem) 45 | } 46 | } 47 | } 48 | } 49 | 50 | export default HtmlParse 51 | -------------------------------------------------------------------------------- /client/src/helpers/htmlParse2.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const DOMAIN = process.env.REACT_APP_SEARCH_DOMAIN || 'youtube' 4 | const SEARCH_URL = `https://www.${DOMAIN}.com/videos/search?q=` 5 | const PROXY_URL = 'https://cors-anywhere.herokuapp.com/' 6 | 7 | class HtmlParse { 8 | // search query 9 | search = null 10 | 11 | // List of ids that will be pulled out of the parsed response 12 | videos = [] 13 | 14 | constructor(search) { 15 | this.search = search 16 | } 17 | 18 | static getUrl() { 19 | return `https://www.${DOMAIN}.com/videos/embed/` 20 | } 21 | 22 | async parsePage() { 23 | console.log('parsing page') 24 | const response = await axios.get(PROXY_URL + SEARCH_URL + this.search) // not needed due to proxy , {headers: {'Access-Control-Allow-Origin': '*'}}) 25 | const parser = new DOMParser() 26 | const doc = parser.parseFromString(response.data, 'text/html') 27 | console.log(doc) 28 | // videoSearchResult on full web, videoListSearchResults on mobile 29 | const ulResults = doc.querySelector('.videos') || doc.querySelector('#videoListSearchResults') 30 | const isMobile = true || !!doc.querySelector('#videoListSearchResults') 31 | console.log(ulResults) 32 | if (!ulResults) { 33 | console.log('no results') 34 | return 35 | } 36 | const items = ulResults.getElementsByClassName('item') 37 | for (const i of items) { 38 | if (i.querySelector('a').hasAttribute('_vkey') || isMobile) { 39 | const newItem = {} 40 | newItem.id = isMobile 41 | ? i 42 | .querySelector('a') 43 | .getAttribute('href') 44 | // .split('/videos/')[1] 45 | .split('-') 46 | .slice(-1)[0] 47 | : i.getAttribute('_vkey') 48 | newItem.title = i.querySelector('img').getAttribute('alt') 49 | newItem.img = i.querySelector('img').getAttribute('src') 50 | newItem.duration = isMobile 51 | ? i.querySelector('.meta p').lastChild.textContent 52 | : i.querySelector('.duration').textContent 53 | this.videos.push(newItem) 54 | } 55 | } 56 | } 57 | } 58 | 59 | export default HtmlParse 60 | -------------------------------------------------------------------------------- /client/src/helpers/htmlParse3.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const DOMAIN = process.env.REACT_APP_SEARCH_DOMAIN || 'youtube' 4 | const SEARCH_URL = `https://www.${DOMAIN}.com/?search=` 5 | const PROXY_URL = 'https://cors-anywhere.herokuapp.com/' 6 | 7 | class HtmlParse { 8 | // search query 9 | search = null 10 | 11 | // List of ids that will be pulled out of the parsed response 12 | videos = [] 13 | 14 | constructor(search) { 15 | this.search = search 16 | } 17 | 18 | static async getUrl(id) { 19 | const videoResponse = await axios.get(`${PROXY_URL}https://www.${DOMAIN}.com/${id}`) 20 | const parser = new DOMParser() 21 | const doc = parser.parseFromString(videoResponse.data, 'text/html') 22 | const videoUrl = doc 23 | .getElementById(`${DOMAIN}-player`) 24 | .innerHTML.match(/videoUrl":"(http.*?)"}/)[1] 25 | .replace(/\\/g, '') 26 | return videoUrl 27 | } 28 | 29 | async parsePage() { 30 | console.log('parsing page') 31 | const response = await axios.get(PROXY_URL + SEARCH_URL + this.search) // not needed due to proxy , {headers: {'Access-Control-Allow-Origin': '*'}}) 32 | const parser = new DOMParser() 33 | const doc = parser.parseFromString(response.data, 'text/html') 34 | const ulResults = doc.querySelectorAll('.tm_video_block') 35 | if (!ulResults) { 36 | console.log('no results') 37 | return 38 | } 39 | 40 | const regex = /\//g 41 | for (const i of ulResults) { 42 | const newItem = {} 43 | const href = i.querySelector('.video_link').getAttribute('href') 44 | if (href.length > 1 && Number.parseInt(href.charAt(1), 10)) { 45 | newItem.id = href.replace(regex, '') 46 | newItem.title = i.querySelector('img').getAttribute('alt') 47 | newItem.img = i.querySelector('img').getAttribute('data-src') 48 | newItem.duration = i.querySelector('.duration').innerText 49 | this.videos.push(newItem) 50 | } 51 | } 52 | } 53 | } 54 | 55 | export default HtmlParse 56 | -------------------------------------------------------------------------------- /client/src/helpers/socketHelper.js: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client' 2 | 3 | export default class SocketHelper { 4 | constructor() { 5 | this.socket = io() 6 | // this.socket.emit('create or join'); 7 | this.pc = null 8 | this.localStream = null 9 | this.remoteStream = null 10 | this.isCaller = false 11 | this.iceServers = { 12 | iceServers: [ 13 | { urls: 'stun:stun.services.mozilla.com' }, 14 | { urls: 'stun:stun.l.google.com:19302' }, 15 | /* turn here */ 16 | ], 17 | } 18 | // this.initializeEvents(); 19 | } 20 | 21 | // Functions to overwrite 22 | 23 | onTrack = e => e 24 | 25 | onIceConnectionStateChange = e => e 26 | 27 | onComment = e => e 28 | 29 | updateConnectionMsg = connectionMsg => connectionMsg 30 | 31 | onNextRoom = userId => userId 32 | 33 | onDisconnect = () => {} 34 | 35 | onIdentity = user => user 36 | 37 | onMatchId = matchId => matchId 38 | 39 | /** 40 | * General Flow 41 | * 42 | * User hits page, calls 'create or join' which checks the number of clients connected. 43 | * User1 creates room. Sets up camera 44 | * User2 joins room. Sets up camera, emits 'ready' 45 | * User1 receives 'ready'. Creates PC, sets ICE, adds localStream track, sets local desc, emits new offer. 46 | * User2 receives 'offer'. Creates PC, sets ICE, adds localStream track, sets local desc, emits new answer, sets remote desc. 47 | * User1 receives 'answer'. Sets remote desc. 48 | * 49 | * Candidates are sent by both users throughout process. 50 | */ 51 | initializeEvents = () => { 52 | this.socket.on('created', () => { 53 | console.log('created') 54 | this.isCaller = true 55 | this.updateConnectionMsg('Waiting for users that meet your criteria...') 56 | }) 57 | this.socket.on('joined', roomId => { 58 | // await setupCamera(); 59 | console.log('joined') 60 | this.updateConnectionMsg('Connecting to your match...') 61 | this.roomId = roomId 62 | this.socket.emit('ready', roomId) 63 | }) 64 | this.socket.on('full room', roomId => { 65 | console.log(`full room at ${roomId}`) 66 | this.updateConnectionMsg('Room full') 67 | this.onNextRoom(this.localStream) 68 | }) 69 | this.socket.on('disconnect', () => { 70 | console.log('disconnect') 71 | // this.pc.close() 72 | this.onDisconnect() 73 | this.socket.disconnect(true) 74 | }) 75 | 76 | // Called each time a candidate is received, executes on both users 77 | const onIceCandidate = e => { 78 | console.log('on ice candidate') 79 | if (e.candidate) { 80 | // console.log(e.candidate); 81 | this.socket.emit('candidate', { 82 | type: 'candidate', 83 | label: e.candidate.sdpMLineIndex, 84 | id: e.candidate.sdpMid, 85 | candidate: e.candidate.candidate, 86 | roomId: this.roomId, 87 | }) 88 | } 89 | } 90 | 91 | // This code is called from ready and offer, once per user 92 | const createPC = () => { 93 | if (!this.localStream) { 94 | console.error('Missing localStream!') 95 | return 96 | } 97 | this.pc = new RTCPeerConnection(this.iceServers) 98 | this.pc.onicecandidate = onIceCandidate 99 | this.pc.ontrack = e => { 100 | console.log('ontrack', e.streams[0]) 101 | this.updateConnectionMsg('Connection complete') 102 | const [stream] = e.streams 103 | this.remoteStream = stream 104 | console.log('set remote stream to ', this.remoteStream) 105 | this.onTrack(e) 106 | } 107 | this.pc.oniceconnectionstatechange = e => { 108 | this.onIceConnectionStateChange(e) 109 | } 110 | this.pc.onnegotiationneeded = async e => { 111 | console.log(`negotiation needed for`, e) 112 | // await this.pc.createOffer(setLocalAndOffer, err => console.error(err)) 113 | } 114 | // if (this.pc.addStream) { 115 | // console.log('addStream') 116 | // this.pc.addStream(this.localStream) 117 | // } else { 118 | console.log('addTracks') 119 | // Recommended implementation since addStream is obsolete 120 | this.localStream.getTracks().forEach(track => { 121 | console.log('track gotten', track) 122 | this.pc.addTrack(track, this.localStream) 123 | }) 124 | // } 125 | } 126 | 127 | // Set Local description and emit offer 128 | const setLocalAndOffer = sessionDesc => { 129 | console.log('setLocalAndOffer', sessionDesc) 130 | this.pc.setLocalDescription(sessionDesc) 131 | this.socket.emit('offer', { 132 | type: 'offer', 133 | sdp: sessionDesc, 134 | roomId: this.roomId, 135 | }) 136 | } 137 | 138 | // Ready called after 2nd user joins, only 1st user executes this 139 | this.socket.on('ready', async () => { 140 | if (this.isCaller) { 141 | console.log('on ready') 142 | createPC() 143 | try { 144 | const offer = await this.pc.createOffer() 145 | setLocalAndOffer(offer) 146 | } catch (e) { 147 | console.error(e) 148 | } 149 | console.log('local rtc established') 150 | } 151 | }) 152 | 153 | const setLocalAndAnswer = sessionDesc => { 154 | console.log('setLocalAndAnswer', sessionDesc) 155 | this.pc.setLocalDescription(sessionDesc) 156 | this.socket.emit('answer', { 157 | type: 'answer', 158 | sdp: sessionDesc, 159 | roomId: this.roomId, 160 | }) 161 | } 162 | // Offer emitted by 1st user, only 2nd user executes this and emits the answer 163 | this.socket.on('offer', async desc => { 164 | if (!this.isCaller) { 165 | console.log('on offer', desc) 166 | createPC() 167 | try { 168 | await this.pc.setRemoteDescription(new RTCSessionDescription(desc)) 169 | console.log('set remote description') 170 | // await this.pc.createAnswer(setLocalAndAnswer, e => console.log(e)) 171 | const answer = await this.pc.createAnswer() 172 | setLocalAndAnswer(answer) 173 | } catch (e) { 174 | console.error(e) 175 | } 176 | } else { 177 | console.log('offer received by user 1', desc) 178 | } 179 | }) 180 | 181 | // Answer emitted by 2nd user, only 1st user executes this which triggers ontrack 182 | this.socket.on('answer', e => { 183 | console.log('on answer') 184 | this.pc.setRemoteDescription(new RTCSessionDescription(e)) 185 | }) 186 | 187 | // Both users execute this when a candidate is chosen 188 | this.socket.on('candidate', e => { 189 | console.log('on candidate') 190 | this.pc.addIceCandidate( 191 | new RTCIceCandidate({ 192 | sdpMLineIndex: e.label, 193 | candidate: e.candidate, 194 | }), 195 | ) 196 | }) 197 | 198 | // Other non-setup functions 199 | // this.socket.on('identity', this.onIdentity) 200 | // this.socket.on('matchId', matchId => console.log('socketHelper says matchId is ', matchId)) 201 | } 202 | 203 | leaveRooms() { 204 | this.socket.disconnect() 205 | } 206 | 207 | // Helper function for creating and joining a specific room 208 | joinRoom(roomId) { 209 | this.socket.emit('create or join', roomId) 210 | console.log(`joinRoom ${roomId}`) 211 | this.roomId = roomId 212 | } 213 | 214 | emit(title, msg) { 215 | this.socket.emit(title, msg) 216 | } 217 | 218 | async replaceTrack(newStream) { 219 | console.log('switching tracks') 220 | const videoTracks = newStream.getVideoTracks() 221 | const audioTracks = newStream.getAudioTracks() 222 | if (videoTracks && videoTracks[0]) { 223 | const sender = this.pc.getSenders().find(s => s.track.kind === videoTracks[0].kind) 224 | console.log('found video sender', sender) 225 | await sender.replaceTrack(videoTracks[0]) 226 | } 227 | if (audioTracks && audioTracks[0]) { 228 | const sender = this.pc.getSenders().find(s => s.track.kind === audioTracks[0].kind) 229 | console.log('found audio sender', sender) 230 | await sender.replaceTrack(audioTracks[0]) 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /client/src/helpers/themes.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles' 2 | import { createGlobalStyle } from 'styled-components' 3 | 4 | export const darkTheme = { 5 | colorPrimary: '#9932cc', 6 | colorPrimaryLight: '#c38fdd', 7 | 8 | colorWhite1: '#aaa', 9 | 10 | colorGreyLight1: '#949494', 11 | colorGreyLight2: '#777', 12 | colorGreyLight3: '#555', 13 | 14 | colorGreyDark1: '#3f3f3f', 15 | colorGreyDark2: '#313131', 16 | colorGreyDark3: '#222', 17 | 18 | fontPrimary: 'K2D, sans-serif', 19 | fontSecondary: 'Megrim, cursive', 20 | } 21 | 22 | export const muiTheme = createMuiTheme({ 23 | palette: { 24 | primary: { 25 | light: '#cd65ff', 26 | main: '#9932cc', 27 | dark: '#66009a', 28 | contrastText: '#fff', 29 | }, 30 | secondary: { 31 | light: '#ff7961', 32 | main: '#f44336', 33 | dark: '#ba000d', 34 | contrastText: '#000', 35 | }, 36 | }, 37 | }) 38 | 39 | export const GlobalStyle = createGlobalStyle` 40 | * { 41 | margin: 0; 42 | padding: 0; 43 | box-sizing: border-box; 44 | } 45 | 46 | html { 47 | font-size: 62.5%; 48 | font-family: 'K2D', sans-serif; 49 | } 50 | 51 | body { 52 | background-image: linear-gradient(#181818, #3f3f3f 30%); 53 | color: white; 54 | text-align: center; 55 | } 56 | ` 57 | 58 | // gradientPrimaryBR: 'linear-gradient(to bottom right, ${p => p.theme.colorPrimary}, ${p => p.theme.colorGreyDark1})', 59 | -------------------------------------------------------------------------------- /client/src/hooks/EnabledWidgetsContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const EnabledWidgetsContext = React.createContext({ 4 | enabledWidgets: { 5 | text: false, 6 | menu: false, 7 | video: false, 8 | countdown: false, 9 | profile: false, 10 | matches: false, 11 | stats: false, 12 | updatePref: false, 13 | }, 14 | setEnabledWidgets: () => {}, 15 | chatSettings: { micMute: false, speakerMute: false }, 16 | setChatSettings: () => {}, 17 | featureToggle: () => {}, 18 | }) 19 | export function useEnabledWidgets() { 20 | return React.useContext(EnabledWidgetsContext) 21 | } 22 | 23 | export function EnabledWidgetsProvider(props) { 24 | const { children } = props 25 | const [enabledWidgets, setEnabledWidgets] = React.useState({ 26 | text: false, 27 | menu: false, 28 | video: false, 29 | countdown: false, 30 | profile: false, 31 | matches: false, 32 | stats: false, 33 | updatePref: false, 34 | 35 | localVideo: true, 36 | }) 37 | 38 | const [chatSettings, setChatSettings] = React.useState({ micMute: false, speakerMute: false }) 39 | 40 | const featureToggle = React.useCallback( 41 | (elem, inHub) => { 42 | const { text, countdown, profile, video, localVideo } = enabledWidgets 43 | setEnabledWidgets({ 44 | text, 45 | countdown, 46 | profile, 47 | video: inHub ? false : video, 48 | localVideo, 49 | [elem]: !enabledWidgets[elem], 50 | }) 51 | }, 52 | [enabledWidgets], 53 | ) 54 | 55 | return ( 56 | 59 | {children} 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /client/src/hooks/LocalStreamContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useEnabledWidgets } from './EnabledWidgetsContext' 3 | import { useSocket } from './SocketContext' 4 | 5 | const LocalStreamContext = React.createContext({ localStream: null, requestDevices: () => {} }) 6 | export function useLocalStream() { 7 | return React.useContext(LocalStreamContext) 8 | } 9 | 10 | export const LocalStreamProvider = props => { 11 | const { children } = props 12 | const [localStream, setLocalStream] = React.useState(null) 13 | const [inEfficiencyMode, setInEfficiencyMode] = React.useState(false) 14 | 15 | const { chatSettings } = useEnabledWidgets() 16 | const { remoteStream, socketHelper } = useSocket() 17 | 18 | React.useEffect(() => { 19 | console.log(`localStream changed to `, localStream) 20 | }, [localStream]) 21 | 22 | const determineEfficiencyMode = React.useCallback( 23 | async inCall => { 24 | const videoTracks = localStream && localStream.getVideoTracks() 25 | if (!videoTracks || !videoTracks.length) { 26 | setInEfficiencyMode(false) 27 | return 28 | } 29 | 30 | const allDevices = await navigator.mediaDevices.enumerateDevices() 31 | const rear = allDevices.find(d => d.kind === 'videoinput' && d.label.match(/back/i)) 32 | const front = allDevices.find(d => d.kind === 'videoinput' && d.label.match(/front/i)) 33 | const onMobile = rear && front 34 | if (!onMobile) { 35 | setInEfficiencyMode(false) 36 | return 37 | } 38 | 39 | const videoConstraints = videoTracks[0].getConstraints() 40 | if (inCall && videoConstraints.frameRate === 1) { 41 | videoConstraints.frameRate = 30 42 | } else if (!inCall && videoConstraints.frameRate !== 1) { 43 | videoConstraints.frameRate = 1 44 | } 45 | videoTracks[0].applyConstraints(videoConstraints) 46 | setInEfficiencyMode(videoConstraints.frameRate === 1) 47 | }, 48 | [localStream], 49 | ) 50 | 51 | // InitializeSocket needs to be called first 52 | const requestDevices = React.useCallback( 53 | async ({ videoSource, audioSource }) => { 54 | const constraints = { 55 | video: { 56 | deviceId: videoSource ? { exact: videoSource } : undefined, 57 | aspectRatio: { min: 0.5, max: 2 }, 58 | frameRate: inEfficiencyMode ? 1 : 30, 59 | }, 60 | audio: { 61 | deviceId: audioSource ? { exact: audioSource } : undefined, 62 | }, 63 | } 64 | // Get stream 65 | try { 66 | // Have to stop tracks before switching on mobile 67 | if (localStream) localStream.getTracks().forEach(track => track.stop()) 68 | console.log('tracks stopped') 69 | const stream = await navigator.mediaDevices.getUserMedia(constraints) 70 | // If we have an existing connection 71 | console.log('stream', stream) 72 | // TODO: pull in socketHelper 73 | if (remoteStream && videoSource) { 74 | socketHelper.replaceTrack(stream) 75 | } 76 | setLocalStream(stream) 77 | const audio = stream.getAudioTracks() 78 | if (audio.length > 0) { 79 | audio[0].enabled = !chatSettings.micMute 80 | console.log(`audio enabled is now ${audio[0].enabled}`) 81 | } 82 | } catch (e) { 83 | alert( 84 | "Video is required to use this app. On iOS only Safari can share video. Also make sure you're at 'https://'. If you're still receiving this error, please contact me.", 85 | ) 86 | console.error(e) 87 | } 88 | }, 89 | [chatSettings.micMute, inEfficiencyMode, localStream, remoteStream, socketHelper], 90 | ) 91 | 92 | return ( 93 | 94 | {children} 95 | 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /client/src/hooks/MyUserContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useApolloClient } from '@apollo/client' 3 | import { GET_ME } from '../queries/queries' 4 | 5 | const MyUserContext = React.createContext({}) 6 | export function useMyUser() { 7 | return React.useContext(MyUserContext) 8 | } 9 | 10 | export default function MyUserProvider(props) { 11 | const { children } = props 12 | const [user, setUser] = React.useState() 13 | 14 | const client = useApolloClient() 15 | 16 | const getMe = React.useCallback(async () => { 17 | const { data } = await client.query({ query: GET_ME, fetchPolicy: 'network-only' }) 18 | if (data.me) { 19 | setUser(data.me) 20 | } 21 | return data.me 22 | }, [client]) 23 | 24 | React.useEffect(() => { 25 | getMe() 26 | }, [getMe]) 27 | 28 | return {user ? children : ''} 29 | } 30 | -------------------------------------------------------------------------------- /client/src/hooks/NotifyContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const NotifyContext = React.createContext() 4 | export function useNotify() { 5 | return React.useContext(NotifyContext) 6 | } 7 | 8 | export const NotifyProvider = props => { 9 | const { children } = props 10 | const [countdownNotify, setCountdownNotify] = React.useState(false) 11 | const [videoNotify, setVideoNotify] = React.useState(false) 12 | const [textNotify, setTextNotify] = React.useState(0) 13 | 14 | return ( 15 | 25 | {children} 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /client/src/hooks/VideoPlayerContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import HtmlParse from '../helpers/htmlParse3' 3 | 4 | const VideoPlayerContext = React.createContext({ 5 | videoTime: 0, 6 | }) 7 | 8 | export function useVideoPlayer() { 9 | return React.useContext(VideoPlayerContext) 10 | } 11 | 12 | export function VideoPlayerProvider(props) { 13 | const { children } = props 14 | const savedTime = React.useRef(0) 15 | const savedUrl = React.useRef('') 16 | const savedId = React.useRef(null) 17 | const savedPausedState = React.useRef(true) 18 | const [parser, setParser] = React.useState(new HtmlParse('')) 19 | 20 | const setVideoData = data => { 21 | savedTime.current = data.savedTime 22 | savedUrl.current = data.savedUrl 23 | savedId.current = data.savedId 24 | } 25 | 26 | // When user presses search 27 | const performNewSearch = async newQuery => { 28 | if (!newQuery || newQuery === parser.search) return 29 | const newParser = new HtmlParse(newQuery) 30 | await newParser.parsePage() 31 | setParser(newParser) 32 | } 33 | 34 | return ( 35 | 46 | {children} 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /client/src/hooks/WindowSizeHook.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function useWindowSize() { 4 | const [innerHeight, setInnerHeight] = React.useState(window.innerHeight) 5 | const [innerWidth, setInnerWidth] = React.useState(window.innerWidth) 6 | 7 | const isPC = React.useMemo(() => innerWidth > 600, [innerWidth]) 8 | const flowDirection = React.useMemo(() => (innerWidth > innerHeight ? 'row' : 'column'), [innerHeight, innerWidth]) 9 | 10 | const updateSize = React.useCallback(() => { 11 | setInnerHeight(window.innerHeight) 12 | setInnerWidth(window.innerWidth) 13 | }, []) 14 | 15 | React.useEffect(() => { 16 | window.addEventListener('resize', updateSize) 17 | return () => window.removeEventListener('resize', updateSize) 18 | // eslint-disable-next-line react-hooks/exhaustive-deps 19 | }, []) 20 | 21 | return { innerHeight, innerWidth, isPC, flowDirection } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as Sentry from '@sentry/browser' 3 | import ReactDOM from 'react-dom' 4 | import { ThemeProvider } from 'styled-components' 5 | import ApolloClient, { InMemoryCache } from 'apollo-boost' 6 | import { ApolloProvider } from '@apollo/client' 7 | import StylesProvider from '@material-ui/styles/StylesProvider' 8 | import { MuiThemeProvider } from '@material-ui/core/styles' 9 | import { fetch } from 'whatwg-fetch' // Cypress still prefers XMLHttpRequest (xhr requests), so need to polyfill fetch 10 | import App from './components/App' 11 | import { darkTheme, muiTheme, GlobalStyle } from './helpers/themes' 12 | 13 | console.log(`React in ${process.env.NODE_ENV} mode`) 14 | if (process.env.NODE_ENV && process.env.NODE_ENV !== 'development') { 15 | Sentry.init({ dsn: 'https://cfc156ae965449309801e5a8973ece80@sentry.io/1493191' }) 16 | } 17 | 18 | const client = new ApolloClient({ 19 | // uri: /* '/', */ 'http://localhost:4000', // removed to default to '/graphql' 20 | cache: new InMemoryCache(), 21 | fetch, 22 | request: operation => { 23 | operation.setContext({ 24 | fetchOptions: { 25 | credentials: 'include', 26 | }, 27 | }) 28 | }, 29 | }) 30 | 31 | ReactDOM.render( 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | , 42 | document.getElementById('root'), 43 | ) 44 | -------------------------------------------------------------------------------- /client/src/queries/mutations.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-boost' 2 | 3 | export const CREATE_USER = gql` 4 | mutation CreateUserMutation($data: CreateUserInput!) { 5 | createUser(data: $data) { 6 | id 7 | gender 8 | lastActive 9 | age 10 | minAge 11 | maxAge 12 | audioPref 13 | } 14 | } 15 | ` 16 | export const UPDATE_USER = gql` 17 | mutation UpdateUserMutation($data: UserUpdateInput!) { 18 | updateUser(data: $data) { 19 | id 20 | gender 21 | age 22 | lookingFor { 23 | name 24 | } 25 | minAge 26 | maxAge 27 | audioPref 28 | accAudioPrefs { 29 | name 30 | } 31 | isHost 32 | isConnected 33 | matches { 34 | users { 35 | id 36 | gender 37 | age 38 | } 39 | hostId 40 | clientId 41 | endedAt 42 | createdAt 43 | } 44 | } 45 | } 46 | ` 47 | export const UPDATE_ADDRESSES = gql` 48 | mutation UpdateAddressesMutation($data: UpdateAddressesInput!) { 49 | updateAddresses(data: $data) { 50 | id 51 | } 52 | } 53 | ` 54 | 55 | export const CREATE_FEEDBACK = gql` 56 | mutation CreateFeedbackMutation($data: FeedbackCreateInput!) { 57 | createFeedback(data: $data) { 58 | id 59 | text 60 | } 61 | } 62 | ` 63 | 64 | export const CREATE_REPORT = gql` 65 | mutation CreateReportMutation($data: CreateReportInput!) { 66 | createReport(data: $data) { 67 | id 68 | } 69 | } 70 | ` 71 | 72 | export const CREATE_MATCH = gql` 73 | mutation CreateMatchMutation($data: CreateMatchInput!) { 74 | createMatch(data: $data) { 75 | id 76 | } 77 | } 78 | ` 79 | 80 | export const DISCONNECT_MATCH = gql` 81 | mutation DisconnectMatchMutation($data: DisconnectMatchInput!) { 82 | disconnectMatch(data: $data) { 83 | id 84 | } 85 | } 86 | ` 87 | -------------------------------------------------------------------------------- /client/src/queries/queries.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-boost' 2 | 3 | export const GET_USERS = gql` 4 | query Users { 5 | users { 6 | id 7 | gender 8 | lookingFor { 9 | name 10 | } 11 | updatedAt 12 | createdAt 13 | audioPref 14 | accAudioPrefs { 15 | name 16 | } 17 | } 18 | } 19 | ` 20 | 21 | export const GET_ME = gql` 22 | query GetMe { 23 | me { 24 | id 25 | gender 26 | lookingFor { 27 | name 28 | } 29 | age 30 | minAge 31 | maxAge 32 | audioPref 33 | accAudioPrefs { 34 | name 35 | } 36 | lastActive 37 | isHost 38 | isConnected 39 | reportsMade { 40 | type 41 | offender { 42 | id 43 | } 44 | } 45 | matches(orderBy: endedAt_DESC) { 46 | id 47 | endedAt 48 | createdAt 49 | disconnectType 50 | users { 51 | id 52 | gender 53 | age 54 | } 55 | } 56 | } 57 | } 58 | ` 59 | export const FIND_ROOM = gql` 60 | query FindRoom { 61 | findRoom { 62 | id 63 | gender 64 | lookingFor { 65 | name 66 | } 67 | age 68 | minAge 69 | maxAge 70 | audioPref 71 | accAudioPrefs { 72 | name 73 | } 74 | lastActive 75 | isHost 76 | isConnected 77 | } 78 | } 79 | ` 80 | -------------------------------------------------------------------------------- /e2e/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "func-names": "off", 4 | "import/no-unresolved": "off", 5 | "import/extensions": "off", 6 | "no-unused-expressions": "off" 7 | } 8 | } -------------------------------------------------------------------------------- /e2e/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "supportFile": "cypress/support/index.ts", 3 | "baseUrl": "http://127.0.0.1:3000", 4 | "testFiles": ["home_page_spec.ts", "chat_hub_spec.ts", "matchmaking_spec.ts", "in_call_spec.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /e2e/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /e2e/cypress/integration/chat_hub_spec.ts: -------------------------------------------------------------------------------- 1 | describe('chat_hub_spec', function () { 2 | before(() => { 3 | cy.clearCookies() 4 | 5 | const query = ` 6 | mutation CreateUserMutation($data: CreateUserInput!) { 7 | createUser(data: $data) { 8 | id 9 | } 10 | }` 11 | const variables = { 12 | data: { 13 | gender: 'F2M', 14 | lookingFor: { 15 | connect: [{ name: 'F2M' }, { name: 'M2F' }], 16 | }, 17 | age: 50, 18 | minAge: 40, 19 | maxAge: 60, 20 | audioPref: 'CONVERSATION', 21 | accAudioPrefs: { 22 | connect: [{ name: 'CONVERSATION' }, { name: 'CHAT_FIRST' }], 23 | }, 24 | }, 25 | } 26 | cy.request({ 27 | log: true, 28 | url: 'http://127.0.0.1:4000/graphql', 29 | method: 'POST', 30 | body: { query, variables }, 31 | }) 32 | 33 | cy.visit('/') 34 | }) 35 | 36 | it('Widgets behave correctly', function () { 37 | cy.getCookie('token').should('have.property', 'value') 38 | 39 | cy.dataCy('shareVideoButton').click() 40 | cy.server().route('POST', '/graphql').as('graphql') 41 | 42 | // Update user widget 43 | cy.dataCy('navUpdateUserButton').click() 44 | cy.dataCy('applyChangesButton').should('be.disabled') 45 | cy.dataCy('theirGenderPicker', '[data-cy="picker-active"]').should('have.length', 2) 46 | cy.dataCy('theirAgeSlider').should('contain.text', 40).should('contain.text', 60) 47 | cy.dataCy('myAudioSlider').contains('CONVERSATION').invoke('attr', 'data-cy').should('contain', 'slider-active') 48 | cy.dataCy('theirAudioPicker').scrollIntoView({ duration: 500 }) 49 | cy.dataCy('theirAudioPicker', '[data-cy="picker-active"]').should('have.length', 2) 50 | 51 | // Try changing and resetting 52 | cy.dataCy('theirAudioPicker').contains('CONVERSATION').as('conversation').click() 53 | cy.dataCy('applyChangesButton').should('be.enabled').wait(500) 54 | cy.get('@conversation').click() 55 | cy.dataCy('applyChangesButton').should('be.disabled') 56 | cy.get('@conversation').click() 57 | cy.dataCy('applyChangesButton').should('be.enabled').click() 58 | // .should('be.disabled'); //TODO Fix this 59 | 60 | // Stats widget 61 | cy.dataCy('navStatsButton').click().wait('@graphql') 62 | cy.dataCy('timeSelect').click() 63 | cy.contains('1 Hour').click() 64 | cy.dataCy('timeSelect').click() 65 | cy.contains('6 Hours').click() 66 | cy.dataCy('timeSelect').click() 67 | cy.contains('1 Day').click() 68 | cy.dataCy('timeSelect').click() 69 | cy.contains('6 Days').click() 70 | cy.dataCy('timeSelect').click() 71 | cy.contains('1 Month').click() 72 | 73 | // Matches widget 74 | cy.dataCy('navMatchesButton').click() 75 | cy.contains(/No Matches Yet/i) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /e2e/cypress/integration/home_page_spec.ts: -------------------------------------------------------------------------------- 1 | describe('home_page_spec', function () { 2 | before(() => { 3 | cy.clearCookies() 4 | cy.visit('/') 5 | }) 6 | 7 | it('Uses UI to adjust sliders and submit form', function () { 8 | cy.dataCy('myGenderSlider').contains('F2M').click() 9 | 10 | cy.dataCy('theirGenderPicker') 11 | .as('theirGenderPicker') 12 | .contains('MALE') 13 | .click() 14 | .get('@theirGenderPicker') 15 | .contains('FEMALE') 16 | .click() 17 | 18 | cy.dataCy('myAgeSlider', '[data-index="0"]') 19 | .trigger('mousedown') 20 | .trigger('mousemove', { clientX: 500, clientY: 0 }) 21 | .trigger('mouseup') 22 | 23 | cy.dataCy('theirAgeSlider', '[data-index="0"]') 24 | .trigger('mousedown') 25 | .trigger('mousemove', { clientX: 400, clientY: 0 }) 26 | .trigger('mouseup') 27 | 28 | cy.dataCy('theirAgeSlider', '[data-index="1"]') 29 | .trigger('mousedown') 30 | .trigger('mousemove', { clientX: 600, clientY: 0 }) 31 | .trigger('mouseup') 32 | 33 | cy.dataCy('myAudioSlider').contains('CONVERSATION').click() 34 | 35 | cy.dataCy('theirAudioPicker') 36 | .as('theirAudioPicker') 37 | .contains('NO AUDIO') 38 | .click() 39 | .get('@theirAudioPicker') 40 | .contains('MOANS') 41 | .click() 42 | 43 | cy.server() 44 | cy.route('POST', '/graphql').as('gql') 45 | cy.dataCy('startButton').click() 46 | cy.wait('@gql') 47 | 48 | cy.getCookie('token').should('have.property', 'value') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /e2e/cypress/integration/in_call_spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import SocketHelper from '../support/socketHelper' 3 | 4 | describe('in_call_spec', function () { 5 | let theirSocketHelper: SocketHelper 6 | let myToken: Cypress.Cookie | null 7 | 8 | before(() => { 9 | cy.clearCookies() 10 | 11 | // Create my user 12 | cy.createUser({}) 13 | cy.getCookie('token').then(t => { 14 | myToken = t 15 | }) 16 | // expect(myToken).to.haveOwnProperty('value'); 17 | 18 | // Get their user's stream/socket 19 | cy.window().then(async win => { 20 | const mediaStream = await win.navigator.mediaDevices.getUserMedia({ 21 | video: true, 22 | audio: true, 23 | }) 24 | theirSocketHelper = new SocketHelper(mediaStream) 25 | await theirSocketHelper.initializeSocket() 26 | }) 27 | 28 | cy.visit('/') 29 | cy.dataCy('shareVideoButton').click() 30 | 31 | // Create their user and have them try to join 32 | 33 | cy.clearCookies() 34 | 35 | cy.createUser({ gender: 'M2F', lookingFor: { connect: [{ name: 'F2M' }] } }) 36 | cy.updateUser({ isHost: true }).then(data => { 37 | const [resp] = data.allRequestResponses 38 | const { updateUser } = resp['Response Body'].data 39 | console.log('resp is', updateUser.id) 40 | theirSocketHelper.user = updateUser 41 | theirSocketHelper.emit('create or join', updateUser.id) 42 | }) 43 | 44 | cy.getCookie('token').then(() => { 45 | if (!myToken) throw new Error() 46 | cy.setCookie('token', myToken.value) 47 | }) 48 | 49 | // Next match and go in-call 50 | cy.server().route('POST', '/graphql').as('graphql') 51 | 52 | cy.dataCy('nextMatchButton').click().wait('@graphql') // connection countdown length 53 | cy.contains(/connection complete/i, { timeout: 10000 }).should('exist') 54 | 55 | cy.get('[data-cy=remoteVideo]', { timeout: 15000 }).should('exist') 56 | }) 57 | 58 | after(() => { 59 | theirSocketHelper.socket.close() 60 | }) 61 | 62 | it('Uses comment widget send/recieve and notifications', function () { 63 | cy.window().then(() => { 64 | theirSocketHelper.sendComment('I guess you have some Bukowski to depart?') 65 | }) 66 | cy.dataCy('navCommentButton').should('contain.text', '1').wait(1000).click() 67 | cy.dataCy('commentInput') 68 | .type('you can’t beat death but{enter}', { delay: 30 }) 69 | .type('you can beat death in life, sometimes.{enter}', { delay: 30 }) 70 | .type('and the more often you learn to do it,{enter}', { delay: 30 }) 71 | .type('the more light there will be.{enter}', { delay: 30 }) 72 | .wait(300) 73 | cy.window().then(() => { 74 | theirSocketHelper.sendComment('That was beautiful, gahhhh') 75 | }) 76 | cy.contains(/that was beautiful/i).wait(300) 77 | cy.dataCy('navCommentButton').click() 78 | }) 79 | 80 | it('Uses profile widget', function () { 81 | cy.dataCy('navProfileButton').click() 82 | // Make sure their gender is correct, if so the others should be correct 83 | cy.dataCy('profileGender').contains(/m2f/i).should('exist').wait(1000) 84 | cy.dataCy('navProfileButton').click() 85 | }) 86 | 87 | it('Uses countdown widget', function () { 88 | cy.window().then(() => { 89 | theirSocketHelper.sendCountdownUpdate('requestedCountdown') 90 | }) 91 | cy.dataCy('navCountdownButton').should('contain.text', '1').click().wait(1000) 92 | cy.dataCy('countdownStartButton').click() 93 | cy.dataCy('countdownText').contains(/go/i, { timeout: 20000 }).should('exist') 94 | cy.dataCy('countdownCancelButton').click() 95 | cy.wait(1000) 96 | cy.dataCy('navCountdownButton').click() 97 | }) 98 | 99 | it('Uses video player widget', function () { 100 | cy.dataCy('navPlayerButton').click() 101 | cy.window().then(() => { 102 | theirSocketHelper.sendPlayerSync('start') 103 | }) 104 | // No longer needed since video player defaults open 105 | // cy.dataCy('navPlayerButton').should('contain.text', '1').click().wait(1000) 106 | cy.dataCy('playerSyncButton').should('contain.text', 'Accept Sync').click().should('contain.text', 'Synced') 107 | 108 | cy.dataCy('navMicButton').click() 109 | cy.dataCy('navSpeakerButton').click() // Stop feedback from playing video 110 | 111 | cy.dataCy('playerSearchButton').click() 112 | cy.dataCy('playerSearchInput').type('spongebob') 113 | cy.dataCy('playerSearchSubmit').click() 114 | cy.dataCy('playerSearchResult').first().click() 115 | cy.get('[data-cy=playerVideo]', { timeout: 15000 }) 116 | .as('video') 117 | .then(() => { 118 | theirSocketHelper.sendPlayerUpdate({ type: 'seeked', currentTime: 60 }) 119 | }) 120 | .wait(1000) 121 | .get('@video') 122 | .then(video => { 123 | const videoEl = video.get(0) as HTMLVideoElement 124 | videoEl.muted = true 125 | console.log('playback rate is ', videoEl.playbackRate) 126 | expect(videoEl.currentTime).to.be.greaterThan(59) 127 | theirSocketHelper.sendPlayerUpdate({ type: 'pause', currentTime: 80 }) 128 | }) 129 | .wait(1000) 130 | .get('@video') 131 | .then(video => { 132 | const videoEl = video.get(0) as HTMLVideoElement 133 | videoEl.muted = true 134 | console.log('playback rate is ', videoEl.playbackRate) 135 | // expect(videoEl.playbackRate).to.equal(0); 136 | expect(videoEl.currentTime).to.be.greaterThan(79) 137 | theirSocketHelper.sendPlayerUpdate({ type: 'play', currentTime: 100 }) 138 | }) 139 | .wait(1000) 140 | .get('@video') 141 | .then(video => { 142 | const videoEl = video.get(0) as HTMLVideoElement 143 | videoEl.muted = true 144 | console.log('playback rate is ', videoEl.playbackRate) 145 | expect(videoEl.playbackRate).to.equal(1) 146 | expect(videoEl.currentTime).to.be.greaterThan(99) 147 | }) 148 | 149 | cy.dataCy('navPlayerButton').click() 150 | cy.dataCy('navStopButton').click().wait(2000) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /e2e/cypress/integration/matchmaking_spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import SocketHelper from '../support/socketHelper' 3 | 4 | describe('matchmaking_spec', function () { 5 | let theirSocketHelper: SocketHelper 6 | 7 | before(() => { 8 | cy.clearCookies() 9 | Cypress.Cookies.preserveOnce('token') 10 | 11 | // Create my user 12 | cy.createUser({}) 13 | 14 | // Get their user's stream/socket 15 | cy.window().then(async win => { 16 | const mediaStream = await win.navigator.mediaDevices.getUserMedia({ 17 | video: true, 18 | audio: true, 19 | }) 20 | theirSocketHelper = new SocketHelper(mediaStream) 21 | await theirSocketHelper.initializeSocket() 22 | }) 23 | 24 | cy.visit('/') 25 | cy.dataCy('shareVideoButton').click() 26 | }) 27 | 28 | it("doesn't match with users I don't want", function () { 29 | // Create their user and have them try to join 30 | cy.getCookie('token').then(token => { 31 | expect(token).to.exist 32 | if (!token) return 33 | 34 | cy.createUser({ gender: 'MALE' }) 35 | cy.updateUser({ isHost: true }).then(data => { 36 | const [resp] = data.allRequestResponses 37 | const { updateUser } = resp['Response Body'].data 38 | theirSocketHelper.emit('create or join', updateUser.id) 39 | }) 40 | 41 | cy.createUser({ age: 20 }) 42 | cy.updateUser({ isHost: true }).then(data => { 43 | const [resp] = data.allRequestResponses 44 | const { updateUser } = resp['Response Body'].data 45 | theirSocketHelper.emit('create or join', updateUser.id) 46 | }) 47 | 48 | cy.createUser({ audioPref: 'NO_AUDIO' }) 49 | cy.updateUser({ isHost: true }).then(data => { 50 | const [resp] = data.allRequestResponses 51 | const { updateUser } = resp['Response Body'].data 52 | theirSocketHelper.emit('create or join', updateUser.id) 53 | }) 54 | 55 | cy.getCookie('token') 56 | cy.setCookie('token', token.value) 57 | }) 58 | 59 | // Next match and go in-call 60 | cy.server().route('POST', '/graphql').as('graphql') 61 | 62 | cy.dataCy('nextMatchButton').click().wait('@graphql') // connection countdown length 63 | cy.contains(/no hosts found/i, { timeout: 6000 }).should('exist') 64 | 65 | Cypress.Cookies.preserveOnce('token') 66 | }) 67 | 68 | after(() => { 69 | theirSocketHelper.socket.close() 70 | }) 71 | 72 | it("doesn't match with users that don't want me", function () { 73 | // Create their user and have them try to join 74 | cy.getCookie('token').then(token => { 75 | expect(token).to.exist 76 | if (!token) return 77 | 78 | cy.createUser({ lookingFor: { connect: [{ name: 'MALE' }, { name: 'FEMALE' }] } }) 79 | cy.updateUser({ isHost: true }).then(data => { 80 | const [resp] = data.allRequestResponses 81 | const { updateUser } = resp['Response Body'].data 82 | theirSocketHelper.emit('create or join', updateUser.id) 83 | }) 84 | 85 | cy.createUser({ minAge: 20, maxAge: 49 }) 86 | cy.updateUser({ isHost: true }).then(data => { 87 | const [resp] = data.allRequestResponses 88 | const { updateUser } = resp['Response Body'].data 89 | theirSocketHelper.emit('create or join', updateUser.id) 90 | }) 91 | 92 | cy.createUser({ accAudioPrefs: { connect: [{ name: 'NO_AUDIO' }, { name: 'CHAT_FIRST' }] } }) 93 | cy.updateUser({ isHost: true }).then(data => { 94 | const [resp] = data.allRequestResponses 95 | const { updateUser } = resp['Response Body'].data 96 | theirSocketHelper.emit('create or join', updateUser.id) 97 | }) 98 | 99 | cy.getCookie('token') 100 | cy.setCookie('token', token.value) 101 | }) 102 | 103 | // Next match and go in-call 104 | cy.server().route('POST', '/graphql').as('graphql') 105 | 106 | cy.dataCy('nextMatchButton').click().wait('@graphql') // connection countdown length 107 | cy.contains(/no hosts found/i, { timeout: 6000 }).should('exist') 108 | 109 | Cypress.Cookies.preserveOnce('token') 110 | }) 111 | 112 | it('matches with a compatible user', function () { 113 | cy.getCookie('token').then(token => { 114 | expect(token).to.exist 115 | if (!token) return 116 | 117 | cy.createUser({ gender: 'M2F', lookingFor: { connect: [{ name: 'F2M' }] } }) 118 | cy.updateUser({ isHost: true }).then(data => { 119 | const [resp] = data.allRequestResponses 120 | const { updateUser } = resp['Response Body'].data 121 | console.log('resp is', updateUser.id) 122 | theirSocketHelper.user = updateUser 123 | theirSocketHelper.emit('create or join', updateUser.id) 124 | }) 125 | 126 | cy.getCookie('token').then(newToken => console.log('newToken', newToken)) 127 | cy.setCookie('token', token.value) 128 | }) 129 | 130 | // Next match and go in-call 131 | cy.server().route('POST', '/graphql').as('graphql') 132 | 133 | cy.dataCy('nextMatchButton').click().wait('@graphql') // connection countdown length 134 | cy.contains(/connection complete/i, { timeout: 6000 }).should('exist') 135 | cy.wait(8000) 136 | 137 | cy.get('[data-cy=remoteVideo]', { timeout: 15000 }).should('exist') 138 | cy.dataCy('navStopButton').click().wait(2000) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /e2e/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /// 3 | const wp = require('@cypress/webpack-preprocessor'); 4 | // *********************************************************** 5 | // This example plugins/index.js can be used to load plugins 6 | // 7 | // You can change the location of this file or turn off loading 8 | // the plugins file with the 'pluginsFile' configuration option. 9 | // 10 | // You can read more here: 11 | // https://on.cypress.io/plugins-guide 12 | // *********************************************************** 13 | 14 | // This function is called when a project is opened or re-opened (e.g. due to 15 | // the project's config changing) 16 | 17 | /** 18 | * @type {Cypress.PluginConfig} 19 | */ 20 | module.exports = (on, config) => { 21 | // `on` is used to hook into various events Cypress emits 22 | // `config` is the resolved Cypress config 23 | on('before:browser:launch', (browser = {}, launchOptions) => { 24 | // args.push('--use-fake-device-for-media-stream'); 25 | launchOptions.args.push('--use-fake-ui-for-media-stream'); 26 | launchOptions.args.push(`--use-file-for-fake-video-capture=${__dirname}\\miss_am_qcif.y4m`); 27 | 28 | return launchOptions; 29 | }); 30 | const options = { webpackOptions: require('../../webpack.config') }; 31 | on('file:preprocessor', wp(options)) 32 | }; -------------------------------------------------------------------------------- /e2e/cypress/plugins/miss_am_qcif.y4m: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baconcheese113/chat2gether/77f681b41a02deef4bfbf5a954ce4b849e966d77/e2e/cypress/plugins/miss_am_qcif.y4m -------------------------------------------------------------------------------- /e2e/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('dataCy', (dataCy: string, selector?: string) => { 2 | const operatorRegex = /^[\~\^\$\*]/; 3 | const operatorModifierArr = operatorRegex.exec(dataCy) || ['']; 4 | const dataCyStripped = dataCy.replace(operatorRegex, ''); 5 | const dataCySelector = `[data-cy${operatorModifierArr[0]}="${dataCyStripped}"]`; 6 | if (!selector) { 7 | return cy.get(dataCySelector); 8 | } 9 | 10 | const selectorIsPsuedo = selector.startsWith(':'); 11 | const joinChar = selectorIsPsuedo ? '' : ' '; 12 | const dataCyWithSelector = [dataCySelector, selector].join(joinChar); 13 | return cy.get(dataCyWithSelector); 14 | }); 15 | 16 | type Gender = 'F2M' | 'M2F' | 'MALE' | 'FEMALE'; 17 | type AudioPref = 'NO_AUDIO' | 'CONVERSATION' | 'CHAT_FIRST'; 18 | interface User { 19 | gender: string; 20 | lookingFor: { connect: { name: Gender }[] }; 21 | age: number; 22 | minAge: number; 23 | maxAge: number; 24 | audioPref: AudioPref; 25 | accAudioPrefs: { connect: { name: AudioPref }[] }; 26 | isHost: boolean; 27 | isConnected: boolean; 28 | } 29 | 30 | Cypress.Commands.add('createUser', (userPartial: Partial) => { 31 | const query = ` 32 | mutation CreateUserMutation($data: CreateUserInput!) { 33 | createUser(data: $data) { 34 | id 35 | } 36 | } 37 | `; 38 | const defaultVars = { 39 | gender: 'F2M', 40 | lookingFor: { 41 | connect: [{ name: 'F2M' }, { name: 'M2F' }] 42 | }, 43 | age: 50, 44 | minAge: 40, 45 | maxAge: 60, 46 | audioPref: 'CONVERSATION', 47 | accAudioPrefs: { 48 | connect: [{ name: 'CONVERSATION' }, { name: 'CHAT_FIRST' }] 49 | } 50 | }; 51 | const variables = { data: { ...defaultVars, ...userPartial } }; 52 | return cy.request({ 53 | log: true, 54 | url: '/graphql', 55 | method: 'POST', 56 | body: { query, variables } 57 | }); 58 | }); 59 | 60 | Cypress.Commands.add('updateUser', (userPartial: Partial) => { 61 | const query = ` 62 | mutation UpdateUserMutation($data: UserUpdateInput!) { 63 | updateUser(data: $data) { 64 | id 65 | gender 66 | age 67 | lookingFor { 68 | name 69 | } 70 | minAge 71 | maxAge 72 | audioPref 73 | accAudioPrefs { 74 | name 75 | } 76 | } 77 | } 78 | `; 79 | const variables = { data: userPartial }; 80 | 81 | return cy.request({ 82 | log: true, 83 | url: '/graphql', 84 | method: 'POST', 85 | body: { query, variables } 86 | }); 87 | }); 88 | 89 | declare namespace Cypress { 90 | interface Chainable { 91 | dataCy(dataCy: string, selector?: string): Chainable>; 92 | /** 93 | * Create a user (will send back a token and overwrite any existing token), pass in a partial to customize beyond the defaults 94 | */ 95 | createUser(userPartial: Partial): Chainable; 96 | /** 97 | * Update a user (uses the current token), pass in a partial to customize beyond the defaults 98 | */ 99 | updateUser(userPartial: Partial): Chainable; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /e2e/cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | import './commands' 2 | 3 | // Fail-fast-all-files 4 | before(function () { 5 | cy.getCookie('has-failed-test').then(cookie => { 6 | if (cookie && typeof cookie === 'object' && cookie.value === 'true') { 7 | const cypress: any = Cypress 8 | cypress.runner.stop() 9 | } 10 | }) 11 | }) 12 | 13 | // Fail-fast-single-file 14 | afterEach(function () { 15 | if (this.currentTest && this.currentTest.state === 'failed') { 16 | cy.setCookie('has-failed-test', 'true') 17 | const cypress: any = Cypress 18 | cypress.runner.stop() 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /e2e/cypress/support/socketHelper.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import io from 'socket.io-client'; 3 | 4 | interface ISocketHelper { 5 | initializeSocket: () => Promise; 6 | emit: (label: string, data: string | number | Object) => void; 7 | } 8 | 9 | interface User { 10 | id: string; 11 | } 12 | 13 | export default class SocketHelper implements ISocketHelper { 14 | public socket: any; 15 | public mediaStream: MediaStream; 16 | public roomId: string = ''; 17 | public user: User = { id: '' }; 18 | 19 | constructor(mediaStream: MediaStream) { 20 | this.socket = io(); 21 | this.mediaStream = mediaStream; 22 | } 23 | 24 | public async initializeSocket() { 25 | this.socket.on('ready', async (roomId: string) => { 26 | this.roomId = roomId; 27 | const pc = new RTCPeerConnection(); 28 | 29 | this.mediaStream.getTracks().forEach((track: MediaStreamTrack) => { 30 | pc.addTrack(track, this.mediaStream); 31 | }); 32 | 33 | pc.onicecandidate = (e: RTCPeerConnectionIceEvent) => { 34 | if (e.candidate) { 35 | this.emit('candidate', { 36 | type: 'candidate', 37 | label: e.candidate.sdpMLineIndex, 38 | id: e.candidate.sdpMid, 39 | candidate: e.candidate.candidate, 40 | roomId 41 | }); 42 | } 43 | }; 44 | 45 | pc.ontrack = async (e: RTCTrackEvent) => { 46 | console.log('their user is', this.user); 47 | this.emit('identity', { user: this.user, roomId }); 48 | }; 49 | 50 | const offer = await pc.createOffer(); 51 | pc.setLocalDescription(offer); 52 | this.emit('offer', { 53 | type: 'offer', 54 | sdp: offer, 55 | roomId 56 | }); 57 | 58 | this.socket.on('answer', (e: RTCSessionDescriptionInit) => { 59 | pc.setRemoteDescription(new RTCSessionDescription(e)); 60 | }); 61 | 62 | this.socket.on('candidate', (e: { label: number; candidate: string }) => { 63 | pc.addIceCandidate( 64 | new RTCIceCandidate({ 65 | sdpMLineIndex: e.label, 66 | candidate: e.candidate 67 | }) 68 | ); 69 | }); 70 | }); 71 | } 72 | 73 | public emit(label: string, data: string | number | Object) { 74 | console.log(this.user.id, ' is emitting ', label, ' -> ', data); 75 | this.socket.emit(label, data); 76 | } 77 | 78 | public sendComment(text: string) { 79 | this.emit('send', { 80 | text, 81 | userId: this.user.id, 82 | roomId: this.roomId 83 | }); 84 | } 85 | 86 | public sendPlayerSync(type: string) { 87 | this.emit('videoPlayerSync', { 88 | type, 89 | userId: this.user.id, 90 | roomId: this.roomId 91 | }); 92 | } 93 | 94 | /** 95 | * Perform html video element function where currentTime is in seconds 96 | */ 97 | public sendPlayerUpdate(partial: { type: 'play' | 'pause' | 'seeked'; currentTime: number }) { 98 | this.emit('videoPlayerUpdate', { 99 | ...partial, 100 | userId: this.user.id, 101 | roomId: this.roomId 102 | }); 103 | } 104 | 105 | public sendCountdownUpdate(type: 'requestedCountdown' | 'startedCountdown' | 'cancelledCountdown') { 106 | this.emit('countdown', { 107 | type, 108 | userId: this.user.id, 109 | roomId: this.roomId 110 | }); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "cypress:open": "cypress open", 8 | "build": "webpack --output-filename out.js --entry ./integration/*.ts", 9 | "cypress:run": "cypress run", 10 | "tsc": "tsc --pretty --noEmit" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@babel/core": "^7.10.3", 16 | "@babel/preset-env": "^7.10.3", 17 | "@cypress/webpack-preprocessor": "^5.4.1", 18 | "babel-loader": "^8.1.0", 19 | "cypress": "^4.8.0", 20 | "ts-loader": "^7.0.5", 21 | "typescript": "^3.9.5", 22 | "webpack": "^4.43.0" 23 | }, 24 | "dependencies": { 25 | "socket.io-client": "^2.3.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "module": "ES2015", 5 | "lib": ["es5", "dom", "es6", "es2015", "es7", "es2016", "es2017", "esnext"], 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "baseUrl": "./node_modules", 9 | "typeRoots": ["./support"], 10 | "types": ["cypress"] 11 | }, 12 | "include": ["**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /e2e/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'development', 3 | // make sure the source maps work 4 | devtool: 'eval-source-map', 5 | // webpack will transpile TS and JS files 6 | resolve: { 7 | extensions: ['.ts', '.js'], 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | // every time webpack sees a TS file (except for node_modules) 13 | // webpack will use "ts-loader" to transpile it to JavaScript 14 | test: /\.ts$/, 15 | exclude: [/node_modules/], 16 | use: [ 17 | { 18 | loader: 'ts-loader', 19 | options: { 20 | // skip typechecking for speed 21 | transpileOnly: false, 22 | }, 23 | }, 24 | ], 25 | }, 26 | ], 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat2gether", 3 | "version": "1.0.0", 4 | "description": "", 5 | "engines": { 6 | "node": "12.4.0" 7 | }, 8 | "type": "module", 9 | "main": "index.js", 10 | "scripts": { 11 | "test": "cd e2e && npm run cypress:run", 12 | "start:server": "cd server && npm start", 13 | "start:client": "cd client && npm start", 14 | "build:server": "cd server && npm run build", 15 | "build:client": "cd client && npm install && npm install --only=dev --no-shrinkwrap && npm run build", 16 | "start": "npm run start:server && npm run start:client", 17 | "build": "npm run build:server", 18 | "heroku-postbuild": "npm run build:client && npm run build:server", 19 | "lint": "eslint --fix ." 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "devDependencies": { 24 | "babel-eslint": "^10.1.0", 25 | "eslint": "^6.8.0", 26 | "eslint-config-airbnb": "^18.2.0", 27 | "eslint-config-prettier": "^6.11.0", 28 | "eslint-plugin-import": "^2.21.2", 29 | "eslint-plugin-jsx-a11y": "^6.3.1", 30 | "eslint-plugin-prettier": "^3.1.4", 31 | "eslint-plugin-react": "^7.20.0", 32 | "eslint-plugin-react-hooks": "^4.0.4", 33 | "prettier": "^2.0.5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat2gether-server", 3 | "version": "1.0.0", 4 | "description": "Server for project", 5 | "repository": { 6 | "directory": "https://github.com/baconcheese113/chat2gether" 7 | }, 8 | "main": "index.js", 9 | "type": "module", 10 | "scripts": { 11 | "start": "node --experimental-modules --es-module-specifier-resolution=node --async-stack-traces src/index.js", 12 | "build": "npm install && npm install --only=dev --no-shrinkwrap", 13 | "test": "env-cmd -f ./config/test.env jest --watch --runInBand", 14 | "dev": "env-cmd -f ./config/dev.env nodemon --experimental-modules --es-module-specifier-resolution=node --async-stack-traces --inspect src --ext .js,.json,.graphql" 15 | }, 16 | "jest": { 17 | "globalSetup": "./tests/jest/globalSetup.js", 18 | "globalTeardown": "./tests/jest/globalTeardown.js" 19 | }, 20 | "author": "", 21 | "license": "ISC", 22 | "dependencies": { 23 | "apollo-server-express": "^2.13.0", 24 | "cookie-parser": "^1.4.5", 25 | "ejs": "^3.1.2", 26 | "express": "^4.17.1", 27 | "express-fingerprint": "^1.1.3", 28 | "graphql": "^14.6.0", 29 | "graphql-import": "^0.7.1", 30 | "is-localhost-ip": "^1.4.0", 31 | "jsonwebtoken": "^8.5.1", 32 | "prisma-binding": "^2.3.16", 33 | "socket.io": "^2.3.0" 34 | }, 35 | "devDependencies": { 36 | "env-cmd": "^10.1.0", 37 | "nodemon": "^2.0.3" 38 | }, 39 | "optionalDependencies": { 40 | "bufferutil": "^4.0.1", 41 | "utf-8-validate": "^5.0.2" 42 | }, 43 | "resolutions": { 44 | "graphql": "^14.6.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/prisma/datamodel.graphql: -------------------------------------------------------------------------------- 1 | type User { 2 | id: ID! @unique @id 3 | ip: String! 4 | addresses: String 5 | fingerprint: String! 6 | gender: GenderType! 7 | lookingFor: [GenderObject!]! 8 | age: Int! 9 | minAge: Int! 10 | maxAge: Int! 11 | audioPref: AudioPrefType! 12 | accAudioPrefs: [AudioPrefObject!]! 13 | lastActive: DateTime! 14 | isHost: Boolean! 15 | isConnected: Boolean! 16 | visited: [User!]! @relation(name: "VisitedUsers", onDelete: SET_NULL) 17 | matches: [Match!]! @relation(name: "MatchedUsers") 18 | reportsMade: [Report!]! @relation(name: "ReportsMade") 19 | reportsReceived: [Report!]! @relation(name: "ReportsReceived") 20 | updatedAt: DateTime! @updatedAt 21 | createdAt: DateTime! @createdAt 22 | } 23 | 24 | type Feedback { 25 | id: ID! @unique @id 26 | text: String! 27 | updatedAt: DateTime! @updatedAt 28 | createdAt: DateTime! @createdAt 29 | } 30 | 31 | type Report { 32 | id: ID! @unique @id 33 | type: ReportType! 34 | reporter: User! @relation(name: "ReportsMade") 35 | offender: User! @relation(name: "ReportsReceived") 36 | updatedAt: DateTime! @updatedAt 37 | createdAt: DateTime! @createdAt 38 | } 39 | 40 | type Ban { 41 | id: ID! @unique @id 42 | user: User! 43 | reason: ReportType! 44 | startAt: DateTime! 45 | endAt: DateTime! 46 | updatedAt: DateTime! @updatedAt 47 | createdAt: DateTime! @createdAt 48 | } 49 | 50 | enum ReportType { 51 | UNDERAGE 52 | ABUSIVE 53 | NO_VIDEO 54 | FALSE_AGE 55 | FALSE_SEX 56 | FALSE_AUDIO 57 | } 58 | 59 | type Match { 60 | id: ID! @unique @id 61 | users: [User!]! @relation(name: "MatchedUsers") 62 | disconnectType: DisconnectType 63 | hostId: ID! 64 | clientId: ID! 65 | creatorId: ID! 66 | disconnectorId: ID 67 | hasEnded: Boolean! @default(value: false) 68 | endedAt: DateTime 69 | updatedAt: DateTime! @updatedAt 70 | createdAt: DateTime! @createdAt 71 | } 72 | 73 | enum DisconnectType { 74 | REFRESH 75 | STOP 76 | NEXT_MATCH 77 | REPORT 78 | } 79 | 80 | type GenderObject { 81 | id: ID! @unique @id 82 | name: GenderType! @unique 83 | } 84 | 85 | enum GenderType { 86 | MALE 87 | FEMALE 88 | M2F 89 | F2M 90 | } 91 | 92 | type AudioPrefObject { 93 | id: ID! @unique @id 94 | name: AudioPrefType! @unique 95 | } 96 | 97 | enum AudioPrefType { 98 | NO_AUDIO 99 | MOANS 100 | CONVERSATION 101 | CHAT_FIRST 102 | } -------------------------------------------------------------------------------- /server/prisma/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | prisma: 4 | image: prismagraphql/prisma:1.34.0 5 | restart: always 6 | ports: 7 | - '4466:4466' 8 | environment: 9 | PRISMA_CONFIG: | 10 | port: 4466 11 | # uncomment the next line and provide the env var PRISMA_MANAGEMENT_API_SECRET=my-secret to activate cluster security 12 | # managementApiSecret: my-secret 13 | databases: 14 | default: 15 | connector: postgres 16 | host: {$env:PG_HOST} 17 | database: {$env:PG_DATABASE} 18 | ssl: true 19 | user: {$env:PG_USER} 20 | password: {$env:PG_PASSWORD} 21 | rawAccess: true 22 | port: '5432' 23 | migrations: true 24 | -------------------------------------------------------------------------------- /server/prisma/prisma.yml: -------------------------------------------------------------------------------- 1 | endpoint: ${env:PRISMA_ENDPOINT} 2 | datamodel: datamodel.graphql 3 | secret: ${env:PRISMA_SECRET} 4 | 5 | generate: 6 | - generator: graphql-schema 7 | output: ../src/generated/prisma.graphql 8 | 9 | hooks: 10 | post-deploy: 11 | - prisma generate 12 | - echo "Deployment finished" 13 | 14 | seed: 15 | run: node ./seed.js 16 | - echo "Seeded DB" 17 | -------------------------------------------------------------------------------- /server/prisma/seed.js: -------------------------------------------------------------------------------- 1 | const { Prisma } = require('prisma-binding') 2 | 3 | const prisma = new Prisma({ 4 | typeDefs: '../src/generated/prisma.graphql', 5 | endpoint: process.env.PRISMA_ENDPOINT, 6 | secret: process.env.PRISMA_SECRET, 7 | }) 8 | 9 | const setup = async () => { 10 | const genders = ['MALE', 'FEMALE', 'M2F', 'F2M'] 11 | const audioPrefs = ['NO_AUDIO', 'MOANS', 'CONVERSATION', 'CHAT_FIRST'] 12 | 13 | genders.forEach(async gender => { 14 | await prisma.mutation.createGenderObject({ data: { name: gender } }) 15 | }) 16 | audioPrefs.forEach(async audioPref => { 17 | await prisma.mutation.createAudioPrefObject({ data: { name: audioPref } }) 18 | }) 19 | } 20 | 21 | // Remember to call setup method in the end 22 | setup() 23 | -------------------------------------------------------------------------------- /server/src/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import http from 'http'; 3 | import express from 'express'; 4 | import jwt from 'jsonwebtoken'; 5 | import cookieParser from 'cookie-parser'; 6 | import Fingerprint from 'express-fingerprint'; 7 | import SocketIO from 'socket.io'; 8 | import server from './server'; 9 | import socket from './socket'; 10 | 11 | // eslint-disable-next-line prettier/prettier 12 | const __dirname = path.join(path.dirname(decodeURI(new URL(import.meta.url).pathname))); 13 | 14 | const app = express(); 15 | const httpServer = http.createServer(app); 16 | 17 | const io = new SocketIO(httpServer); 18 | socket(io); 19 | 20 | console.log(`env is ${process.env.IS_UNDER_CONSTRUCTION}`); 21 | if (process.env.IS_UNDER_CONSTRUCTION === 'true') { 22 | app.set('view engine', 'ejs') 23 | app.set('views', `${__dirname}/views`) 24 | app.get('*', async (req, res) => { 25 | res.render('construction', { welcomeMessage: process.env.REACT_APP_WELCOME_MESSAGE }) 26 | }) 27 | } else if (process.env.NODE_ENV === 'production') { 28 | app.use(express.static(path.join(__dirname, '../../client/build'))); 29 | app.get('/', (req, res) => { 30 | res.sendFile(path.join(__dirname, '../../client/build', 'index.html')); 31 | }); 32 | } else { 33 | app.get('/', (req, res) => { 34 | res.sendFile(path.join(__dirname, '../../client/build', 'index.html')); 35 | }); 36 | } 37 | 38 | app.use(cookieParser()); 39 | 40 | app.use((req, res, next) => { 41 | if (!req.cookies || !req.cookies.token) { 42 | return next(); 43 | } 44 | const { token } = req.cookies; 45 | if (token) { 46 | const { userId } = jwt.verify(token, process.env.JWT_SECRET); 47 | // Put the userId onto the req for future requests to access 48 | req.userId = userId; 49 | } 50 | next(); 51 | }); 52 | 53 | app.use( 54 | Fingerprint({ 55 | parameters: [ 56 | // Defaults 57 | Fingerprint.useragent, 58 | Fingerprint.acceptHeaders, 59 | Fingerprint.geoip 60 | ] 61 | }) 62 | ); 63 | 64 | server.applyMiddleware({ 65 | app, 66 | path: '/graphql', 67 | cors: { 68 | credentials: true, 69 | origin: `${process.env.DOMAIN_FULL}:${process.env.PORT}` || '3000' 70 | } 71 | }); 72 | 73 | httpServer.listen( 74 | { 75 | port: process.env.PORT || 4000, 76 | host: '0.0.0.0' 77 | }, 78 | () => console.log(`Server is running on ${server.graphqlPath}`) 79 | ); 80 | -------------------------------------------------------------------------------- /server/src/prisma.js: -------------------------------------------------------------------------------- 1 | import Prisma from 'prisma-binding' 2 | import { fragmentReplacements } from './resolvers/index' 3 | 4 | export default new Prisma.Prisma({ 5 | typeDefs: 'src/generated/prisma.graphql', 6 | endpoint: process.env.PRISMA_ENDPOINT, 7 | secret: process.env.PRISMA_SECRET, 8 | fragmentReplacements, 9 | }) 10 | -------------------------------------------------------------------------------- /server/src/resolvers/Mutation.js: -------------------------------------------------------------------------------- 1 | import isLocalhost from 'is-localhost-ip' 2 | import getUserId from '../utils/getUserId' 3 | import generateToken from '../utils/generateToken' 4 | 5 | export default { 6 | async createUser(parent, args, { prisma, request }, info) { 7 | const lastActive = new Date().toISOString() 8 | let ip = request.req.connection.remoteAddress 9 | 10 | if (request.req.headers && request.req.headers['x-forwarded-for']) { 11 | ;[ip] = request.req.headers['x-forwarded-for'].split(',') 12 | } 13 | const user = await prisma.mutation.createUser({ 14 | data: { 15 | ...args.data, 16 | lastActive, 17 | isHost: false, 18 | isConnected: false, 19 | ip, 20 | fingerprint: request.req.fingerprint.hash, 21 | }, 22 | info, 23 | }) 24 | const token = generateToken(user.id) 25 | const options = { 26 | maxAge: 1000 * 60 * 60 * 72, // expires in 3 days 27 | // httpOnly: true, // cookie is only accessible by the server 28 | // secure: process.env.NODE_ENV === 'prod', // only transferred over https 29 | // sameSite: true, // only sent for requests to the same FQDN as the domain in the cookie 30 | } 31 | console.log('user created: ', user.id) 32 | request.res.cookie('token', token, options) 33 | return user 34 | }, 35 | async updateUser(parent, args, { prisma, request }, info) { 36 | const userId = getUserId(request) 37 | 38 | return prisma.mutation.updateUser( 39 | { 40 | where: { id: userId }, 41 | data: args.data, 42 | }, 43 | info, 44 | ) 45 | }, 46 | async updateAddresses(parent, args, { prisma, request }, info) { 47 | const userId = getUserId(request) 48 | const inputAddresses = await args.data.addresses.reduce(async (prev, a) => { 49 | const isLocal = await isLocalhost(a) 50 | console.log(`${a} is${isLocal ? '' : ' not'} local`) 51 | if (isLocal) return prev 52 | return [...(await prev), a] 53 | }, []) 54 | console.log('arg data', args.data) 55 | console.log('inputAddresses', inputAddresses) 56 | 57 | if (!inputAddresses.length) { 58 | return prisma.query.user({ where: { id: userId } }, info) 59 | } 60 | const me = await prisma.query.user({ where: { id: userId } }, `{ addresses }`) 61 | const existingAddresses = me.addresses || '' 62 | console.log('existingAddresses are ', existingAddresses) 63 | console.log('set of inputAddresses are ', new Set(inputAddresses)) 64 | const newAddresses = [ 65 | ...existingAddresses.split(',').reduce((set, a) => (a ? set.add(a) : set), new Set(inputAddresses)), 66 | ] 67 | console.log('newAddresses', newAddresses.join(',')) 68 | return prisma.mutation.updateUser({ where: { id: userId }, data: { addresses: newAddresses.join(',') } }, info) 69 | }, 70 | async createFeedback(parent, args, { prisma }, info) { 71 | return prisma.mutation.createFeedback( 72 | { 73 | data: { ...args.data }, 74 | }, 75 | info, 76 | ) 77 | }, 78 | 79 | async createReport(parent, args, { prisma, request }, info) { 80 | const userId = getUserId(request) 81 | 82 | const { type, offenderId } = args.data 83 | const offender = await prisma.query.user( 84 | { where: { id: offenderId } }, 85 | `{reportsReceived { type reporter { id } }}`, 86 | ) 87 | if (!offender) { 88 | throw new Error('Offender not valid') 89 | } 90 | // if reported before, throw error 91 | if (offender.reportsReceived.some(r => r.reporter.id === userId)) { 92 | throw new Error('Already reported this user') 93 | } 94 | // createReport 95 | const report = await prisma.mutation.createReport( 96 | { data: { type, offender: { connect: { id: offenderId } }, reporter: { connect: { id: userId } } } }, 97 | info, 98 | ) 99 | // if >=2 reports for same type, create ban 100 | const reportsOfThisType = offender.reportsReceived.filter(r => r.type === type) 101 | if (reportsOfThisType.length >= 2) { 102 | // HAMMER TIME 103 | const now = new Date() 104 | const endAt = new Date() 105 | endAt.setMinutes(now.getMinutes() + 15) 106 | await prisma.mutation.createBan({ 107 | data: { 108 | reason: type, 109 | startAt: now.toISOString(), 110 | endAt: endAt.toISOString(), 111 | user: { connect: { id: offenderId } }, 112 | }, 113 | }) 114 | } 115 | 116 | return report 117 | }, 118 | 119 | async createMatch(parent, args, { prisma, request }, info) { 120 | const userId = getUserId(request) 121 | 122 | const { otherUserId } = args.data 123 | const usersArr = [userId, otherUserId] 124 | // determine if there's already an active match between these two users 125 | const existingMatches = await prisma.query.matches( 126 | { 127 | where: { hasEnded: false, hostId_in: usersArr, clientId_in: usersArr }, 128 | }, 129 | info, 130 | ) 131 | console.log('User/Host is ', userId, ' and client is ', otherUserId, ' existing matches -> ', existingMatches) 132 | if (existingMatches && existingMatches.length) return existingMatches[0] 133 | // determine if user is host or not 134 | const me = await prisma.query.user({ where: { id: userId } }, `{ isHost }`) 135 | const hostId = me.isHost ? userId : otherUserId 136 | const clientId = me.isHost ? otherUserId : userId 137 | // create and return new match 138 | return prisma.mutation.createMatch( 139 | { data: { hostId, clientId, creatorId: userId, users: { connect: [{ id: hostId }, { id: clientId }] } } }, 140 | info, 141 | ) 142 | }, 143 | 144 | async disconnectMatch(parent, args, { prisma, request }, info) { 145 | const userId = getUserId(request) 146 | 147 | const { id, type } = args.data 148 | // get matches where id and user is in users array 149 | const matches = await prisma.query.matches({ where: { id, users_some: { id: userId } } }) 150 | if (!matches) throw new Error('Cannot disconnect that which does not exist') 151 | console.log('disconnect matches ', matches) 152 | 153 | if (matches[0].hasEnded) { 154 | return { updateMatch: matches[0] } 155 | } 156 | // return updated match with type, disconnectorId, and endedAt 157 | try { 158 | const updateMatch = await prisma.mutation.updateMatch( 159 | { 160 | where: { id }, 161 | data: { disconnectType: type, disconnectorId: userId, endedAt: new Date(), hasEnded: true }, 162 | }, 163 | info, 164 | ) 165 | return updateMatch 166 | } catch (e) { 167 | console.log('error disconnecting ', e) 168 | } 169 | return { updateMatch: { ...matches } } 170 | }, 171 | } 172 | -------------------------------------------------------------------------------- /server/src/resolvers/Query.js: -------------------------------------------------------------------------------- 1 | import getUserId from '../utils/getUserId' 2 | 3 | export default { 4 | async users(parent, args, { prisma, request }, info) { 5 | const userId = getUserId(request, false) 6 | const opArgs = { 7 | first: args.first, 8 | skip: args.skip, 9 | after: args.after, 10 | orderBy: args.orderBy, 11 | where: args.where, 12 | } 13 | const users = await prisma.query.users(opArgs, info) 14 | return users.filter(user => user.id !== userId) 15 | }, 16 | me(parent, args, { prisma, request }, info) { 17 | const userId = getUserId(request) 18 | return prisma.query.user( 19 | { 20 | where: { id: userId }, 21 | }, 22 | info, 23 | ) 24 | }, 25 | async findRoom(parent, args, { prisma, request }, info) { 26 | const userId = getUserId(request) 27 | const user = await prisma.query.user( 28 | { where: { id: userId } }, 29 | `{ 30 | ip 31 | gender 32 | lookingFor { name } 33 | age 34 | minAge 35 | maxAge 36 | audioPref 37 | accAudioPrefs { name } 38 | }`, 39 | ) 40 | // Make sure they're not banned 41 | const bans = await prisma.query.bans({ 42 | where: { user: { OR: [{ id: userId }, { ip: user.ip }] }, endAt_gte: new Date() }, 43 | }) 44 | if (bans.length) { 45 | throw Error(`Please contact an admin`) 46 | } 47 | const d = new Date() 48 | d.setMinutes(d.getMinutes() - 0.25) 49 | const potentialMatch = await prisma.query.users( 50 | { 51 | where: { 52 | AND: [ 53 | { id_not: userId }, 54 | { gender_in: user.lookingFor.map(x => x.name) }, 55 | { lookingFor_some: { name: user.gender } }, 56 | { minAge_lte: user.age }, 57 | { maxAge_gte: user.age }, 58 | { age_lte: user.maxAge }, 59 | { age_gte: user.minAge }, 60 | { audioPref_in: user.accAudioPrefs.map(x => x.name) }, 61 | { accAudioPrefs_some: { name: user.audioPref } }, 62 | { isHost: true }, 63 | { isConnected: false }, 64 | { matches_none: { users_some: { id: userId }, disconnectType_not: 'REFRESH' } }, 65 | { updatedAt_gt: d.toISOString() }, 66 | ], 67 | }, 68 | orderBy: 'updatedAt_DESC', 69 | first: 1, 70 | }, 71 | info, 72 | ) 73 | console.log('potentialMatch', potentialMatch) 74 | const [match] = potentialMatch 75 | return match 76 | }, 77 | matches(parent, args, { prisma, request }, info) { 78 | getUserId(request) 79 | 80 | return prisma.query.matches(args, info) 81 | }, 82 | } 83 | -------------------------------------------------------------------------------- /server/src/resolvers/Subscription.js: -------------------------------------------------------------------------------- 1 | // import getUserId from "../utils/getUserId"; 2 | 3 | export default {} 4 | -------------------------------------------------------------------------------- /server/src/resolvers/User.js: -------------------------------------------------------------------------------- 1 | // import getUserId from '../utils/getUserId' 2 | 3 | export default { 4 | // gender: { 5 | // fragment: 'fragment userId on User { id }', 6 | // resolve(parent, args, {request}, info) { 7 | // const userId = getUserId(request, false) 8 | // return "FEMALE" 9 | // // if(userId && userId === parent.id) { 10 | // // return parent.email 11 | // // } else { 12 | // // return null 13 | // // } 14 | // } 15 | // }, 16 | } 17 | -------------------------------------------------------------------------------- /server/src/resolvers/index.js: -------------------------------------------------------------------------------- 1 | import Prisma from 'prisma-binding' 2 | import Query from './Query' 3 | import Mutation from './Mutation' 4 | // import Subscription from './Subscription'; 5 | import User from './User' 6 | 7 | const resolvers = { 8 | Query, 9 | Mutation, 10 | // Subscription, 11 | User, 12 | } 13 | 14 | const fragmentReplacements = Prisma.extractFragmentReplacements(resolvers) 15 | 16 | export { resolvers, fragmentReplacements } 17 | -------------------------------------------------------------------------------- /server/src/schema.graphql: -------------------------------------------------------------------------------- 1 | # import * from './generated/prisma.graphql' 2 | 3 | # typedefs 4 | type Query { 5 | users( 6 | where: UserWhereInput 7 | orderBy: UserOrderByInput 8 | skip: Int 9 | after: String 10 | before: String 11 | first: Int 12 | last: Int 13 | ): [User!]! 14 | user(where: UserWhereUniqueInput!): User 15 | me: User! 16 | findRoom: User 17 | matches( 18 | where: MatchWhereInput 19 | orderBy: MatchOrderByInput 20 | skip: Int 21 | after: String 22 | before: String 23 | first: Int 24 | last: Int 25 | ): [Match!]! 26 | } 27 | 28 | type Mutation { 29 | createUser(data: CreateUserInput!): User! 30 | updateUser(data: UserUpdateInput!): User! 31 | updateAddresses(data: UpdateAddressesInput!): User! 32 | createFeedback(data: FeedbackCreateInput!): Feedback! 33 | createReport(data: CreateReportInput!): Report! 34 | createMatch(data: CreateMatchInput!): Match! 35 | disconnectMatch(data: DisconnectMatchInput!): Match! 36 | } 37 | 38 | # type Subscription { 39 | 40 | # } 41 | 42 | type GenderObject { 43 | id: ID! 44 | name: GenderType! 45 | } 46 | type AudioPrefObject { 47 | id: ID! 48 | name: AudioPrefType! 49 | } 50 | input DisconnectMatchInput { 51 | id: ID! 52 | type: DisconnectType! 53 | } 54 | input CreateMatchInput { 55 | otherUserId: ID! 56 | } 57 | input UpdateAddressesInput { 58 | addresses: [String!]! 59 | } 60 | input CreateReportInput { 61 | type: ReportType! 62 | offenderId: ID! 63 | } 64 | 65 | input CreateUserInput { 66 | gender: GenderType! 67 | lookingFor: GenderObjectCreateManyInput 68 | age: Int! 69 | minAge: Int! 70 | maxAge: Int! 71 | audioPref: AudioPrefType! 72 | accAudioPrefs: AudioPrefObjectCreateManyInput 73 | } 74 | type User { 75 | id: ID! 76 | gender: GenderType! 77 | lookingFor: [GenderObject!]! 78 | age: Int! 79 | minAge: Int! 80 | maxAge: Int! 81 | audioPref: AudioPrefType! 82 | accAudioPrefs: [AudioPrefObject!]! 83 | lastActive: String! 84 | isHost: Boolean! 85 | isConnected: Boolean! 86 | matches(orderBy: MatchOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Match!]! 87 | reportsMade: [Report!] 88 | reportsReceived: [Report!] 89 | updatedAt: String! 90 | createdAt: String! 91 | } 92 | enum MutationType { 93 | CREATED 94 | UPDATED 95 | DELETED 96 | } 97 | enum GenderType { 98 | MALE 99 | FEMALE 100 | M2F 101 | F2M 102 | } 103 | enum AudioPrefType { 104 | NO_AUDIO 105 | MOANS 106 | CONVERSATION 107 | CHAT_FIRST 108 | } 109 | 110 | type Feedback { 111 | id: ID! 112 | text: String! 113 | updatedAt: String! 114 | createdAt: String! 115 | } 116 | -------------------------------------------------------------------------------- /server/src/server.js: -------------------------------------------------------------------------------- 1 | import ApolloServerExpress from 'apollo-server-express' 2 | import GQLImport from 'graphql-import' 3 | import { resolvers, fragmentReplacements } from './resolvers/index' 4 | import prisma from './prisma' 5 | 6 | // const pubsub = new ApolloServerExpress.PubSub() 7 | 8 | export default new ApolloServerExpress.ApolloServer({ 9 | typeDefs: GQLImport.importSchema('./src/schema.graphql'), 10 | resolvers, 11 | context(request) { 12 | return { prisma, request } 13 | }, 14 | fragmentReplacements, 15 | // debug: true, 16 | }) 17 | -------------------------------------------------------------------------------- /server/src/socket.js: -------------------------------------------------------------------------------- 1 | export default io => { 2 | io.on('connection', socket => { 3 | socket.on('create or join', roomId => { 4 | socket.leave(socket.room) 5 | socket.join(roomId) 6 | const numClients = io.sockets.adapter.rooms[roomId].length 7 | console.log(`${numClients} clients connected in room ${roomId}`) 8 | if (numClients > 2) { 9 | // Trying to join a full room 10 | socket.leave(roomId) 11 | socket.emit('full room', roomId) 12 | return 13 | } 14 | if (numClients === 1) { 15 | // eslint-disable-next-line no-param-reassign 16 | socket.username = 'host' 17 | socket.emit('created', roomId) 18 | console.log('emitted create') 19 | } else { 20 | // eslint-disable-next-line no-param-reassign 21 | socket.username = 'client' 22 | socket.emit('joined', roomId) 23 | } 24 | }) 25 | socket.on('ready', roomId => { 26 | console.log('ready', roomId) 27 | socket.to(roomId).emit('ready', roomId) 28 | }) 29 | socket.on('candidate', msg => { 30 | // console.log('candidate'); 31 | socket.to(msg.roomId).emit('candidate', msg) 32 | }) 33 | socket.on('offer', msg => { 34 | console.log('offer') 35 | socket.to(msg.roomId).emit('offer', msg.sdp) 36 | }) 37 | socket.on('answer', msg => { 38 | console.log('answer') 39 | socket.to(msg.roomId).emit('answer', msg.sdp) 40 | }) 41 | 42 | socket.on('send', msg => { 43 | console.log('send', msg, ' using ', socket.client.conn.transport.constructor.name) 44 | io.in(msg.roomId).emit('comment', { 45 | text: msg.text, 46 | userId: msg.userId, 47 | }) 48 | }) 49 | 50 | socket.on('identity', msg => { 51 | console.log(`${msg.user.id} identified themselves`) 52 | socket.to(msg.roomId).emit('identity', msg.user) 53 | }) 54 | 55 | socket.on('matchId', msg => { 56 | console.log(`${msg.userId} created match ${msg.matchId} as host`) 57 | socket.to(msg.roomId).emit('matchId', msg.matchId) 58 | }) 59 | 60 | socket.on('countdown', msg => { 61 | console.log(`${msg.type} from ${msg.userId}`) 62 | socket.to(msg.roomId).emit(msg.type, msg.userId) 63 | }) 64 | 65 | socket.on('videoPlayerSync', msg => { 66 | console.log(`${msg.type} from ${msg.userId}`) 67 | socket.to(msg.roomId).emit('videoPlayerSync', msg) 68 | }) 69 | socket.on('videoPlayerUpdate', msg => { 70 | console.log(`${msg.type} from ${msg.userId}`) 71 | socket.to(msg.roomId).emit('videoPlayerUpdate', msg) 72 | }) 73 | 74 | // Called when a user closes the connection 75 | socket.on('disconnecting', reason => { 76 | console.log( 77 | `${socket.username} disconnected from ${Object.values(socket.rooms)[1]} using ${ 78 | socket.client.conn.transport.constructor.name 79 | }`, 80 | ) 81 | // socket.to(Object.values(socket.rooms)[1]).emit('disconnect') 82 | io.in(Object.values(socket.rooms)[1]).emit('disconnect') 83 | console.log(reason) 84 | // socket.leave(socket.room) 85 | }) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /server/src/utils/generateToken.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | export default userId => { 4 | return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '2 days' }) 5 | } 6 | -------------------------------------------------------------------------------- /server/src/utils/getUserId.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | export default (request, requireAuth = true) => { 4 | // Coming from cookie-parser 5 | if (request.req.userId) { 6 | console.log('cookie parsed userId', request.req.userId) 7 | return request.req.userId 8 | } 9 | 10 | const header = request.req ? request.req.headers.authorization : request.connection.context.Authorization 11 | 12 | // const token = request.req.headers.cookie 13 | if (header) { 14 | const token = header.replace('Bearer ', '') 15 | const decoded = jwt.verify(token, process.env.JWT_SECRET) 16 | return decoded.userId 17 | } 18 | 19 | // COOKIE SHOULD ALREADY BE PARSED 20 | // Try to get token from cookies 21 | // const cookieToken = request.req.headers.cookie 22 | // console.log(cookieToken) 23 | // if(cookieToken) { 24 | // const decoded = jwt.verify(cookieToken, process.env.JWT_SECRET) 25 | // return decoded.userId 26 | // } 27 | 28 | if (requireAuth) { 29 | console.error('no token was parsed, throwing Authentication error') 30 | throw new Error('Authentication required') 31 | } 32 | return null 33 | } 34 | -------------------------------------------------------------------------------- /server/src/views/construction.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 22 | 23 | 24 | 25 | 26 | 27 | 147 | C2G 148 | 149 | 150 | background stock photo 151 |
152 |
153 |
154 |

Chat2Gether

155 | 156 |
157 |
158 |

Under Construction

159 | 160 |
161 | <% if(welcomeMessage) { %> 162 |
163 | 164 |

<%= welcomeMessage %>

165 |
166 | <% } %> 167 |
168 |
169 | 170 | 188 | 189 | --------------------------------------------------------------------------------