├── .eslintignore ├── .gitignore ├── .prettierignore ├── .prettierrc ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── __tests__ │ └── index.js ├── bootstrap │ ├── firebase.js │ └── index.js ├── context │ ├── index.js │ └── user.js ├── index.js ├── screens │ ├── about.js │ ├── chat.js │ ├── home.js │ └── login.js └── setupTests.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | public 5 | .docz 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | public 5 | .docz 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "jsxBracketSameLine": false, 5 | "printWidth": 80, 6 | "proseWrap": "always", 7 | "semi": false, 8 | "singleQuote": true, 9 | "tabWidth": 2, 10 | "trailingComma": "all", 11 | "useTabs": false 12 | } 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/core": "^10.0.10", 7 | "@emotion/styled": "^10.0.10", 8 | "@reach/router": "^1.2.1", 9 | "emotion-theming": "^10.0.10", 10 | "firebase": "^5.10.0", 11 | "jest-dom": "^3.1.3", 12 | "lodash": "^4.17.11", 13 | "react": "^16.8.6", 14 | "react-async": "^6.0.2", 15 | "react-dom": "^16.8.6", 16 | "react-firebase-hooks": "^1.2.0", 17 | "react-scripts": "3.0.0-next.68", 18 | "react-testing-library": "^6.1.2" 19 | }, 20 | "devDependencies": { 21 | "cross-env": "^5.2.0", 22 | "husky": "^1.1.4", 23 | "jest-emotion": "^10.0.10", 24 | "lint-staged": "^8.1.5", 25 | "npm-run-all": "^4.1.5", 26 | "prettier": "^1.17.0" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "coverage": "npm run test -- --coverage --no-watch", 33 | "format": "prettier --write \"**/*.+(js|json|css|md|mdx|html)\"", 34 | "validate": "npm-run-all --parallel coverage build" 35 | }, 36 | "eslintConfig": { 37 | "extends": "react-app" 38 | }, 39 | "husky": { 40 | "hooks": { 41 | "pre-commit": "lint-staged && npm run build" 42 | } 43 | }, 44 | "jest": { 45 | "snapshotSerializers": [ 46 | "jest-emotion" 47 | ] 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/geo-chat/609e9b09627e1c44da119562c9180c0a1b6b9ded/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render} from 'react-testing-library' 3 | 4 | test('works', () => { 5 | const {container} = render(
hi
) 6 | expect(container.firstChild).toHaveTextContent('hi') 7 | }) 8 | -------------------------------------------------------------------------------- /src/bootstrap/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app' 2 | import 'firebase/auth' 3 | import 'firebase/firestore' 4 | 5 | firebase.initializeApp({ 6 | apiKey: 'AIzaSyCSA6P3Yj0wGu4Gsr6Hx6w8dWaNjxwyuK4', 7 | authDomain: 'the-geo-chat-app.firebaseapp.com', 8 | databaseURL: 'https://the-geo-chat-app.firebaseio.com', 9 | projectId: 'the-geo-chat-app', 10 | storageBucket: 'the-geo-chat-app.appspot.com', 11 | messagingSenderId: '879802748968', 12 | }) 13 | -------------------------------------------------------------------------------- /src/bootstrap/index.js: -------------------------------------------------------------------------------- 1 | import './firebase' 2 | -------------------------------------------------------------------------------- /src/context/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {UserProvider} from './user' 3 | 4 | function AppProviders({children}) { 5 | return {children} 6 | } 7 | 8 | export default AppProviders 9 | -------------------------------------------------------------------------------- /src/context/user.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import isMatch from 'lodash/isMatch' 3 | import firebase from 'firebase/app' 4 | import {useAuthState} from 'react-firebase-hooks/auth' 5 | import {useDocument} from 'react-firebase-hooks/firestore' 6 | import {navigate} from '@reach/router' 7 | 8 | function loginWithTwitter() { 9 | const provider = new firebase.auth.TwitterAuthProvider() 10 | firebase.auth().signInWithPopup(provider) 11 | } 12 | 13 | function loginWithGitHub() { 14 | const provider = new firebase.auth.GithubAuthProvider() 15 | firebase.auth().signInWithPopup(provider) 16 | } 17 | 18 | function logout() { 19 | firebase.auth().signOut() 20 | } 21 | 22 | const UserContext = React.createContext() 23 | 24 | function Authenticate(props) { 25 | const {initialising: initializing, user} = useAuthState(firebase.auth()) 26 | const context = React.useMemo(() => { 27 | return { 28 | loading: initializing, 29 | user, 30 | loginWithTwitter, 31 | loginWithGitHub, 32 | logout, 33 | } 34 | }, [initializing, user]) 35 | 36 | if (initializing || !user) { 37 | return 38 | } 39 | return 40 | } 41 | 42 | function UserProvider(props) { 43 | // spelling is weird, so we'll fix it :) 44 | const {authenticatedUser} = props 45 | const {error, loading, value: userDoc} = useDocument( 46 | firebase 47 | .firestore() 48 | .collection('users') 49 | .doc(authenticatedUser.uid), 50 | ) 51 | 52 | // update users collection with old/missing data 53 | React.useEffect(() => { 54 | if (!userDoc) { 55 | return 56 | } 57 | const userData = userDoc.data() 58 | const authenticatedUserData = { 59 | id: authenticatedUser.uid, 60 | displayName: authenticatedUser.displayName, 61 | email: authenticatedUser.email, 62 | photoURL: authenticatedUser.photoURL, 63 | providers: authenticatedUser.providerData.map(p => ({ 64 | providerId: p.providerId, 65 | uid: p.uid, 66 | })), 67 | } 68 | if (!isMatch(userData, authenticatedUserData)) { 69 | userDoc.ref.set(authenticatedUserData) 70 | } 71 | }, [authenticatedUser, userDoc]) 72 | 73 | const context = React.useMemo(() => { 74 | return { 75 | loading, 76 | user: userDoc ? userDoc.data() : null, 77 | error, 78 | loginWithTwitter, 79 | loginWithGitHub, 80 | logout, 81 | } 82 | }, [loading, userDoc, error]) 83 | return 84 | } 85 | 86 | function useUser() { 87 | const context = React.useContext(UserContext) 88 | if (!context) { 89 | throw new Error( 90 | `useUser must be used within a component that's rendered within the UserProvider`, 91 | ) 92 | } 93 | return context 94 | } 95 | 96 | function useAuthenticatedRedirect(destination = '/chat') { 97 | const {user} = useUser() 98 | if (user) { 99 | navigate(destination) 100 | } 101 | } 102 | 103 | function useUnauthenticatedRedirect(destination = '/login') { 104 | const {user} = useUser() 105 | if (user) { 106 | navigate(destination) 107 | } 108 | } 109 | 110 | export { 111 | Authenticate as UserProvider, 112 | useUser, 113 | useUnauthenticatedRedirect, 114 | useAuthenticatedRedirect, 115 | } 116 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import './bootstrap' 3 | 4 | import {jsx} from '@emotion/core' 5 | import ReactDOM from 'react-dom' 6 | import {Router} from '@reach/router' 7 | import AppProviders from './context' 8 | import {useUser} from './context/user' 9 | import Home from './screens/home' 10 | import Chat from './screens/chat' 11 | import Login from './screens/login' 12 | import About from './screens/about' 13 | 14 | function Routes() { 15 | const {loading} = useUser() 16 | if (loading) { 17 | return 'loading ...' 18 | } 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | function App() { 30 | return ( 31 | 32 | 33 | 34 | ) 35 | } 36 | 37 | ReactDOM.render(, document.getElementById('root')) 38 | -------------------------------------------------------------------------------- /src/screens/about.js: -------------------------------------------------------------------------------- 1 | function About() { 2 | return 'About' 3 | } 4 | 5 | export default About 6 | -------------------------------------------------------------------------------- /src/screens/chat.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | import React from 'react' 4 | import firebase from 'firebase/app' 5 | import {useCollection} from 'react-firebase-hooks/firestore' 6 | import {Redirect} from '@reach/router' 7 | import {useUser} from '../context/user' 8 | 9 | function Chat() { 10 | const {user, logout} = useUser() 11 | return ( 12 |
13 |
14 | {user.displayName} 19 |
{user.displayName}
20 |
21 | 22 |
{JSON.stringify(user, null, 2)}
23 | 24 |
25 | ) 26 | } 27 | 28 | const geoPositionReducer = (state, action) => ({...state, ...action}) 29 | 30 | function useGeoLocation(positionOptions) { 31 | const [state, setState] = React.useReducer(geoPositionReducer, { 32 | position: null, 33 | loading: true, 34 | error: null, 35 | }) 36 | React.useEffect(() => { 37 | setState({loading: true}) 38 | 39 | function onPosition(position) { 40 | setState({position, loading: false, error: null}) 41 | } 42 | function onError(error) { 43 | setState({position: null, loading: false, error}) 44 | } 45 | 46 | navigator.geolocation.getCurrentPosition( 47 | onPosition, 48 | onError, 49 | positionOptions, 50 | ) 51 | 52 | const listener = navigator.geolocation.watchPosition( 53 | onPosition, 54 | onError, 55 | positionOptions, 56 | ) 57 | 58 | return () => navigator.geolocation.clearWatch(listener) 59 | }, [positionOptions]) 60 | 61 | return state 62 | } 63 | 64 | const getRoomId = ({latitude, longitude}) => 65 | `${(latitude * 10).toFixed()}_${(longitude * 10).toFixed()}` 66 | 67 | function ChatMessages() { 68 | const {position, loading, error} = useGeoLocation() 69 | if (loading) { 70 | return '... calculating your room based on your location ...' 71 | } 72 | if (error) { 73 | console.error(error) 74 | return '... there was an error determining your location ...' 75 | } 76 | const roomId = getRoomId(position.coords) 77 | return 78 | } 79 | 80 | function ChatMessagesWithRoomId({roomId}) { 81 | const {user} = useUser() 82 | const messagesRef = firebase 83 | .firestore() 84 | .collection('rooms') 85 | .doc(roomId) 86 | .collection('messages') 87 | const {error, loading, value: messagesSnapshot} = useCollection( 88 | messagesRef.orderBy('timestamp', 'desc').limit(20), 89 | ) 90 | if (loading) { 91 | return '... loading room chats ...' 92 | } 93 | if (error) { 94 | console.error(error) 95 | return 'There was an error loading the room chats' 96 | } 97 | 98 | function handleSubmit(event) { 99 | event.preventDefault() 100 | const {message} = event.target.elements 101 | 102 | messagesRef.add({ 103 | authorId: user.id, 104 | message: message.value, 105 | timestamp: firebase.firestore.FieldValue.serverTimestamp(), 106 | }) 107 | } 108 | 109 | return ( 110 |
111 |
112 | {messagesSnapshot.docs.reverse().map(d => ( 113 |
{d.data().message}
114 | ))} 115 |
116 |
117 | 118 | 119 | 120 |
121 |
122 | ) 123 | } 124 | 125 | function ChatRedirect(props) { 126 | const {user} = useUser() 127 | if (!user) { 128 | return 129 | } 130 | return 131 | } 132 | 133 | export default ChatRedirect 134 | -------------------------------------------------------------------------------- /src/screens/home.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import {Link, Redirect} from '@reach/router' 5 | import {useUser} from '../context/user' 6 | 7 | function Home() { 8 | const {user} = useUser() 9 | if (user) { 10 | return 11 | } 12 | return ( 13 |
14 | Home 15 | Login 16 |
17 | ) 18 | } 19 | 20 | export default Home 21 | -------------------------------------------------------------------------------- /src/screens/login.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {useUser} from '../context/user' 3 | import {Redirect} from '@reach/router' 4 | 5 | function Login() { 6 | const {user, loginWithGitHub, loginWithTwitter} = useUser() 7 | if (user) { 8 | return 9 | } 10 | return ( 11 |
12 | 13 | 14 |
15 | ) 16 | } 17 | 18 | export default Login 19 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import 'jest-dom/extend-expect' 2 | import 'react-testing-library/cleanup-after-each' 3 | --------------------------------------------------------------------------------