├── .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 |
120 | )
121 | }
122 | return (
123 |
124 |
125 | {connectionMsg}
126 | {matchCountdown > 0 && {matchCountdown}}
127 | {enabledWidgets.updatePref && }
128 | {enabledWidgets.stats && }
129 | {enabledWidgets.matches && }
130 |
131 |
132 |
135 |
136 | )
137 | }
138 |
139 | return (
140 |
141 |
142 | {renderBackground()}
143 | {enabledWidgets.menu && }
144 |
145 |
146 | )
147 | }
148 |
--------------------------------------------------------------------------------
/client/src/components/ChatNav.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { useEnabledWidgets } from '../hooks/EnabledWidgetsContext'
4 | import { useLocalStream } from '../hooks/LocalStreamContext'
5 | import { useSocket } from '../hooks/SocketContext'
6 | import useWindowSize from '../hooks/WindowSizeHook'
7 | import Button from './common/Button'
8 | import ReportUserDialog from './ReportUserDialog'
9 |
10 | const StyledChatNav = styled.div`
11 | position: absolute;
12 | right: 0;
13 | top: 20px;
14 | display: flex;
15 | justify-content: flex-end;
16 | align-items: stretch;
17 | overflow: hidden;
18 | padding: 2px;
19 | `
20 | const Slide = styled.div`
21 | display: flex;
22 | left: ${p => (p.isExpanded ? 0 : p.slideWidth)}px;
23 | position: relative;
24 | transition: all 0.6s;
25 | `
26 | const Container = styled.div`
27 | position: relative;
28 | padding: 2px 0 2px 8px;
29 | display: flex;
30 | flex-direction: column;
31 | background-color: rgba(0, 0, 0, 0.1);
32 | overflow: hidden;
33 | transition: all 0.6s;
34 | `
35 | const ExpansionButton = styled(Button)`
36 | border-radius: 50% 0 0 50%;
37 | background-color: rgba(0, 0, 0, 0.1);
38 | `
39 | const ExpansionIcon = styled.i`
40 | transform: scaleX(${p => (p.isExpanded ? 1 : -1)});
41 | transition: all 0.8s;
42 | `
43 | const Row = styled.div`
44 | position: relative;
45 | display: flex;
46 | `
47 | const NextMatchButton = styled(Button)`
48 | border-radius: 10px 0 0 10px;
49 | color: ${p => (p.disabled ? '#aaa' : '#fff')};
50 | `
51 |
52 | const ReportButton = styled(Button)`
53 | margin-top: 10px;
54 | `
55 |
56 | const NextMatchSVG = styled.svg`
57 | pointer-events: none;
58 | position: absolute;
59 | top: 0;
60 | left: 0;
61 | transform: scale(1.01, 1.1);
62 | `
63 | const NextMatchRect = styled.rect`
64 | stroke-width: 4px;
65 | stroke-opacity: 1;
66 | stroke-dashoffset: ${p => (p.disabled ? 0 : 349)}px;
67 | stroke-dasharray: 349px;
68 | stroke: ${p => p.theme.colorPrimary};
69 | transition: all ${p => (p.disabled ? 1.8 : 0.2)}s;
70 | `
71 | const SettingsButton = styled(Button)`
72 | border-radius: 0 10px 10px 0;
73 | border-left: 1px solid #444;
74 | padding-left: 15px;
75 | `
76 |
77 | export default function ChatNav() {
78 | const [isExpanded, setIsExpanded] = React.useState(true)
79 | const [dialogOpen, setDialogOpen] = React.useState(false)
80 |
81 | const { localStream } = useLocalStream()
82 | const { enabledWidgets, setEnabledWidgets } = useEnabledWidgets()
83 | const { nextMatch, canNextMatch, endCall, remoteStream, otherUser } = useSocket()
84 | const { isPC } = useWindowSize()
85 |
86 | const handleNextMatch = e => {
87 | e.stopPropagation()
88 | if (localStream && canNextMatch) nextMatch(localStream)
89 | }
90 |
91 | const slideWidth = isPC ? 151 : 118
92 |
93 | const getNextMatchButtonLabel = () => {
94 | if (remoteStream) return 'Next Match'
95 | if (!canNextMatch) return 'I gotchu...'
96 | return 'Find Match'
97 | }
98 |
99 | const handleDialogClose = async madeReport => {
100 | setDialogOpen(false)
101 | if (madeReport) {
102 | await endCall('REPORT')
103 | }
104 | }
105 |
106 | return (
107 | <>
108 |
109 |
110 | setIsExpanded(!isExpanded)}>
111 |
112 |
113 |
114 |
115 |
121 |
122 |
123 |
124 |
125 | {isPC && (
126 | setEnabledWidgets({ ...enabledWidgets, menu: true })}>
127 |
128 |
129 | )}
130 |
131 | setDialogOpen(true)} />
132 |
133 |
134 |
135 |
136 | {otherUser && }
137 | >
138 | )
139 | }
140 |
--------------------------------------------------------------------------------
/client/src/components/ChoicePicker.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const StyledChoicePicker = styled.div`
5 | border-radius: 500rem; /* Large number to ensure rounded ends */
6 | width: ${p => p.width || '90%'};
7 | margin: 1rem auto;
8 | outline: none;
9 | background-color: ${p => p.theme.colorGreyDark1};
10 | display: flex;
11 | justify-content: space-around;
12 | align-items: stretch;
13 | position: relative;
14 | border: 2px solid ${p => p.theme.colorWhite1};
15 | font-size: ${p => p.fontSize || '1.6rem'};
16 | cursor: pointer;
17 | `
18 |
19 | const Option = styled.span`
20 | ${p => (p.optionStart ? 'border-radius: 3rem 0 0 3rem;' : '')}
21 | ${p => (p.optionEnd ? 'border-radius: 0 3rem 3rem 0;' : '')}
22 | flex: 1;
23 | display: flex;
24 | align-items: center;
25 | justify-content: center;
26 | padding: ${p => p.height || '1rem'} 0;
27 | border-right: ${p => (p.showBorderRight ? `2px solid ${p.theme.colorWhite1}` : undefined)};
28 | color: ${p => (p.active ? 'white' : p.theme.colorPrimaryLight)};
29 | ${p => (p.active ? `background-color: ${p.theme.colorPrimary};` : '')}
30 | font-size: ${p => (p.active ? '1.2rem' : '1rem')};
31 | transition: all .6s;
32 | `
33 | const OptionText = styled.span`
34 | opacity: ${p => (p.active ? 1 : 0.2)};
35 | user-select: none;
36 | `
37 |
38 | export default function ChoicePicker(props) {
39 | const { selected, onChange, choices, height, width, fontSize, 'data-cy': dataCy } = props
40 | // props.choices is a list of strings to display as choices
41 | // props.selected is a list of the selected choices
42 | // props.onChange is how to change the selected elements
43 |
44 | const handleClick = (e, choice) => {
45 | e.preventDefault()
46 | if (selected.find(obj => obj.name === choice)) {
47 | if (selected.length <= 1) return
48 | onChange(selected.filter(obj => obj.name !== choice))
49 | } else {
50 | onChange([...selected, { name: choice }])
51 | }
52 | }
53 |
54 | return (
55 |
56 | {choices.map((choice, index) => {
57 | const active = selected.find(obj => obj.name === choice)
58 | return (
59 |
71 | )
72 | })}
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/client/src/components/ChoiceSlider.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const StyledChoiceSlider = styled.div`
5 | width: ${p => p.width || '90%'};
6 | margin: 1rem auto;
7 | background-color: ${p => p.theme.colorGreyDark1};
8 | border: 2px solid ${p => p.theme.colorWhite1};
9 | border-radius: ${p => (p.vertical ? 20 : 5000)}px; /* Large number to ensure rounded ends */
10 | position: relative;
11 | font-size: ${p => p.fontSize || 'inherit'};
12 | cursor: pointer;
13 | -webkit-tap-highlight-color: transparent;
14 |
15 | display: grid;
16 | grid-template-columns: ${p => (p.vertical ? '1fr' : `repeat(${p.totalChoices}, 1fr)`)};
17 | grid-template-rows: ${p => (p.vertical ? `repeat(${p.totalChoices}, 1fr)` : '1fr')};
18 | `
19 | const Option = styled.div`
20 | border: none;
21 | border-radius: 3rem;
22 | width: 100%;
23 | display: flex;
24 | align-items: center;
25 | justify-content: center;
26 | padding: ${p => p.height || '1rem'} 0; /* 16px looks better */
27 | margin: 0;
28 | opacity: 0.8;
29 | color: ${p => (p.active ? 'white' : p.theme.colorPrimaryLight)};
30 | opacity: ${p => (p.active ? 1 : 0.3)};
31 | user-select: none;
32 | z-index: 10;
33 |
34 | &:active,
35 | &:focus {
36 | background-color: transparent;
37 | }
38 | `
39 |
40 | const Slider = styled.div`
41 | background-color: ${p => p.theme.colorPrimary};
42 | position: absolute;
43 | width: ${p => (p.vertical ? 100 : 100 / p.choices.length)}%;
44 | height: ${p => (p.vertical ? 100 / p.choices.length : 100)}%;
45 | top: ${p => (p.vertical ? (p.selected / p.choices.length) * 100 : 0)}%;
46 | bottom: 0;
47 | left: ${p => (p.vertical ? 0 : (p.selected / p.choices.length) * 100)}%;
48 | border-radius: ${p => p.sliderBorderRadius};
49 | transition: all ${p => (p.vertical ? 0.4 : 0.8)}s;
50 | `
51 |
52 | export default function ChoiceSlider(props) {
53 | const { cur, onChange, choices, width, fontSize, vertical, 'data-cy': dataCy } = props
54 | // props.choices is a list of strings to display as choices
55 | // props.cur is the selected element
56 | // props.onChange is how to change the cur selected element
57 |
58 | const sliderBorderRadius = React.useMemo(() => {
59 | if (!vertical) return '5000px'
60 | if (cur > 0 && cur < choices.length - 1) return '0px'
61 | return `${cur === 0 ? '20px 20px' : '0px 0px'} ${cur === choices.length - 1 ? '20px 20px' : '0px 0px'}`
62 | }, [choices.length, cur, vertical])
63 |
64 | return (
65 |
72 |
73 | {choices.map((choice, index) => (
74 |
82 | ))}
83 |
84 | )
85 | }
86 |
87 | ChoiceSlider.defaultProps = {
88 | fontSize: '12px',
89 | }
90 |
--------------------------------------------------------------------------------
/client/src/components/Countdown.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 { Button } from './common'
6 |
7 | /*
8 | Button absolutely positioned on screen "Countdown"
9 | User 1 presses button, emits "requestedCountdown" and button style changes to "Cancel Countdown"
10 | User 2 sees "Accept Countdown", and presses.
11 | Emits "acceptedCountdown" and both user's start counting down.
12 | -- if either user cancels, emits "cancelledCountdown" and immediately stops local timers and resets
13 | -- if User 2 doesn't press accept, it hangs. User 1 can tap to cancel
14 | */
15 |
16 | const scan = keyframes`
17 | 0% {bottom: 100%;}
18 | 100% {bottom: 0;}
19 | `
20 | const absorb = keyframes`
21 | 40%{box-shadow: 0 -5px 4px transparent; text-shadow: 0 0 8px transparent;}
22 | 50%{box-shadow: 0 -5px 4px #9932cc; text-shadow: 0 0 4px #9932cc;}
23 | 100%{box-shadow: 0 -5px 4px transparent;}
24 | `
25 | const bounce = keyframes`
26 | 0% {transform: scale(0); opacity: .5;}
27 | 10%{transform: scale(1.05); opacity: 1; text-shadow: 0 0 4px #fff;}
28 | 88%{transform: scale(.9); opacity: 1;}
29 | 90%{transform: scale(1.05); text-shadow: 0 0 4px transparent;}
30 | 100%{transform: scale(0); opacity: .5;}
31 | `
32 |
33 | const StyledCountdown = styled.div`
34 | display: ${p => (p.active ? 'flex' : 'none')};
35 | color: #aaa;
36 | background-color: rgba(0, 0, 0, 0.6);
37 | overflow: hidden;
38 | position: absolute;
39 | bottom: 20%;
40 | left: 1rem;
41 | border-radius: 2rem 2rem 0 0;
42 | flex-direction: column;
43 | `
44 | const TextContainer = styled.div`
45 | display: flex;
46 | justify-content: center;
47 | align-items: center;
48 | padding: 4px;
49 | width: 15rem;
50 | height: 5rem;
51 | text-align: center;
52 | background-color: #1e1e23;
53 | `
54 | const CountdownText = styled.h3`
55 | font-size: ${p => p.fontSize};
56 | text-shadow: 0 0 4px transparent;
57 | &.animated {
58 | animation: ${bounce} ${p => `${p.spacing}s`} infinite;
59 | }
60 | `
61 | const ButtonsContainer = styled.div`
62 | display: flex;
63 | z-index: 10;
64 | flex: 1;
65 | border: 0;
66 | border-top: 1px solid ${p => p.theme.colorPrimary};
67 | &.animated {
68 | animation: ${absorb} ${p => `${p.spacing}s`} infinite;
69 | }
70 | `
71 | const ActionButton = styled(Button)`
72 | color: inherit;
73 | flex: 1;
74 | font-size: 1.4rem;
75 | border-radius: 0;
76 | padding: 4px;
77 | transition: all 0.4s;
78 | animation: ${absorb} ${p => `${p.animated && p.spacing}s infinite`};
79 |
80 | &:hover {
81 | color: ${p => p.theme.colorPrimary};
82 | text-shadow: 0 0 4px transparent;
83 | }
84 | `
85 | const ScanLine = styled.div`
86 | position: absolute;
87 | bottom: 100%;
88 | left: 0;
89 | right: 0;
90 | height: 5px;
91 | background-color: #9932cc;
92 | animation: ${scan} ${p => `${p.animated && p.spacing}s`} infinite;
93 | `
94 |
95 | export default function Countdown(props) {
96 | const { userId, socketHelper, roomId } = props
97 | const [isRequester, setIsRequester] = React.useState(false)
98 | const [status, setStatus] = React.useState('none')
99 | const [countdownText, setCountdownText] = React.useState('Countdown')
100 |
101 | const timer = React.useRef()
102 |
103 | const { setCountdownNotify } = useNotify()
104 | const { enabledWidgets } = useEnabledWidgets()
105 | const active = enabledWidgets.countdown
106 |
107 | const spacing = 1.6 // parsedText ? (4 / countdownText) * 0.2 + 1 : 1
108 |
109 | const resetTimer = () => {
110 | setStatus('none')
111 | setIsRequester(false)
112 | setCountdownText('Countdown')
113 | if (timer.current) clearTimeout(timer.current)
114 | }
115 |
116 | const tickTimer = React.useCallback(oldTime => {
117 | const time = oldTime - 1
118 | timer.current = undefined
119 | if (time < 1) {
120 | console.log(`${time} is the time but cleared`)
121 | setCountdownText('Go!')
122 | timer.current = setTimeout(() => resetTimer(), 3000)
123 | } else {
124 | console.log(`${time} is the time`)
125 | setCountdownText(time)
126 | timer.current = setTimeout(() => {
127 | tickTimer(time)
128 | }, spacing * 1000) // (4 / time) * 200 + 1000)
129 | }
130 | }, [])
131 | const startCountdown = React.useCallback(() => {
132 | // Taking advantage of closure
133 | const time = 10
134 | console.log('timer started')
135 | // Start countdown in 1 second
136 | timer.current = setTimeout(() => {
137 | tickTimer(time)
138 | }, 1000)
139 | }, [tickTimer])
140 |
141 | // Cancel on button instead of title (Cancel, Request)
142 | // Timing has only one slight delay for "Started Countdown" message
143 | // clearTimeout Timer.currentis not a function
144 | // Show "Cancelled Countdown" for both users
145 |
146 | React.useEffect(() => {
147 | if (active) setCountdownNotify(false)
148 | })
149 |
150 | React.useEffect(() => {
151 | if (!socketHelper) return
152 |
153 | socketHelper.socket.on('requestedCountdown', senderUserId => {
154 | console.log('requested countdown', senderUserId)
155 | if (!active) {
156 | setCountdownNotify(true)
157 | }
158 | if (userId !== senderUserId) {
159 | setStatus('requested')
160 | setCountdownText('Ready to Countdown?')
161 | }
162 | })
163 | socketHelper.socket.on('startedCountdown', senderUserId => {
164 | console.log('started countdown', senderUserId)
165 | if (!active) {
166 | setCountdownNotify(true)
167 | }
168 | if (userId !== senderUserId) {
169 | setStatus('started')
170 | setCountdownText('10')
171 | startCountdown()
172 | }
173 | })
174 | socketHelper.socket.on('cancelledCountdown', senderUserId => {
175 | console.log('cancelled countdown', senderUserId)
176 | setCountdownNotify(false)
177 | if (timer && timer.current) {
178 | clearTimeout(timer.current)
179 | }
180 | setStatus('cancelled')
181 | setCountdownText('Countdown Cancelled')
182 | timer.current = setTimeout(() => resetTimer(), 3000)
183 | })
184 | return () => {
185 | if (timer && timer.current) clearTimeout(timer.current)
186 | socketHelper.socket.off('requestedCountdown')
187 | socketHelper.socket.off('startedCountdown')
188 | socketHelper.socket.off('cancelledCountdown')
189 | }
190 | }, [active, setCountdownNotify, socketHelper, startCountdown, userId])
191 |
192 | const msg = {
193 | roomId,
194 | userId,
195 | }
196 |
197 | const handleRequest = () => {
198 | msg.type = 'requestedCountdown'
199 | socketHelper.emit('countdown', msg)
200 | setStatus('requested')
201 | setCountdownText('Requesting Countdown')
202 | setIsRequester(true)
203 | }
204 | const handleStart = () => {
205 | msg.type = 'startedCountdown'
206 | socketHelper.emit('countdown', msg)
207 | setStatus('started')
208 | setCountdownText('10')
209 | startCountdown()
210 | }
211 | const handleCancel = () => {
212 | msg.type = 'cancelledCountdown'
213 | if (timer && timer.current) {
214 | clearTimeout(timer.current)
215 | }
216 | socketHelper.emit('countdown', msg)
217 | setStatus('cancelled')
218 | setCountdownText('Countdown Cancelled')
219 | timer.current = setTimeout(() => resetTimer(), 3000)
220 | }
221 |
222 | return (
223 |
224 |
225 |
231 | {countdownText}
232 |
233 |
234 |
235 |
236 | {status === 'none' && }
237 | {status === 'requested' && !isRequester && (
238 |
239 | )}
240 | {(status === 'started' || status === 'requested') && (
241 |
248 | )}
249 |
250 |
251 | )
252 | }
253 |
--------------------------------------------------------------------------------
/client/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const StyledHeader = styled.header`
5 | text-align: center;
6 | background-color: transparent;
7 | padding: 10px;
8 | font-size: 1.4rem;
9 | text-shadow: 0 0 2rem #111;
10 | `
11 | const Title = styled.h1`
12 | color: #9932cc;
13 | font-family: 'Megrim', cursive;
14 | `
15 |
16 | export default function Header() {
17 | return (
18 |
19 | Chat2Gether
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/components/MatchHistory.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import moment from 'moment'
4 | import { useMyUser } from '../hooks/MyUserContext'
5 | import useWindowSize from '../hooks/WindowSizeHook'
6 | import { Button } from './common'
7 | import ReportUserDialog from './ReportUserDialog'
8 |
9 | const StyledMatchHistory = styled.section`
10 | margin: 10px;
11 | padding: 5px;
12 | width: 100%;
13 | max-width: 50rem;
14 | display: flex;
15 | flex-direction: column;
16 | align-items: flex-start;
17 | max-height: ${p => p.height / 2}px;
18 | `
19 | const ListContainer = styled.div`
20 | overflow-y: auto;
21 | width: 100%;
22 | `
23 | const MatchList = styled.div`
24 | display: grid;
25 | gap: 16px;
26 | `
27 | const MatchItem = styled.div`
28 | width: 100%;
29 | display: grid;
30 | grid-template-columns: max-content 1fr;
31 | align-items: center;
32 | justify-content: center;
33 | padding: 1rem 0.6rem;
34 | background-color: rgba(0, 0, 0, 0.4);
35 | border-radius: 2rem;
36 | `
37 | const MatchIconContainer = styled.div`
38 | position: relative;
39 | `
40 | const HumanIcon = styled.i`
41 | margin-left: 5px;
42 | font-size: 120px;
43 | color: ${p => (p.red ? '#c23636b5' : 'rgba(256, 256, 256, 0.4)')};
44 | `
45 | const GenderIcon = styled.i`
46 | position: absolute;
47 | bottom: 5%;
48 | left: 15%;
49 | font-size: 24px;
50 | color: rgba(256, 256, 256, 0.8);
51 | `
52 | const MatchDetails = styled.div`
53 | display: flex;
54 | flex-direction: column;
55 | height: 100%;
56 | justify-content: space-between;
57 | `
58 | const MatchText = styled.p`
59 | font-size: 16px;
60 | color: #ffffffcc;
61 | margin-bottom: 16px;
62 | `
63 | const PillContainer = styled.div`
64 | display: flex;
65 | flex-wrap: wrap;
66 | justify-content: center;
67 | align-items: center;
68 | `
69 | const Pill = styled.div`
70 | color: ${p => p.theme.colorPrimaryLight};
71 | background-color: ${p => p.theme.colorGreyDark1};
72 | border-radius: 500rem;
73 | display: flex;
74 | justify-content: center;
75 | align-items: center;
76 | padding: 4px 1rem;
77 | font-size: 20px;
78 | position: relative;
79 | margin-left: 8px;
80 | `
81 | const UserIdPill = styled(Pill)`
82 | position: absolute;
83 | bottom: 0;
84 | right: -15%;
85 | `
86 | const PillLabel = styled.span`
87 | position: absolute;
88 | font-size: 11px;
89 | color: rgba(255, 255, 255, 0.6);
90 | top: -20%;
91 | right: 10%;
92 | `
93 | const RefreshButton = styled(Button)`
94 | margin-bottom: 8px;
95 | margin-right: 8px;
96 | `
97 | const ActionButtons = styled.div`
98 | display: flex;
99 | justify-content: flex-end;
100 | margin-top: 16px;
101 | padding-right: 8px;
102 | `
103 | const ReportTypePill = styled(Pill)`
104 | position: absolute;
105 | font-size: 10px;
106 | bottom: 35%;
107 | left: 0;
108 | max-width: 100px;
109 | transform: rotateZ(-18deg);
110 | `
111 | const EmptyText = styled.p`
112 | color: #fff;
113 | `
114 |
115 | export default function MatchHistory() {
116 | const { user, getMe } = useMyUser()
117 | const { innerHeight } = useWindowSize()
118 |
119 | const [offenderId, setOffenderId] = React.useState()
120 | const [canRefresh, setCanRefresh] = React.useState(true)
121 |
122 | const getHumanIcon = gender => {
123 | if (gender === 'MALE') return 'fas fa-user-secret'
124 | if (gender === 'FEMALE') return 'fas fa-user-nurse'
125 | if (gender === 'M2F') return 'fas fa-user-ninja'
126 | return 'fas fa-user-astronaut'
127 | }
128 |
129 | const getGenderIcon = gender => {
130 | if (gender === 'MALE') return 'fas fa-mars'
131 | if (gender === 'FEMALE') return 'fas fa-venus'
132 | if (gender === 'M2F') return 'fas fa-transgender'
133 | return 'fas fa-transgender-alt'
134 | }
135 |
136 | const getDisconnectReason = type => {
137 | if (type === 'STOP') return `User clicked STOP`
138 | if (type === 'REFRESH') return `User refreshed page, you can match again`
139 | return `User clicked NEXT MATCH`
140 | }
141 | const handleRefresh = async () => {
142 | setCanRefresh(false)
143 | await getMe()
144 | setCanRefresh(true)
145 | }
146 |
147 | const handleReportClose = async didReport => {
148 | setOffenderId()
149 | if (didReport) {
150 | await handleRefresh()
151 | }
152 | }
153 |
154 | const hasMatches = !!user.matches.length
155 |
156 | if (!hasMatches) return No matches yet
157 |
158 | return (
159 | <>
160 |
161 |
162 |
163 |
164 | {hasMatches &&
165 | user.matches.map(m => {
166 | const otherUser = m.users.find(u => u.id !== user.id)
167 | const reportMade = user.reportsMade.find(r => r.offender.id === otherUser.id)
168 | // Moment stuff
169 | const lengthDiff = Math.abs(moment(m.endedAt).diff(m.createdAt))
170 | const duration = moment.duration(lengthDiff)
171 | const matchLength = `${duration.asMinutes().toFixed()}m ${duration.seconds().toFixed()}s`
172 | const matchEnded = moment(m.endedAt).fromNow()
173 |
174 | return (
175 |
176 |
177 |
178 |
179 |
180 | ID
181 | {otherUser.id.substr(-5)}
182 |
183 |
184 |
185 | {`${matchLength} call ended ${matchEnded}`}
186 | {`${getDisconnectReason(m.disconnectType)}`}
187 |
188 |
189 | Sex
190 | {otherUser.gender}
191 |
192 |
193 | Age
194 | {otherUser.age}
195 |
196 |
197 |
198 | {/* */}
199 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------