├── .env.production ├── .dockerignore ├── sentry.properties ├── public ├── favicon.ico ├── icon-192x192.png ├── icon-256x256.png ├── icon-384x384.png ├── icon-512x512.png ├── icon-maskable.png ├── images │ ├── c3-nav.png │ ├── c3-nav.webp │ ├── comma-white.png │ ├── splash-750x1334.png │ ├── splash-828x1792.png │ ├── splash-1080x1920.png │ ├── splash-1080x2340.png │ ├── splash-1125x2436.png │ ├── splash-1170x2532.png │ ├── splash-1242x2688.png │ ├── splash-1284x2778.png │ ├── splash-1488x2266.png │ ├── splash-1536x2048.png │ ├── splash-1620x2160.png │ ├── splash-1640x2360.png │ ├── splash-1668x2224.png │ ├── splash-1668x2388.png │ └── splash-2048x2732.png ├── config.js ├── manifest.json └── index.html ├── src ├── icons │ ├── ios_share.png │ ├── auth_apple.png │ ├── auth_github.png │ ├── auth_google.png │ ├── 360-degrees-video.svg │ └── original │ │ ├── pin-pinned.svg │ │ ├── pin-marker.svg │ │ ├── pin-work.svg │ │ ├── pin-home.svg │ │ ├── pin-car.svg │ │ └── 360-degrees-video.svg ├── demo │ ├── profile.json │ ├── devices.json │ └── index.js ├── App.test.js ├── reducers │ └── index.js ├── utils │ ├── conversions.js │ ├── clips.js │ ├── geocode.test.js │ ├── utils.test.js │ ├── geocode.js │ └── index.js ├── devtools.js ├── setupTests.js ├── __puppeteer__ │ ├── jest.config.js │ └── demo.test.js ├── index.js ├── actions │ ├── index.test.js │ ├── history.js │ ├── types.js │ ├── startup.js │ ├── history.test.js │ └── clips.js ├── store.js ├── components │ ├── Dashboard │ │ ├── DriveListItem.test.js │ │ ├── DriveListEmpty.js │ │ ├── index.js │ │ ├── DriveList.js │ │ └── DriveListItem.js │ ├── ResizeHandler │ │ ├── index.js │ │ └── ResizeHandler.test.js │ ├── Prime │ │ └── index.js │ ├── Navigation │ │ ├── utils.js │ │ └── utils.test.js │ ├── DriveView │ │ ├── NoDeviceUpsell.js │ │ └── index.js │ ├── utils │ │ ├── BackgroundImage.js │ │ ├── PullDownReload.js │ │ ├── SwitchLoading.js │ │ └── InfoTooltip.js │ ├── Timeline │ │ ├── thumbnails.js │ │ └── thumbnails.test.js │ ├── ClipView │ │ └── index.js │ ├── VisibilityHandler │ │ └── index.js │ ├── AppDrawer │ │ └── index.js │ ├── ServiceWorkerWrapper │ │ └── index.js │ ├── Misc │ │ └── DeviceSelect.js │ ├── IosPwaPopup │ │ └── index.js │ └── anonymous.js ├── url.js ├── timeline │ ├── index.js │ ├── segments.js │ ├── segments.test.js │ ├── playback.js │ └── playback.test.js ├── hooks │ └── window.js ├── index.css ├── initialState.js ├── analytics-v2.js ├── theme.js ├── App.js ├── colors.js └── serviceWorkerRegistration.js ├── .eslintignore ├── .env.development ├── jest-puppeteer.build.config.js ├── jest-puppeteer.config.js ├── motd ├── .editorconfig ├── config.js.template ├── .gitignore ├── lighthouserc.js ├── Dockerfile ├── .devcontainer └── devcontainer.json ├── nginx.conf ├── .eslintrc.js ├── LICENSE ├── craco.config.js ├── README.md ├── package.json └── .github └── workflows └── build.yaml /.env.production: -------------------------------------------------------------------------------- 1 | BROWSER=none 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | build/ 4 | -------------------------------------------------------------------------------- /sentry.properties: -------------------------------------------------------------------------------- 1 | defaults.project=connect 2 | defaults.org=commaai 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/icon-256x256.png -------------------------------------------------------------------------------- /public/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/icon-384x384.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/icon-512x512.png -------------------------------------------------------------------------------- /src/icons/ios_share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/src/icons/ios_share.png -------------------------------------------------------------------------------- /public/icon-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/icon-maskable.png -------------------------------------------------------------------------------- /public/images/c3-nav.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/c3-nav.png -------------------------------------------------------------------------------- /public/images/c3-nav.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/c3-nav.webp -------------------------------------------------------------------------------- /src/icons/auth_apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/src/icons/auth_apple.png -------------------------------------------------------------------------------- /src/icons/auth_github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/src/icons/auth_github.png -------------------------------------------------------------------------------- /src/icons/auth_google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/src/icons/auth_google.png -------------------------------------------------------------------------------- /public/images/comma-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/comma-white.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/ 3 | build/ 4 | .storybook 5 | src/registerServiceWorker.js 6 | src/stories/* 7 | -------------------------------------------------------------------------------- /public/images/splash-750x1334.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-750x1334.png -------------------------------------------------------------------------------- /public/images/splash-828x1792.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-828x1792.png -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | BROWSER=none 2 | REACT_APP_DEVTOOLS=1 3 | REACT_APP_LOCAL_COORDS_DATA=0 4 | REACT_APP_LOCAL_EVENTS_DATA=0 5 | -------------------------------------------------------------------------------- /public/images/splash-1080x1920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1080x1920.png -------------------------------------------------------------------------------- /public/images/splash-1080x2340.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1080x2340.png -------------------------------------------------------------------------------- /public/images/splash-1125x2436.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1125x2436.png -------------------------------------------------------------------------------- /public/images/splash-1170x2532.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1170x2532.png -------------------------------------------------------------------------------- /public/images/splash-1242x2688.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1242x2688.png -------------------------------------------------------------------------------- /public/images/splash-1284x2778.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1284x2778.png -------------------------------------------------------------------------------- /public/images/splash-1488x2266.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1488x2266.png -------------------------------------------------------------------------------- /public/images/splash-1536x2048.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1536x2048.png -------------------------------------------------------------------------------- /public/images/splash-1620x2160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1620x2160.png -------------------------------------------------------------------------------- /public/images/splash-1640x2360.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1640x2360.png -------------------------------------------------------------------------------- /public/images/splash-1668x2224.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1668x2224.png -------------------------------------------------------------------------------- /public/images/splash-1668x2388.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-1668x2388.png -------------------------------------------------------------------------------- /public/images/splash-2048x2732.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nelsonjchen/connect/master/public/images/splash-2048x2732.png -------------------------------------------------------------------------------- /jest-puppeteer.build.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | server: { 3 | command: 'serve -s -l 3003 build', 4 | port: 3003, 5 | launchTimeout: 10000, 6 | debug: true, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | server: { 3 | command: 'PORT=3003 env-cmd .env.development craco start', 4 | port: 3003, 5 | launchTimeout: 10000, 6 | debug: true, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /motd: -------------------------------------------------------------------------------- 1 | {"title":"End Of Life","text":"This app has been replaced by our all new web app at connect.comma.ai. For easy access, we recommend you bookmark the web app to your phone’s homescreen.","url":"connect","eol":true} 2 | -------------------------------------------------------------------------------- /public/config.js: -------------------------------------------------------------------------------- 1 | window.COMMA_URL_ROOT = 'https://api.comma.ai/'; 2 | window.ATHENA_URL_ROOT = 'https://athena.comma.ai/'; 3 | window.BILLING_URL_ROOT = 'https://billing.comma.ai/'; 4 | window.USERADMIN_URL_ROOT = 'https://useradmin.comma.ai/'; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /config.js.template: -------------------------------------------------------------------------------- 1 | window.COMMA_URL_ROOT = '${COMMA_URL_ROOT}'; 2 | window.ATHENA_URL_ROOT = '${ATHENA_URL_ROOT}'; 3 | window.BILLING_URL_ROOT = '${BILLING_URL_ROOT}'; 4 | window.USERADMIN_URL_ROOT = '${USERADMIN_URL_ROOT}'; 5 | window.SENTRY_ENV = '${SENTRY_ENV}'; 6 | -------------------------------------------------------------------------------- /src/demo/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "commaexplorer@gmail.com", 3 | "id": "8ca5365277ebc093", 4 | "points": 7857, 5 | "prime": false, 6 | "regdate": 1542645991, 7 | "superuser": false, 8 | "upload_video": false, 9 | "username": "commaexplorer" 10 | } 11 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react'; 3 | import { render } from '@testing-library/react'; 4 | import App from './App'; 5 | 6 | it('renders without crashing', () => { 7 | const container = document.createElement('div'); 8 | const { unmount } = render(, { container }); 9 | unmount(); 10 | }); 11 | -------------------------------------------------------------------------------- /src/demo/devices.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "alias": "Rav4", 4 | "device_type": "three", 5 | "dongle_id": "4cf7a6ad03080c90", 6 | "ignore_uploads": null, 7 | "is_owner": false, 8 | "shared": true, 9 | "is_paired": true, 10 | "last_athena_ping": 1565453808, 11 | "sim_id": "0000000000000000000" 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { reducer as playbackReducer } from '../timeline/playback'; 2 | import { reducer as segmentsReducers } from '../timeline/segments'; 3 | import globalState from './globalState'; 4 | 5 | const reducers = [ 6 | globalState, 7 | playbackReducer, 8 | segmentsReducers, 9 | ]; 10 | 11 | export default reducers; 12 | -------------------------------------------------------------------------------- /src/utils/conversions.js: -------------------------------------------------------------------------------- 1 | export const KM_PER_MI = 1.60934; 2 | 3 | let metric = null; 4 | 5 | export const isMetric = () => { 6 | if (metric === null) { 7 | // Only a few countries use imperial measurements 8 | metric = ['en-us', 'en-gb', 'my'].indexOf(window.navigator.language.toLowerCase()) === -1; 9 | } 10 | 11 | return metric; 12 | }; 13 | -------------------------------------------------------------------------------- /src/devtools.js: -------------------------------------------------------------------------------- 1 | import window from 'global/window'; 2 | import { compose } from 'redux'; 3 | 4 | function getComposeEnhancers() { 5 | if (process.env.REACT_APP_DEVTOOLS && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { 6 | return window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; 7 | } 8 | return compose; 9 | } 10 | 11 | const composeEnhancers = getComposeEnhancers(); 12 | 13 | export default composeEnhancers; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | .idea 4 | *.iml 5 | .vscode 6 | *.swp 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import '@testing-library/jest-dom'; 4 | 5 | jest.mock('localforage'); 6 | 7 | jest.mock('mapbox-gl/dist/mapbox-gl', () => ({ 8 | GeolocateControl: jest.fn(), 9 | Map: jest.fn(() => ({ 10 | addControl: jest.fn(), 11 | on: jest.fn(), 12 | remove: jest.fn(), 13 | })), 14 | NavigationControl: jest.fn(), 15 | })); 16 | -------------------------------------------------------------------------------- /lighthouserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ci: { 3 | upload: { 4 | target: 'temporary-public-storage', 5 | }, 6 | collect: { 7 | staticDistDir: 'build', 8 | isSinglePageApplication: true, 9 | url: [ 10 | 'http://localhost/', 11 | 'http://localhost/4cf7a6ad03080c90', 12 | 'http://localhost/4cf7a6ad03080c90/1632948396703/1632949028503/', 13 | ], 14 | numberOfRuns: 6, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/demo/index.js: -------------------------------------------------------------------------------- 1 | const demoDevices = require('./devices.json'); 2 | 3 | export function isDemoDevice(dongleId) { 4 | return demoDevices.some((d) => d.dongle_id === dongleId); 5 | } 6 | 7 | export function isDemoRoute(route) { 8 | return route === '4cf7a6ad03080c90|2021-09-29--13-46-36'; 9 | } 10 | 11 | export function isDemo() { 12 | if (!window.location || !window.location.pathname) { 13 | return false; 14 | } 15 | return isDemoDevice(window.location.pathname.split('/')[1]); 16 | } 17 | -------------------------------------------------------------------------------- /src/__puppeteer__/jest.config.js: -------------------------------------------------------------------------------- 1 | const { createJestConfig } = require('@craco/craco'); 2 | const cracoConfig = require('../../craco.config'); 3 | 4 | const jestConfig = createJestConfig(cracoConfig({ env: process.env.NODE_ENV })); 5 | 6 | module.exports = { 7 | ...jestConfig, 8 | preset: 'jest-puppeteer', 9 | setupFilesAfterEnv: [...jestConfig.setupFilesAfterEnv, 'expect-puppeteer'], 10 | testMatch: ['/src/__puppeteer__/**/*.test.{js,jsx,ts,tsx}'], 11 | testPathIgnorePatterns: ['node_modules'], 12 | }; 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-bullseye 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock /app/ 6 | RUN yarn install --immutable --immutable-cache --check-cache 7 | 8 | COPY . /app/ 9 | ENV GENERATE_SOURCEMAP false 10 | ARG SENTRY_AUTH_TOKEN 11 | RUN yarn build:production 12 | 13 | 14 | FROM nginx:1.22 15 | 16 | COPY config.js.template /etc/nginx/templates/config.js.template 17 | COPY nginx.conf /etc/nginx/conf.d/default.conf 18 | COPY --from=0 /app/build /usr/share/nginx/html 19 | 20 | ENV NGINX_ENVSUBST_OUTPUT_DIR /usr/share/nginx/html 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | import React from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import document from 'global/document'; 5 | import { CssBaseline, MuiThemeProvider } from '@material-ui/core'; 6 | 7 | import './index.css'; 8 | import App from './App'; 9 | import Theme from './theme'; 10 | import ServiceWorkerWrapper from './components/ServiceWorkerWrapper'; 11 | 12 | if (window.SENTRY_ENV) { 13 | Sentry.init({ 14 | dsn: 'https://6a242abfa01b4660aa34f150e87de018@o33823.ingest.sentry.io/1234624', 15 | environment: window.SENTRY_ENV, 16 | maxValueLength: 1000, 17 | }); 18 | } 19 | 20 | createRoot(document.getElementById('root')).render(( 21 | 22 | 23 | 24 | 25 | 26 | )); 27 | -------------------------------------------------------------------------------- /src/actions/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { push } from 'connected-react-router'; 3 | import { selectRange } from './index'; 4 | 5 | jest.mock('connected-react-router', () => { 6 | const originalModule = jest.requireActual('connected-react-router'); 7 | return { 8 | __esModule: true, 9 | ...originalModule, 10 | push: jest.fn(), 11 | }; 12 | }); 13 | 14 | describe('timeline actions', () => { 15 | it('should push history state when editing zoom', () => { 16 | const dispatch = jest.fn(); 17 | const getState = jest.fn(); 18 | const actionThunk = selectRange(123, 1234); 19 | 20 | getState.mockImplementationOnce(() => ({ 21 | dongleId: 'statedongle', 22 | loop: {}, 23 | zoom: {}, 24 | })); 25 | actionThunk(dispatch, getState); 26 | expect(push).toBeCalledWith('/statedongle/123/1234'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import * as Redux from 'redux'; 2 | import { connectRouter, routerMiddleware } from 'connected-react-router'; 3 | import thunk from 'redux-thunk'; 4 | import { createBrowserHistory } from 'history'; 5 | import reduceReducers from 'reduce-reducers'; 6 | 7 | import reducers from './reducers'; 8 | import composeEnhancers from './devtools'; 9 | import initialState from './initialState'; 10 | import { onHistoryMiddleware } from './actions/history'; 11 | import { analyticsMiddleware } from './analytics'; 12 | 13 | export const history = createBrowserHistory(); 14 | 15 | const store = Redux.createStore( 16 | connectRouter(history)(reduceReducers(initialState, ...reducers)), 17 | composeEnhancers(Redux.applyMiddleware( 18 | thunk, 19 | onHistoryMiddleware, 20 | routerMiddleware(history), 21 | analyticsMiddleware, 22 | )), 23 | ); 24 | 25 | export default store; 26 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "image": "mcr.microsoft.com/devcontainers/typescript-node:0-18" 6 | 7 | // Features to add to the dev container. More info: https://containers.dev/features. 8 | // "features": {}, 9 | 10 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 11 | // "forwardPorts": [], 12 | 13 | // Use 'postCreateCommand' to run commands after the container is created. 14 | // "postCreateCommand": "yarn install", 15 | 16 | // Configure tool-specific properties. 17 | // "customizations": {}, 18 | 19 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 20 | // "remoteUser": "root" 21 | } 22 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name localhost; 5 | 6 | gzip on; 7 | gzip_types text/html text/plain text/css text/xml text/javascript application/javascript application/x-javascript; 8 | gzip_min_length 1024; 9 | gzip_vary on; 10 | 11 | root /usr/share/nginx/html; 12 | 13 | location /service-worker.js { 14 | add_header Cache-Control 'no-store, no-cache'; 15 | if_modified_since off; 16 | expires off; 17 | etag off; 18 | 19 | try_files $uri $uri/ =404; 20 | } 21 | 22 | location /static/ { 23 | try_files $uri $uri/ =404; 24 | } 25 | 26 | location / { 27 | try_files $uri $uri/ /index.html; 28 | } 29 | 30 | location /404.html { 31 | internal; 32 | } 33 | 34 | location /50x.html { 35 | internal; 36 | } 37 | 38 | error_page 404 /404.html; 39 | error_page 500 502 503 504 /50x.html; 40 | } 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "connect", 3 | "name": "connect", 4 | "icons": [ 5 | { 6 | "src": "/icon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/icon-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/icon-384x384.png", 17 | "sizes": "384x384", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/icon-512x512.png", 22 | "sizes": "512x512", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/icon-maskable.png", 27 | "sizes": "512x512", 28 | "type": "image/png", 29 | "purpose": "any maskable" 30 | } 31 | ], 32 | "lang": "en-US", 33 | "start_url": "/", 34 | "scope": "/", 35 | "display": "standalone", 36 | "orientation": "portrait-primary", 37 | "theme_color": "#16181a", 38 | "background_color": "#16181a" 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Dashboard/DriveListItem.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react'; 3 | import * as Redux from 'redux'; 4 | import thunk from 'redux-thunk'; 5 | import { render, screen } from '@testing-library/react'; 6 | import DriveListItem from './DriveListItem'; 7 | 8 | const defaultState = { 9 | start: Date.now(), 10 | }; 11 | 12 | jest.mock('../Timeline'); 13 | 14 | const store = Redux.createStore((state) => { 15 | if (!state) { 16 | return { ...defaultState }; 17 | } 18 | return state; 19 | }, Redux.applyMiddleware(thunk)); 20 | 21 | describe('drive list items', () => { 22 | it('has DriveEntry class', () => { 23 | render(); 33 | expect(screen.getByRole('link')).toHaveClass('DriveEntry'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'react-app', 4 | 'airbnb', 5 | 'airbnb/hooks', 6 | ], 7 | rules: { 8 | 'import/prefer-default-export': 0, 9 | 'import/no-named-as-default': 0, 10 | 'react/destructuring-assignment': 'warn', 11 | 'react/jsx-curly-spacing': 0, 12 | 'react/jsx-filename-extension': 0, 13 | 'react/forbid-prop-types': 0, 14 | 'react/function-component-definition': [ 15 | 'error', 16 | { 17 | namedComponents: 'arrow-function', 18 | }, 19 | ], 20 | 'react/prop-types': 0, 21 | 'react/require-default-props': 0, 22 | 'no-await-in-loop': 'warn', 23 | 'no-plusplus': [ 24 | 'error', 25 | { 26 | allowForLoopAfterthoughts: true, 27 | }, 28 | ], 29 | 'no-underscore-dangle': 0, 30 | 'no-param-reassign': 0, 31 | 'object-curly-newline': [ 32 | 'error', 33 | { 34 | consistent: true, 35 | }, 36 | ], 37 | }, 38 | env: { 39 | browser: true, 40 | }, 41 | globals: { 42 | gtag: 'readonly', 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/ResizeHandler/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const ResizeHandler = (props) => { 5 | const { onResize } = props; 6 | 7 | let resizeTimeout; 8 | const handleResize = () => { 9 | if (resizeTimeout) { 10 | window.clearTimeout(resizeTimeout); 11 | } 12 | 13 | resizeTimeout = window.setTimeout(() => { 14 | onResize(window.innerWidth, window.innerHeight); 15 | resizeTimeout = null; 16 | }, 150); 17 | }; 18 | 19 | /* eslint-disable react-hooks/exhaustive-deps */ 20 | useEffect(() => { 21 | window.addEventListener('resize', handleResize); 22 | 23 | return () => { 24 | window.removeEventListener('resize', handleResize); 25 | if (resizeTimeout) { 26 | window.clearTimeout(resizeTimeout); 27 | } 28 | }; 29 | }, [onResize]); 30 | /* eslint-enable react-hooks/exhaustive-deps */ 31 | 32 | return null; 33 | }; 34 | 35 | ResizeHandler.propTypes = { 36 | onResize: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default ResizeHandler; 40 | -------------------------------------------------------------------------------- /src/components/Dashboard/DriveListEmpty.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Grid, Typography, withStyles } from '@material-ui/core'; 3 | 4 | import { useWindowWidth } from '../../hooks/window'; 5 | import { hasRoutesData } from '../../timeline/segments'; 6 | 7 | const styles = () => ({ 8 | zeroState: { 9 | flex: '0', 10 | }, 11 | }); 12 | 13 | const DriveListEmpty = (props) => { 14 | const windowWidth = useWindowWidth(); 15 | const { classes, device, routes } = props; 16 | let zeroRidesEle = null; 17 | 18 | if (device && routes === null) { 19 | zeroRidesEle = Loading...; 20 | } else if (hasRoutesData(props) && routes?.length === 0) { 21 | zeroRidesEle = ( 22 | Looks like you haven't driven in the selected time range. 23 | ); 24 | } 25 | 26 | const containerPadding = windowWidth > 520 ? 36 : 16; 27 | return ( 28 | 29 | {zeroRidesEle} 30 | 31 | ); 32 | }; 33 | 34 | export default withStyles(styles)(DriveListEmpty); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 comma.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/Prime/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | import qs from 'query-string'; 5 | 6 | import { Typography } from '@material-ui/core'; 7 | import PrimeManage from './PrimeManage'; 8 | import PrimeCheckout from './PrimeCheckout'; 9 | 10 | const Prime = (props) => { 11 | let stripeCancelled; 12 | let stripeSuccess; 13 | if (window.location) { 14 | const params = qs.parse(window.location.search); 15 | stripeCancelled = params.stripe_cancelled; 16 | stripeSuccess = params.stripe_success; 17 | } 18 | 19 | const { device, profile } = props; 20 | if (!profile) { 21 | return null; 22 | } 23 | 24 | if (!device.is_owner && !profile.superuser) { 25 | return (No access); 26 | } 27 | if (device.prime || stripeSuccess) { 28 | return (); 29 | } 30 | return (); 31 | }; 32 | 33 | const stateToProps = Obstruction({ 34 | subscription: 'subscription', 35 | device: 'device', 36 | profile: 'profile', 37 | }); 38 | 39 | export default connect(stateToProps)(Prime); 40 | -------------------------------------------------------------------------------- /src/utils/clips.js: -------------------------------------------------------------------------------- 1 | import fecha from 'fecha'; 2 | 3 | export function clipErrorToText(errorStatus) { 4 | switch (errorStatus) { 5 | case 'upload_failed_request': 6 | return 'Unable to request file upload from device.'; 7 | case 'upload_failed': 8 | return 'Not all files needed for this clip could be found on the device.'; 9 | case 'upload_failed_dcam': 10 | return 'Not all files needed for this clip could be found on the device, was the "Record and Upload Driver Camera" toggle active?'; 11 | case 'upload_timed_out': 12 | return 'File upload timed out, the device must be on WiFi to upload the required files.'; 13 | case 'export_failed': 14 | return 'An error occurred while creating this clip.'; 15 | default: 16 | return 'Unable to create clip.'; 17 | } 18 | } 19 | 20 | export function formatClipDuration(duration) { 21 | const minutes = Math.floor((duration / (1000 * 60))) % 60; 22 | const seconds = Math.floor((duration / 1000) % 60); 23 | return `${minutes > 0 ? `${minutes} min ` : ''}${seconds} sec`; 24 | } 25 | 26 | export function formatClipTimestamp(timestamp) { 27 | const formatMask = 'MMM Do, HH:mm'; 28 | return fecha.format(timestamp * (timestamp < 10000000000 ? 1000 : 1), formatMask); 29 | } 30 | -------------------------------------------------------------------------------- /src/url.js: -------------------------------------------------------------------------------- 1 | const dongleIdRegex = /[a-f0-9]{16}/; 2 | 3 | export function getDongleID(pathname) { 4 | let parts = pathname.split('/'); 5 | parts = parts.filter((m) => m.length); 6 | 7 | if (!dongleIdRegex.test(parts[0])) { 8 | return null; 9 | } 10 | 11 | return parts[0] || null; 12 | } 13 | 14 | export function getZoom(pathname) { 15 | let parts = pathname.split('/'); 16 | parts = parts.filter((m) => m.length); 17 | 18 | if (parts.length >= 3 && parts[0] !== 'auth' && parts[1] !== 'clips') { 19 | return { 20 | start: Number(parts[1]), 21 | end: Number(parts[2]), 22 | }; 23 | } 24 | return null; 25 | } 26 | 27 | export function getPrimeNav(pathname) { 28 | let parts = pathname.split('/'); 29 | parts = parts.filter((m) => m.length); 30 | 31 | if (parts.length === 2 && parts[0] !== 'auth' && parts[1] === 'prime') { 32 | return true; 33 | } 34 | return false; 35 | } 36 | 37 | export function getClipsNav(pathname) { 38 | let parts = pathname.split('/'); 39 | parts = parts.filter((m) => m.length); 40 | 41 | if (parts.length >= 2 && parts[0] !== 'auth' && parts[1] === 'clips') { 42 | if (parts.length === 3 && parts[2]) { 43 | return { 44 | clip_id: parts[2], 45 | }; 46 | } 47 | return {}; 48 | } 49 | return null; 50 | } 51 | -------------------------------------------------------------------------------- /src/__puppeteer__/demo.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import puppeteer from 'puppeteer'; 3 | import { asyncSleep } from '../utils'; 4 | 5 | const width = 1600; 6 | const height = 1200; 7 | 8 | jest.setTimeout(60000); 9 | 10 | describe('demo mode', () => { 11 | let browser; 12 | let page; 13 | 14 | beforeEach(async () => { 15 | await asyncSleep(500); 16 | }); 17 | beforeAll(async () => { 18 | browser = await puppeteer.launch({ 19 | headless: true, 20 | slowMo: 80, 21 | args: [`--window-size=${width},${height}`], 22 | }); 23 | page = await browser.newPage(); 24 | await page.setViewport({ 25 | width, 26 | height, 27 | deviceScaleFactor: 1, 28 | }); 29 | await page.goto('http://localhost:3003/4cf7a6ad03080c90'); 30 | // wait for the data to start loading... 31 | await asyncSleep(8000); 32 | 33 | return true; 34 | }); 35 | afterAll(async () => { 36 | await page.close(); 37 | await browser.close(); 38 | return true; 39 | }); 40 | 41 | it('should load', async () => { 42 | const list = await expect(page).toMatchElement('.DriveList'); 43 | expect((await list.$$(':scope > a')).length).toBe(1); 44 | 45 | await expect(page).toClick('.DriveEntry'); 46 | await asyncSleep(3000); 47 | 48 | const video = await page.$('video'); 49 | const videoSrc = await page.evaluate((vid) => vid.getAttribute('src'), video); 50 | expect(videoSrc.startsWith('blob:')).toBeTruthy(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/timeline/index.js: -------------------------------------------------------------------------------- 1 | import store from '../store'; 2 | 3 | /** 4 | * Get current playback offset, relative to `state.filter.start` 5 | * 6 | * @param {object} state 7 | * @returns {number} 8 | */ 9 | export function currentOffset(state = null) { 10 | if (!state) { 11 | state = store.getState(); 12 | } 13 | 14 | /** @type {number} */ 15 | let offset; 16 | if (state.offset === null && state.loop?.startTime) { 17 | offset = state.loop.startTime - state.filter.start; 18 | } else { 19 | const playSpeed = state.isBufferingVideo ? 0 : state.desiredPlaySpeed; 20 | offset = state.offset + ((Date.now() - state.startTime) * playSpeed); 21 | } 22 | 23 | if (offset !== null && state.loop?.startTime) { 24 | // respect the loop 25 | const loopOffset = state.loop.startTime - state.filter.start; 26 | if (offset < loopOffset) { 27 | offset = loopOffset; 28 | } else if (offset > loopOffset + state.loop.duration) { 29 | offset = ((offset - loopOffset) % state.loop.duration) + loopOffset; 30 | } 31 | } 32 | 33 | return offset; 34 | } 35 | 36 | /** 37 | * Get current route 38 | * 39 | * @param {object} state 40 | * @param {number} [offset] 41 | * @returns {*|null} 42 | */ 43 | export function getCurrentRoute(state, offset) { 44 | if (!state.routes) return null; 45 | 46 | offset = offset || currentOffset(state); 47 | if (!offset) return null; 48 | 49 | return state.routes 50 | .find((route) => offset >= route.offset && offset <= route.offset + route.duration); 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/geocode.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { priorityGetContext, reverseLookup, forwardLookup } from './geocode'; 3 | 4 | describe('priorityGetContext', () => { 5 | it('should return the first context with a priority', () => { 6 | const contexts = [ 7 | { id: 'place.123' }, 8 | { id: 'locality.123' }, 9 | { id: 'district.123' }, 10 | ]; 11 | expect(priorityGetContext(contexts)).toEqual(contexts[0]); 12 | }); 13 | }); 14 | 15 | describe('reverseLookup', () => { 16 | jest.setTimeout(10000); 17 | 18 | it('should return null if coords are [0, 0]', async () => { 19 | const result = await reverseLookup([0, 0]); 20 | expect(result).toBeNull(); 21 | }); 22 | 23 | it('should return market street', async () => { 24 | const result = await reverseLookup([-117.12547, 32.71137], true); 25 | expect(result).toEqual({ 26 | details: 'San Diego, CA 92102, United States', 27 | place: 'Market Street', 28 | }); 29 | }); 30 | }); 31 | 32 | describe('forwardLookup', () => { 33 | jest.setTimeout(10000); 34 | 35 | it('should return null if query is empty', async () => { 36 | const result = await forwardLookup(''); 37 | expect(result).toHaveLength(0); 38 | }); 39 | 40 | it('should return taco bell', async () => { 41 | const result = await forwardLookup('Taco Bell, 3195 Market St, San Diego, CA 92102'); 42 | const { lat, lng } = result[0].position; 43 | expect(lat).toBeCloseTo(32.71137, 2); 44 | expect(lng).toBeCloseTo(-117.12541, 2); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/actions/history.js: -------------------------------------------------------------------------------- 1 | import { LOCATION_CHANGE } from 'connected-react-router'; 2 | import { getDongleID, getZoom, getPrimeNav, getClipsNav } from '../url'; 3 | import { primeNav, selectDevice, selectRange } from './index'; 4 | import { clipsExit, fetchClipsDetails, fetchClipsList } from './clips'; 5 | 6 | export const onHistoryMiddleware = ({ dispatch, getState }) => (next) => (action) => { 7 | if (action.type === LOCATION_CHANGE && ['POP', 'REPLACE'].includes(action.payload.action)) { 8 | const state = getState(); 9 | 10 | next(action); // must be first, otherwise breaks history 11 | 12 | const pathDongleId = getDongleID(action.payload.location.pathname); 13 | if (pathDongleId && pathDongleId !== state.dongleId) { 14 | dispatch(selectDevice(pathDongleId, false)); 15 | } 16 | 17 | const pathZoom = getZoom(action.payload.location.pathname); 18 | if (pathZoom !== state.zoom) { 19 | dispatch(selectRange(pathZoom?.start, pathZoom?.end, false)); 20 | } 21 | 22 | const pathPrimeNav = getPrimeNav(action.payload.location.pathname); 23 | if (pathPrimeNav !== state.primeNav) { 24 | dispatch(primeNav(pathPrimeNav)); 25 | } 26 | 27 | const pathClipsNav = getClipsNav(action.payload.location.pathname); 28 | if (pathClipsNav === null && state.clips) { 29 | dispatch(clipsExit()); 30 | } else if (pathClipsNav !== null) { 31 | if (pathClipsNav.clip_id) { 32 | dispatch(fetchClipsDetails(pathClipsNav.clip_id)); 33 | } else { 34 | dispatch(fetchClipsList(pathDongleId)); 35 | } 36 | } 37 | } else { 38 | next(action); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Dashboard/index.js: -------------------------------------------------------------------------------- 1 | import React, { lazy, Suspense } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | 5 | import { CircularProgress, Grid, withStyles } from '@material-ui/core'; 6 | 7 | import DriveList from './DriveList'; 8 | import Navigation from '../Navigation'; 9 | import DeviceInfo from '../DeviceInfo'; 10 | 11 | const Prime = lazy(() => import('../Prime')); 12 | 13 | const styles = () => ({ 14 | base: { 15 | display: 'flex', 16 | flexDirection: 'column', 17 | }, 18 | }); 19 | 20 | const DashboardLoading = () => ( 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | 28 | const Dashboard = ({ classes, primeNav, device, dongleId }) => { 29 | if (!device || !dongleId) { 30 | return null; 31 | } 32 | 33 | return ( 34 |
35 | }> 36 | { primeNav 37 | ? 38 | : ( 39 | <> 40 | 41 | 42 | 43 | 44 | )} 45 | 46 |
47 | ); 48 | }; 49 | 50 | const stateToProps = Obstruction({ 51 | dongleId: 'dongleId', 52 | primeNav: 'primeNav', 53 | device: 'device', 54 | }); 55 | 56 | export default connect(stateToProps)(withStyles(styles)(Dashboard)); 57 | -------------------------------------------------------------------------------- /src/components/Dashboard/DriveList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | import { withStyles } from '@material-ui/core'; 5 | 6 | import { checkRoutesData } from '../../actions'; 7 | import VisibilityHandler from '../VisibilityHandler'; 8 | 9 | import DriveListEmpty from './DriveListEmpty'; 10 | import DriveListItem from './DriveListItem'; 11 | 12 | const styles = () => ({ 13 | drivesTable: { 14 | display: 'flex', 15 | flexDirection: 'column', 16 | flexGrow: 1, 17 | }, 18 | drives: { 19 | padding: 16, 20 | flex: '1', 21 | }, 22 | }); 23 | 24 | const DriveList = (props) => { 25 | const { dispatch, classes, device, routes } = props; 26 | 27 | // Filter out drives shorter than 10 seconds 28 | const driveList = (routes || []) 29 | .filter((drive) => drive.end_time_utc_millis - drive.start_time_utc_millis >= 10_000); 30 | 31 | let content; 32 | if (!driveList.length) { 33 | content = ; 34 | } else { 35 | content = ( 36 |
37 | {driveList.map((drive) => ( 38 | 39 | ))} 40 |
41 | ); 42 | } 43 | 44 | return ( 45 |
46 | dispatch(checkRoutesData())} minInterval={60} /> 47 | {content} 48 |
49 | ); 50 | }; 51 | 52 | const stateToProps = Obstruction({ 53 | routes: 'routes', 54 | device: 'device', 55 | }); 56 | 57 | export default connect(stateToProps)(withStyles(styles)(DriveList)); 58 | -------------------------------------------------------------------------------- /src/hooks/window.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | const RESIZE_DEBOUNCE = 150; // ms 4 | 5 | export const useWindowWidth = () => { 6 | const [width, setWidth] = useState(window.innerWidth); 7 | const resizeTimeout = useRef(null); 8 | 9 | useEffect(() => { 10 | const handleResize = () => { 11 | if (resizeTimeout.current) { 12 | window.clearTimeout(resizeTimeout.current); 13 | } 14 | 15 | resizeTimeout.current = window.setTimeout(() => { 16 | setWidth(window.innerWidth); 17 | resizeTimeout.current = null; 18 | }, RESIZE_DEBOUNCE); 19 | }; 20 | window.addEventListener('resize', handleResize); 21 | return () => { 22 | window.removeEventListener('resize', handleResize); 23 | if (resizeTimeout.current) { 24 | window.clearTimeout(resizeTimeout.current); 25 | } 26 | }; 27 | }, []); 28 | 29 | return width; 30 | }; 31 | 32 | export const useWindowHeight = () => { 33 | const [height, setHeight] = useState(window.innerHeight); 34 | const resizeTimeout = useRef(null); 35 | 36 | useEffect(() => { 37 | const handleResize = () => { 38 | if (resizeTimeout.current) { 39 | window.clearTimeout(resizeTimeout.current); 40 | } 41 | 42 | resizeTimeout.current = window.setTimeout(() => { 43 | setHeight(window.innerHeight); 44 | resizeTimeout.current = null; 45 | }, RESIZE_DEBOUNCE); 46 | }; 47 | window.addEventListener('resize', handleResize); 48 | return () => { 49 | window.removeEventListener('resize', handleResize); 50 | if (resizeTimeout.current) { 51 | window.clearTimeout(resizeTimeout.current); 52 | } 53 | }; 54 | }, []); 55 | 56 | return height; 57 | }; 58 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Explorer Base Styles 4 | ~~~~~~~~~~~~~~~~~~~~ 5 | 6 | */ 7 | 8 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap'); 9 | @import "~photo-sphere-viewer/dist/photo-sphere-viewer.css"; 10 | @import "~photo-sphere-viewer/dist/plugins/video.css"; 11 | 12 | @keyframes circular-rotate { 13 | 0% { 14 | transform: rotate(0deg); 15 | /* Fix IE11 wobbly */ 16 | transform-origin: 50% 50%; 17 | } 18 | 100% { 19 | transform: rotate(360deg); 20 | } 21 | } 22 | 23 | :root { 24 | color-scheme: dark; 25 | } 26 | 27 | html, body { 28 | overscroll-behavior-y: none; 29 | } 30 | 31 | html { 32 | font-size: 16px; 33 | } 34 | 35 | body { 36 | background: #16181A; 37 | font-family: 'Inter', sans-serif; 38 | scrollbar-gutter: stable; 39 | overflow-y: scroll; 40 | margin: 0; 41 | padding: 0; 42 | } 43 | 44 | a:focus { 45 | outline: none; 46 | box-shadow: none; 47 | } 48 | 49 | /* 50 | Scrollbar 51 | ~~~~~~~~~~ 52 | */ 53 | 54 | .scrollstyle { 55 | scrollbar-color: rgba(255, 255, 255, 0.2) transparent; 56 | } 57 | 58 | .scrollstyle::-webkit-scrollbar-track { 59 | background-color: transparent; 60 | } 61 | 62 | .scrollstyle::-webkit-scrollbar { 63 | -webkit-appearance: none; 64 | background-color: transparent; 65 | width: 6px; 66 | } 67 | 68 | .scrollstyle::-webkit-scrollbar-thumb { 69 | background-color: transparent; 70 | border-radius: 6px; 71 | } 72 | 73 | .scrollstyle:hover::-webkit-scrollbar-thumb { 74 | background-color: rgba(255, 255, 255, 0.1); 75 | } 76 | 77 | .scrollstyle::-webkit-scrollbar-thumb:hover { 78 | background-color: rgba(255, 255, 255, 0.2); 79 | } 80 | 81 | .scrollstyle::-webkit-scrollbar-thumb:active { 82 | background-color: rgba(255, 255, 255, 0.2); 83 | } 84 | -------------------------------------------------------------------------------- /src/initialState.js: -------------------------------------------------------------------------------- 1 | import { getDongleID, getZoom, getPrimeNav } from './url'; 2 | import * as Demo from './demo'; 3 | 4 | export function getDefaultFilter() { 5 | const d = new Date(); 6 | d.setHours(d.getHours() + 1, 0, 0, 0); 7 | 8 | if (Demo.isDemo()) { 9 | return { 10 | start: 1632948396703, 11 | end: 1632949028503, 12 | }; 13 | } 14 | 15 | return { 16 | start: (new Date(d.getTime() - 1000 * 60 * 60 * 24 * 14)).getTime(), 17 | end: d.getTime(), 18 | }; 19 | } 20 | 21 | function getDefaultLoop(pathname) { 22 | // in time instead of offset 23 | // this makes it so that the timespan can change without this changing 24 | // thats helpful to shared links and other things probably... 25 | const zoom = getZoom(pathname); 26 | if (zoom) { 27 | return { 28 | startTime: zoom.start, 29 | duration: zoom.end - zoom.start, 30 | }; 31 | } 32 | return null; 33 | } 34 | 35 | export default { 36 | dongleId: getDongleID(window.location.pathname), 37 | 38 | desiredPlaySpeed: 1, // speed set by user 39 | isBufferingVideo: true, // if we're currently buffering for more data 40 | offset: null, // in miliseconds, relative to `state.filter.start` 41 | startTime: Date.now(), // millisecond timestamp in which play began 42 | 43 | routes: null, 44 | routesMeta: { 45 | dongleId: null, 46 | start: null, 47 | end: null, 48 | }, 49 | currentRoute: null, 50 | 51 | profile: null, 52 | devices: null, 53 | 54 | primeNav: getPrimeNav(window.location.pathname), 55 | subscription: null, 56 | subscribeInfo: null, 57 | 58 | files: null, 59 | filesUploading: {}, 60 | filesUploadingMeta: { 61 | dongleId: null, 62 | fetchedAt: null, 63 | }, 64 | 65 | clips: null, 66 | 67 | filter: getDefaultFilter(), 68 | zoom: getZoom(window.location.pathname), 69 | loop: getDefaultLoop(window.location.pathname), 70 | }; 71 | -------------------------------------------------------------------------------- /src/utils/utils.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { deviceVersionAtLeast, formatDriveDuration } from '.'; 3 | 4 | test('formats durations correctly', () => { 5 | // 1 hour, 59 minutes, 59 seconds 6 | const one = 1 * 60 * 60 * 1000 + 59 * 60 * 1000 + 59 * 1000; 7 | const oneFormatted = formatDriveDuration(one); 8 | expect(oneFormatted).toEqual('1 hr 59 min'); 9 | 10 | // 59 minutes, 59 seconds 11 | const two = 59 * 60 * 1000 + 59 * 1000; 12 | const twoFormatted = formatDriveDuration(two); 13 | expect(twoFormatted).toEqual('59 min'); 14 | 15 | // 60 seconds 16 | const three = 60 * 1000; 17 | const threeFormatted = formatDriveDuration(three); 18 | expect(threeFormatted).toEqual('1 min'); 19 | 20 | // 59 seconds 21 | const four = 59 * 1000; 22 | const fourFormatted = formatDriveDuration(four); 23 | expect(fourFormatted).toEqual('0 min'); 24 | }); 25 | 26 | test('compares versions correctly', () => { 27 | const device = (version) => ({ openpilot_version: version }); 28 | expect(deviceVersionAtLeast(device('0.8.0'), '0.0.1')).toEqual(true); 29 | expect(deviceVersionAtLeast(device('0.8.0'), '0.7.0')).toEqual(true); 30 | expect(deviceVersionAtLeast(device('0.8.0'), '0.7.99')).toEqual(true); 31 | expect(deviceVersionAtLeast(device('0.8.0.1'), '0.8.0')).toEqual(true); 32 | expect(deviceVersionAtLeast(device('0.8.0.1'), '0.8.0.1')).toEqual(true); 33 | 34 | expect(deviceVersionAtLeast(device('0.8.0'), '0.8.1')).toEqual(false); 35 | expect(deviceVersionAtLeast(device('0.8.0'), '0.9.0')).toEqual(false); 36 | expect(deviceVersionAtLeast(device('0.8.0'), '1.0.0')).toEqual(false); 37 | expect(deviceVersionAtLeast(device('0.7.99'), '0.8.0')).toEqual(false); 38 | expect(deviceVersionAtLeast(device('1.0.0'), '2.0.0')).toEqual(false); 39 | 40 | expect(deviceVersionAtLeast(device('0.8.14'), '0.8.14')).toEqual(true); 41 | expect(deviceVersionAtLeast(device('0.8.14'), '0.8.13')).toEqual(true); 42 | expect(deviceVersionAtLeast(device('0.8.13'), '0.8.14')).toEqual(false); 43 | expect(deviceVersionAtLeast(device('0.8.13'), '0.8.13')).toEqual(true); 44 | }); 45 | -------------------------------------------------------------------------------- /src/components/ResizeHandler/ResizeHandler.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react'; 3 | import { fireEvent, render, waitFor } from '@testing-library/react'; 4 | 5 | import ResizeHandler from '.'; 6 | import { asyncSleep } from '../../utils'; 7 | 8 | describe('resize handler', () => { 9 | it('registers, triggers and unregistered resize listener', async () => { 10 | let aResizeEventListenerWasAddedToWindow = false; 11 | let aResizeEventListenerWasRemovedFromWindow = false; 12 | 13 | const originalAddMethod = window.addEventListener; 14 | const addSpy = jest.spyOn(window, 'addEventListener'); 15 | 16 | addSpy.mockImplementation((...args) => { 17 | originalAddMethod(...args); 18 | 19 | const [eventType] = args; 20 | if (eventType === 'resize') { 21 | aResizeEventListenerWasAddedToWindow = true; 22 | } 23 | }); 24 | 25 | const originalRemoveMethod = window.removeEventListener; 26 | const removeSpy = jest.spyOn(window, 'removeEventListener'); 27 | 28 | removeSpy.mockImplementation((...args) => { 29 | const [eventType] = args; 30 | if (eventType === 'resize') { 31 | aResizeEventListenerWasRemovedFromWindow = true; 32 | } 33 | 34 | originalRemoveMethod(...args); 35 | }); 36 | 37 | const container = document.createElement('div'); 38 | const callback = jest.fn(); 39 | const { unmount } = render(, { container }); 40 | 41 | // Wait for the resize handler in the component to be registered (useEffect callback is async) 42 | await waitFor(() => expect(aResizeEventListenerWasAddedToWindow).toBeTruthy()); 43 | fireEvent.resize(window); 44 | await asyncSleep(150); 45 | expect(callback).toHaveBeenCalled(); 46 | 47 | unmount(); 48 | 49 | // Wait for resize handler in the component to be unregistered 50 | await waitFor(() => expect(aResizeEventListenerWasRemovedFromWindow).toBeTruthy()); 51 | 52 | // Restore the original methods to window 53 | addSpy.mockRestore(); 54 | removeSpy.mockRestore(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/icons/360-degrees-video.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/Navigation/utils.js: -------------------------------------------------------------------------------- 1 | export function formatDistance(meters, metric) { 2 | if (metric) { 3 | return `${(meters / 1000.0).toFixed(1)} km`; 4 | } 5 | return `${(meters / 1609.34).toFixed(1)} mi`; 6 | } 7 | 8 | export function formatRouteDistance(route) { 9 | let metric = true; 10 | try { 11 | route.legs[0].admins.forEach((adm) => { 12 | if (['US', 'GB'].includes(adm.iso_3166_1)) { 13 | metric = false; 14 | } 15 | }); 16 | } catch (err) { 17 | metric = false; 18 | } 19 | 20 | return formatDistance(route.distance, metric); 21 | } 22 | 23 | export function formatDuration(seconds) { 24 | let mins = Math.round(seconds / 60.0); 25 | let res = ''; 26 | if (mins >= 60) { 27 | const hours = Math.floor(mins / 60.0); 28 | mins -= hours * 60; 29 | res += `${hours} hr `; 30 | } 31 | return `${res}${mins} min`; 32 | } 33 | 34 | export function formatRouteDuration(route) { 35 | return formatDuration(route.duration_typical); 36 | } 37 | 38 | export function formatSearchName(item) { 39 | if (item.resultType === 'place' || item.resultType === 'car') { 40 | return item.title; 41 | } 42 | return item.title.split(',', 1)[0]; 43 | } 44 | 45 | export function formatSearchAddress(item, full = true) { 46 | let res; 47 | 48 | if (['street', 'city'].some((key) => !item.address[key])) { 49 | res = item.address.label; 50 | } else { 51 | const { houseNumber, street, city } = item.address; 52 | res = houseNumber ? `${houseNumber} ${street}, ${city}`.trimStart() : `${street}, ${city}`; 53 | if (full) { 54 | const { stateCode, postalCode, countryName } = item.address; 55 | res += `, ${stateCode} ${postalCode}, ${countryName}`; 56 | } 57 | } 58 | 59 | if (!full) { 60 | const name = formatSearchName(item); 61 | if (res.startsWith(name)) { 62 | res = res.substring(name.length + 2); 63 | } 64 | } 65 | 66 | return res; 67 | } 68 | 69 | export function formatSearchDetails(item) { 70 | return formatSearchAddress(item, false); 71 | } 72 | 73 | export function formatSearchList(item) { 74 | const address = formatSearchAddress(item, false); 75 | return `, ${address} (${formatDistance(item.distance)})`; 76 | } 77 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | const { loaderByName, addBeforeLoader } = require('@craco/craco'); 2 | 3 | const SentryCliPlugin = require('@sentry/webpack-plugin'); 4 | const CompressionPlugin = require('compression-webpack-plugin'); 5 | const zlib = require('zlib'); 6 | 7 | const eslintConfig = require('./.eslintrc'); 8 | 9 | module.exports = ({ env }) => { 10 | let sentryPlugin; 11 | if (env === 'production' && process.env.SENTRY_AUTH_TOKEN) { 12 | sentryPlugin = new SentryCliPlugin({ 13 | include: './build/', 14 | ignoreFile: '.sentrycliignore', 15 | ignore: ['node_modules', 'webpack.config.js', 'craco.config.js'], 16 | configFile: 'sentry.properties', 17 | }); 18 | } 19 | 20 | let compressionPlugin; 21 | if (env === 'production') { 22 | compressionPlugin = new CompressionPlugin({ 23 | filename: '[path][base].br', 24 | algorithm: 'brotliCompress', 25 | test: /\.(js|css|html|svg)$/, 26 | compressionOptions: { 27 | params: { 28 | [zlib.constants.BROTLI_PARAM_QUALITY]: 11, 29 | }, 30 | }, 31 | threshold: 10240, 32 | minRatio: 0.8, 33 | deleteOriginalAssets: false, 34 | }); 35 | } 36 | 37 | return { 38 | eslint: { 39 | enable: false, 40 | config: eslintConfig, 41 | }, 42 | webpack: { 43 | plugins: [ 44 | sentryPlugin, 45 | compressionPlugin, 46 | ].filter(Boolean), 47 | configure: (webpackConfig) => { 48 | webpackConfig.output.globalObject = 'this'; 49 | addBeforeLoader(webpackConfig, loaderByName('babel-loader'), { 50 | test: /\.worker\.js/, 51 | use: { loader: 'worker-loader' }, 52 | }); 53 | webpackConfig.optimization.minimizer.forEach((plugin) => { 54 | if (plugin.constructor.name !== 'TerserPlugin') { 55 | return; 56 | } 57 | plugin.options.terserOptions = { keep_fnames: true }; 58 | }); 59 | return webpackConfig; 60 | }, 61 | }, 62 | jest: { 63 | configure: (jestConfig) => ({ 64 | ...jestConfig, 65 | testPathIgnorePatterns: ['node_modules', 'src/__puppeteer__'], 66 | transformIgnorePatterns: ['node_modules/(?!(@commaai/(.+))/)'], 67 | }), 68 | }, 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # comma connect 2 | 3 | The frontend to the comma connect progressive web app. This a react app using [Create React App](https://github.com/facebookincubator/create-react-app) 4 | 5 | ## Environments 6 | * Development (local machine) http://localhost:3000 7 | * Staging (docker) 8 | * packages/images are build by CI, and put on staging branch 9 | * Production (docker) https://connect.comma.ai 10 | * pushed manually 11 | 12 | ## Libraries Used 13 | There's a ton of them, but these are worth mentioning because they sort of affect everything. 14 | 15 | * `React` - Object oriented components with basic lifecycle callbacks rendered by state and prop changes. 16 | * `Redux` - Sane formal *global* scope. This is not a replacement for component state, which is the best way to store local component level variables and trigger re-renders. Redux state is for global state that many unrelated components care about. No free-form editing, only specific pre-defined actions. [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en) can be very helpful. 17 | * `@material-ui` - Lots of fully featured highly customizable components for building the UIs with. Theming system with global and per-component overrides of any CSS values. 18 | * `react-router-redux` - the newer one, 5.x.... Mindlessly simple routing with convenient global access due to redux 19 | 20 | ## How things works 21 | The current playback is tracked not by storing the current offset, but instead storing the local time that the player began, the offset it began at, and the playback rate. Any time any of these values change, it rebases them all back to the current time. It means that at any arbitrary moment you can calculate the current offset with... 22 | ```js 23 | (Date.now() - state.startTime) * state.playSpeed + state.offset 24 | ``` 25 | 26 | With this central authority on current offset time, it becomes much easier to have each data source keep themselves in sync instead of trying to manage synchronizing all of them. 27 | 28 | ## Development 29 | `yarn start` 30 | 31 | ## Contributing 32 | 33 | * Use best practices 34 | * Write test cases 35 | * Keep files small and clean 36 | * Use branches / pull requests to isolate work. Don't do work that can't be merged quickly, find ways to break it up 37 | -------------------------------------------------------------------------------- /src/actions/types.js: -------------------------------------------------------------------------------- 1 | export const TIMELINE_SELECTION_CHANGED = 'timeline_selection_changed'; 2 | 3 | // init 4 | export const ACTION_STARTUP_DATA = 'ACTION_STARTUP_DATA'; 5 | 6 | // global state management 7 | export const ACTION_SELECT_DEVICE = 'ACTION_SELECT_DEVICE'; 8 | export const ACTION_SELECT_TIME_FILTER = 'ACTION_SELECT_TIME_FILTER'; 9 | export const ACTION_UPDATE_DEVICES = 'ACTION_UPDATE_DEVICES'; 10 | export const ACTION_UPDATE_DEVICE = 'ACTION_UPDATE_DEVICE'; 11 | export const ACTION_UPDATE_ROUTE = 'ACTION_UPDATE_ROUTE'; 12 | export const ACTION_UPDATE_ROUTE_EVENTS = 'ACTION_UPDATE_ROUTE_EVENTS'; 13 | export const ACTION_UPDATE_ROUTE_LOCATION = 'ACTION_UPDATE_ROUTE_LOCATION'; 14 | export const ACTION_UPDATE_SHARED_DEVICE = 'ACTION_UPDATE_SHARED_DEVICE'; 15 | export const ACTION_UPDATE_DEVICE_ONLINE = 'ACTION_UPDATE_DEVICE_ONLINE'; 16 | export const ACTION_UPDATE_DEVICE_NETWORK = 'ACTION_UPDATE_DEVICE_NETWORK'; 17 | export const ACTION_PRIME_NAV = 'ACTION_PRIME_NAV'; 18 | export const ACTION_PRIME_SUBSCRIPTION = 'ACTION_PRIME_SUBSCRIPTION'; 19 | export const ACTION_PRIME_SUBSCRIBE_INFO = 'ACTION_PRIME_SUBSCRIBE_INFO'; 20 | 21 | // playback 22 | export const ACTION_SEEK = 'action_seek'; 23 | export const ACTION_PAUSE = 'action_pause'; 24 | export const ACTION_PLAY = 'action_play'; 25 | export const ACTION_LOOP = 'action_loop'; 26 | export const ACTION_BUFFER_VIDEO = 'action_buffer_video'; 27 | export const ACTION_RESET = 'action_reset'; 28 | 29 | // segments 30 | export const ACTION_UPDATE_SEGMENTS = 'update_segments'; 31 | export const ACTION_ROUTES_METADATA = 'routes_metadata'; 32 | 33 | // files 34 | export const ACTION_FILES_URLS = 'files_urls'; 35 | export const ACTION_FILES_UPDATE = 'files_update'; 36 | export const ACTION_FILES_UPLOADING = 'files_uploading'; 37 | export const ACTION_FILES_CANCELLED_UPLOADS = 'files_cancelled_uploads'; 38 | 39 | // analytics 40 | export const ANALYTICS_EVENT = 'analytics_event'; 41 | 42 | // clips 43 | export const ACTION_CLIPS_LOADING = 'clips_loading'; 44 | export const ACTION_CLIPS_INIT = 'clips_init'; 45 | export const ACTION_CLIPS_LIST = 'clips_list'; 46 | export const ACTION_CLIPS_CREATE = 'clips_create'; 47 | export const ACTION_CLIPS_DONE = 'clips_done'; 48 | export const ACTION_CLIPS_EXIT = 'clips_exit'; 49 | export const ACTION_CLIPS_UPDATE = 'clips_update'; 50 | export const ACTION_CLIPS_DELETE = 'clips_delete'; 51 | export const ACTION_CLIPS_ERROR = 'clips_error'; 52 | -------------------------------------------------------------------------------- /src/analytics-v2.js: -------------------------------------------------------------------------------- 1 | import { onCLS, onFCP, onFID, onLCP, onTTFB } from 'web-vitals'; 2 | 3 | import { getCommaAccessToken } from '@commaai/my-comma-auth/storage'; 4 | 5 | const ATTRIBUTES = { 6 | app: 'connect', 7 | gitCommit: process.env.REACT_APP_GIT_SHA || 'dev', 8 | environment: process.env.NODE_ENV || 'development', 9 | ci: new URLSearchParams(window.location.search).get('ci') || false, 10 | }; 11 | const RESERVED_KEYS = new Set(['_id', ...Object.keys(ATTRIBUTES)]); 12 | 13 | const queue = new Set(); 14 | let counter = 0; 15 | 16 | function uniqueId() { 17 | counter += 1; 18 | return `${Date.now()}-${Math.random().toString(10).substring(2, 10)}-${counter}`; 19 | } 20 | 21 | async function flushQueue() { 22 | if (queue.size === 0) return; 23 | 24 | // TODO: flush queue when auth state changes 25 | const accessToken = await getCommaAccessToken(); 26 | 27 | const body = JSON.stringify([...queue]); 28 | 29 | await fetch(`${window.COMMA_URL_ROOT}_/ping`, { 30 | body, 31 | headers: { 32 | Authorization: `JWT ${accessToken}`, 33 | 'Content-Type': 'application/json', 34 | }, 35 | keepalive: true, 36 | method: 'POST', 37 | }); 38 | 39 | queue.clear(); 40 | } 41 | 42 | export function sendEvent(event) { 43 | if (!event.event) { 44 | throw new Error('Analytics event must have an event property'); 45 | } 46 | const collisions = Object.keys(event).filter((key) => RESERVED_KEYS.has(key)); 47 | if (collisions.length > 0) { 48 | throw new Error(`Analytics event cannot have reserved keys ${collisions.join(', ')}`); 49 | } 50 | queue.add({ 51 | _id: uniqueId(), 52 | ...ATTRIBUTES, 53 | ...event, 54 | }); 55 | } 56 | 57 | function sendToAnalytics(metric) { 58 | sendEvent({ 59 | event: 'web_vitals', 60 | ...metric, 61 | }); 62 | } 63 | 64 | onCLS(sendToAnalytics); 65 | onFCP(sendToAnalytics); 66 | onFID(sendToAnalytics); 67 | onLCP(sendToAnalytics); 68 | onTTFB(sendToAnalytics); 69 | 70 | // Report all available metrics whenever the page is backgrounded or unloaded. 71 | window.addEventListener('visibilitychange', () => { 72 | if (document.visibilityState === 'hidden') { 73 | flushQueue(); 74 | } 75 | }); 76 | 77 | // NOTE: Safari does not reliably fire the `visibilitychange` event when the 78 | // page is being unloaded. If Safari support is needed, you should also flush the 79 | // queue in the `pagehide` event. 80 | window.addEventListener('pagehide', flushQueue); 81 | -------------------------------------------------------------------------------- /src/timeline/segments.js: -------------------------------------------------------------------------------- 1 | import * as Types from '../actions/types'; 2 | import { getCurrentRoute } from '.'; 3 | 4 | export const SEGMENT_LENGTH = 1000 * 60; 5 | 6 | export function reducer(_state, action) { 7 | let state = { ..._state }; 8 | switch (action.type) { 9 | case Types.ACTION_UPDATE_SEGMENTS: 10 | state = { 11 | ...state, 12 | }; 13 | break; 14 | default: 15 | break; 16 | } 17 | 18 | const currentRoute = getCurrentRoute(state); 19 | state.currentRoute = currentRoute ? { ...currentRoute } : null; 20 | 21 | return state; 22 | } 23 | 24 | export function updateSegments() { 25 | return { 26 | type: Types.ACTION_UPDATE_SEGMENTS, 27 | }; 28 | } 29 | 30 | export function getSegmentFetchRange(state) { 31 | if (!state.zoom && !(state.clips && state.clips.state === 'upload')) { 32 | return state.filter; 33 | } 34 | if (state.clips && state.clips.end_time < state.filter.start) { 35 | return { 36 | start: state.clips.start_time, 37 | end: state.clips.end_time, 38 | }; 39 | } 40 | if (state.zoom && state.zoom.end < state.filter.start) { 41 | return { 42 | start: state.zoom.start, 43 | end: state.zoom.end, 44 | }; 45 | } 46 | const mins = [state.filter.start]; 47 | const maxs = [state.filter.end]; 48 | if (state.clips && state.clips.state === 'upload') { 49 | mins.push(state.clips.start_time); 50 | maxs.push(state.clips.end_time); 51 | } 52 | if (state.zoom) { 53 | mins.push(state.zoom.start); 54 | maxs.push(state.zoom.end); 55 | } 56 | return { 57 | start: Math.min(...mins), 58 | end: Math.max(...maxs), 59 | }; 60 | } 61 | 62 | export function hasRoutesData(state) { 63 | if (!state) { 64 | return false; 65 | } 66 | if (state.devices && state.devices.length === 0 && !state.dongleId) { 67 | // new users without devices won't have segment metadata 68 | return true; 69 | } 70 | if (!state.routesMeta || !state.routesMeta.dongleId || state.routesMeta.start === null 71 | || state.routesMeta.end === null) { 72 | console.log('No routes data at all'); 73 | return false; 74 | } 75 | if (!state.routes) { 76 | console.log('Still loading...'); 77 | return false; 78 | } 79 | if (state.dongleId !== state.routesMeta.dongleId) { 80 | console.log('Bad dongle id'); 81 | return false; 82 | } 83 | const fetchRange = getSegmentFetchRange(state); 84 | if (fetchRange.start < state.routesMeta.start) { 85 | console.log('Bad start offset'); 86 | return false; 87 | } 88 | if (fetchRange.end > state.routesMeta.end) { 89 | console.log('Bad end offset'); 90 | return false; 91 | } 92 | 93 | return true; 94 | } 95 | -------------------------------------------------------------------------------- /src/components/DriveView/NoDeviceUpsell.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { Typography, withStyles } from '@material-ui/core'; 4 | 5 | import ResizeHandler from '../ResizeHandler'; 6 | import AddDevice from '../Dashboard/AddDevice'; 7 | 8 | const styles = () => ({ 9 | content: { 10 | alignSelf: 'center', 11 | textAlign: 'center', 12 | minWidth: 120, 13 | '& p': { 14 | fontSize: '1rem', 15 | fontWeight: 600, 16 | }, 17 | }, 18 | imageContainer: { 19 | alignSelf: 'center', 20 | '& img': { 21 | width: 800, 22 | maxWidth: '100%', 23 | }, 24 | }, 25 | shopLink: { 26 | display: 'inline-block', 27 | marginTop: 8, 28 | marginLeft: 16, 29 | textDecoration: 'none', 30 | }, 31 | pairInstructions: { 32 | alignSelf: 'center', 33 | maxWidth: 600, 34 | minHeight: 150, 35 | display: 'flex', 36 | flexDirection: 'column', 37 | justifyContent: 'center', 38 | textAlign: 'center', 39 | '& p': { 40 | fontSize: '1rem', 41 | '&:first-child': { fontWeight: 600 }, 42 | }, 43 | }, 44 | addDeviceContainer: { 45 | marginTop: 10, 46 | width: '80%', 47 | maxWidth: 250, 48 | margin: '0 auto', 49 | }, 50 | }); 51 | 52 | class NoDeviceUpsell extends Component { 53 | constructor(props) { 54 | super(props); 55 | 56 | this.state = { 57 | windowWidth: window.innerWidth, 58 | }; 59 | 60 | this.onResize = this.onResize.bind(this); 61 | } 62 | 63 | onResize(windowWidth) { 64 | this.setState({ windowWidth }); 65 | } 66 | 67 | render() { 68 | const { classes } = this.props; 69 | const { windowWidth } = this.state; 70 | 71 | const containerPadding = windowWidth > 520 ? 36 : 16; 72 | 73 | return ( 74 | <> 75 | 76 |
77 | Pair your device 78 | 79 | Pair your comma device by scanning the QR code on the device 80 | 81 |
82 | 83 |
84 |
85 |
86 | 87 | 88 | 89 | comma three 90 | 91 |
92 | 93 | ); 94 | } 95 | } 96 | 97 | export default withStyles(styles)(NoDeviceUpsell); 98 | -------------------------------------------------------------------------------- /src/actions/startup.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | import { account as Account, devices as Devices } from '@commaai/api'; 3 | import MyCommaAuth from '@commaai/my-comma-auth'; 4 | 5 | import * as Demo from '../demo'; 6 | import { ACTION_STARTUP_DATA } from './types'; 7 | import { primeFetchSubscription, checkRoutesData, selectDevice, fetchSharedDevice } from '.'; 8 | 9 | const demoProfile = require('../demo/profile.json'); 10 | const demoDevices = require('../demo/devices.json'); 11 | 12 | async function initProfile() { 13 | if (MyCommaAuth.isAuthenticated()) { 14 | try { 15 | return await Account.getProfile(); 16 | } catch (err) { 17 | if (err.resp && err.resp.status === 401) { 18 | await MyCommaAuth.logOut(); 19 | } else { 20 | console.error(err); 21 | Sentry.captureException(err, { fingerprint: 'init_api_get_profile' }); 22 | } 23 | } 24 | } else if (Demo.isDemo()) { 25 | return demoProfile; 26 | } 27 | return null; 28 | } 29 | 30 | async function initDevices() { 31 | let devices = []; 32 | 33 | if (Demo.isDemo()) { 34 | devices = devices.concat(demoDevices); 35 | } 36 | 37 | if (MyCommaAuth.isAuthenticated()) { 38 | try { 39 | devices = devices.concat(await Devices.listDevices()); 40 | } catch (err) { 41 | if (!err.resp || err.resp.status !== 401) { 42 | console.error(err); 43 | Sentry.captureException(err, { fingerprint: 'init_api_list_devices' }); 44 | } 45 | } 46 | } 47 | 48 | return devices; 49 | } 50 | 51 | export default function init() { 52 | return async (dispatch, getState) => { 53 | let state = getState(); 54 | if (state.dongleId) { 55 | dispatch(checkRoutesData()); 56 | } 57 | 58 | const [profile, devices] = await Promise.all([initProfile(), initDevices()]); 59 | state = getState(); 60 | 61 | if (profile) { 62 | Sentry.setUser({ id: profile.id }); 63 | } 64 | 65 | if (devices.length > 0) { 66 | if (!state.dongleId) { 67 | const selectedDongleId = window.localStorage.getItem('selectedDongleId'); 68 | if (selectedDongleId && devices.find((d) => d.dongle_id === selectedDongleId)) { 69 | dispatch(selectDevice(selectedDongleId)); 70 | } else { 71 | dispatch(selectDevice(devices[0].dongle_id)); 72 | } 73 | } 74 | const dongleId = state.dongleId || devices[0].dongle_id || null; 75 | const device = devices.find((dev) => dev.dongle_id === dongleId); 76 | if (device) { 77 | dispatch(primeFetchSubscription(dongleId, device, profile)); 78 | } else if (dongleId) { 79 | dispatch(fetchSharedDevice(dongleId)); 80 | } 81 | } 82 | 83 | dispatch({ 84 | type: ACTION_STARTUP_DATA, 85 | profile, 86 | devices, 87 | }); 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/icons/original/pin-pinned.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 68 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/icons/original/pin-marker.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 68 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/components/utils/BackgroundImage.js: -------------------------------------------------------------------------------- 1 | import React, { createRef, useEffect } from 'react'; 2 | import { withStyles } from '@material-ui/core/styles'; 3 | 4 | const styles = () => ({ 5 | root: { 6 | position: 'relative', 7 | width: '100%', 8 | height: '100%', 9 | overflow: 'hidden', 10 | }, 11 | placeholder: { 12 | position: 'absolute', 13 | zIndex: 1, 14 | top: 0, 15 | right: 0, 16 | bottom: 0, 17 | left: 0, 18 | backgroundPosition: 'center', 19 | backgroundSize: 'cover', 20 | opacity: 0, 21 | transition: 'opacity 0.3s ease', 22 | }, 23 | hdImage: { 24 | position: 'absolute', 25 | zIndex: 2, 26 | top: 0, 27 | right: 0, 28 | bottom: 0, 29 | left: 0, 30 | // TODO: add attributes 31 | // backgroundPosition: 'center', 32 | // backgroundSize: 'cover', 33 | backgroundSize: 'contain', 34 | backgroundRepeat: 'no-repeat', 35 | backgroundPosition: 'center', 36 | opacity: 0, 37 | transition: 'opacity 0.3s ease', 38 | }, 39 | overlay: { 40 | position: 'absolute', 41 | zIndex: 3, 42 | top: 0, 43 | right: 0, 44 | bottom: 0, 45 | left: 0, 46 | opacity: 0, 47 | transition: 'opacity 0.3s ease', 48 | }, 49 | fadeIn: { 50 | opacity: 1, 51 | }, 52 | }); 53 | 54 | /* eslint-disable react/jsx-props-no-spreading, react-hooks/exhaustive-deps */ 55 | const BackgroundImage = (props) => { 56 | const hdImageRef = createRef(); 57 | const placeholderRef = createRef(); 58 | const overlayRef = createRef(); 59 | const { placeholder, classes, className, src, children, overlay, ...rest } = props; 60 | 61 | useEffect(() => { 62 | const newImage = document.createElement('img'); 63 | const hdImageEl = hdImageRef.current; 64 | const placeholderEl = placeholderRef.current; 65 | const overlayEl = overlayRef.current; 66 | newImage.src = src; 67 | newImage.onload = () => { 68 | hdImageEl.setAttribute('style', `background-image: url(${src})`); 69 | hdImageEl.classList.add(classes.fadeIn); 70 | overlayEl.classList.add(classes.fadeIn); 71 | }; 72 | newImage.onerror = () => { 73 | placeholderEl.classList.add(classes.fadeIn); 74 | overlayEl.classList.add(classes.fadeIn); 75 | }; 76 | 77 | return () => { 78 | newImage.remove(); 79 | }; 80 | }, []); 81 | 82 | return ( 83 |
87 |
88 |
93 | {children} 94 |
95 |
{overlay}
96 |
97 | ); 98 | }; 99 | 100 | export default withStyles(styles)(BackgroundImage); 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "connect", 3 | "version": "0.8.0", 4 | "scripts": { 5 | "start": "craco start", 6 | "build:development": "env-cmd .env.development craco build", 7 | "build:production": "REACT_APP_GIT_SHA=`git rev-parse HEAD` env-cmd .env.production craco build", 8 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src", 9 | "test": "craco test", 10 | "test-ci": "CI=true craco test", 11 | "test-coverage": "CI=true craco test --coverage", 12 | "test-puppeteer": "jest -c src/__puppeteer__/jest.config.js", 13 | "test-puppeteer-build": "JEST_PUPPETEER_CONFIG=jest-puppeteer.build.config.js yarn run test-puppeteer" 14 | }, 15 | "repository": "git@github.com:commaai/connect.git", 16 | "author": "Chris Vickery ", 17 | "private": true, 18 | "dependencies": { 19 | "@commaai/api": "github:commaai/comma-api#v3.0.1", 20 | "@commaai/my-comma-auth": "^1.4.1", 21 | "@craco/craco": "^7.1.0", 22 | "@mapbox/mapbox-sdk": "^0.13.5", 23 | "@material-ui/core": "^1.0.0", 24 | "@material-ui/icons": "^1.0.0", 25 | "@sentry/react": "^7.44.2", 26 | "@sentry/webpack-plugin": "^1.20.0", 27 | "classnames": "^2.3.2", 28 | "connected-react-router": "^4.5.0", 29 | "debounce": "^1.2.1", 30 | "fecha": "^3.0.3", 31 | "global": "^4.4.0", 32 | "history": "^4.10.1", 33 | "jwt-decode": "^3.1.2", 34 | "localforage": "^1.10.0", 35 | "mapbox-gl": "^1.13.2", 36 | "obstruction": "^2.1.0", 37 | "photo-sphere-viewer": "github:commaai/Photo-Sphere-Viewer#fix-threejs", 38 | "prop-types": "^15.8.1", 39 | "qr-scanner": "^1.4.2", 40 | "query-string": "^6.14.1", 41 | "raf": "^3.4.1", 42 | "react": "~18.2", 43 | "react-dom": "~18.2", 44 | "react-map-gl": "^5.3.15", 45 | "react-measure": "^2.5.2", 46 | "react-player": "^2.12.0", 47 | "react-redux": "^5.0.7", 48 | "react-responsive-carousel": "^3.2.23", 49 | "react-router": "^5.3.4", 50 | "react-router-dom": "^5.3.4", 51 | "react-scripts": "5.0.1", 52 | "reduce-reducers": "^1.0.4", 53 | "redux": "^4.2.0", 54 | "redux-thunk": "^2.4.1", 55 | "web-vitals": "^3.3.1" 56 | }, 57 | "devDependencies": { 58 | "@testing-library/jest-dom": "^5.16.5", 59 | "@testing-library/react": "^13.4.0", 60 | "@testing-library/user-event": "^14.4.3", 61 | "compression-webpack-plugin": "^7.1.2", 62 | "env-cmd": "^8.0.2", 63 | "eslint-config-airbnb": "19.0.4", 64 | "eslint-plugin-import": "^2.26.0", 65 | "eslint-plugin-jsx-a11y": "^6.6.1", 66 | "eslint-plugin-react": "^7.31.10", 67 | "eslint-plugin-react-hooks": "^4.6.0", 68 | "expect-puppeteer": "^6.1.1", 69 | "jest-puppeteer": "^6.1.1", 70 | "puppeteer": "^9.1.1", 71 | "serve": "^14.0.1" 72 | }, 73 | "browserslist": { 74 | "production": [ 75 | ">0.2%", 76 | "not dead", 77 | "not op_mini all" 78 | ], 79 | "development": [ 80 | "last 1 chrome version", 81 | "last 1 firefox version", 82 | "last 1 safari version" 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/icons/original/pin-work.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 68 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/components/Timeline/thumbnails.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { getSegmentNumber } from '../../utils'; 4 | 5 | export default function Thumbnails(props) { 6 | const { thumbnail } = props; 7 | const imgStyles = { 8 | display: 'inline-block', 9 | height: thumbnail.height, 10 | width: (128 / 80) * thumbnail.height, 11 | }; 12 | const imgCount = Math.ceil(thumbnail.width / imgStyles.width); 13 | 14 | const imgArr = []; 15 | let currSegment = null; 16 | 17 | if (!Number.isFinite(imgCount)) { 18 | return []; 19 | } 20 | for (let i = 0; i < imgCount; ++i) { 21 | const offset = props.percentToOffset((i + 0.5) / imgCount); 22 | const route = props.getCurrentRoute(offset); 23 | if (!route) { 24 | if (currSegment && !currSegment.blank) { 25 | imgArr.push(currSegment); 26 | currSegment = null; 27 | } 28 | if (!currSegment) { 29 | currSegment = { 30 | blank: true, 31 | length: 0, 32 | }; 33 | } 34 | currSegment.length += 1; 35 | } else { 36 | // 12 per file, 5s each 37 | let seconds = Math.floor((offset - route.offset) / 1000); 38 | const segmentNum = getSegmentNumber(route, offset); 39 | const url = `${route.url}/${segmentNum}/sprite.jpg`; 40 | seconds %= 60; 41 | 42 | if (currSegment && (currSegment.blank || currSegment.segmentNum !== segmentNum)) { 43 | imgArr.push(currSegment); 44 | currSegment = null; 45 | } 46 | 47 | const imageIndex = Math.floor(seconds / 5); 48 | 49 | if (currSegment) { 50 | if (imageIndex === currSegment.endImage + 1) { 51 | currSegment.endImage = imageIndex; 52 | } else { 53 | imgArr.push(currSegment); 54 | currSegment = null; 55 | } 56 | } 57 | 58 | if (!currSegment) { 59 | currSegment = { 60 | segmentNum, 61 | startOffset: seconds, 62 | startImage: imageIndex, 63 | endImage: imageIndex, 64 | length: 0, 65 | url, 66 | }; 67 | } 68 | 69 | currSegment.length += 1; 70 | currSegment.endOffset = seconds; 71 | } 72 | } 73 | 74 | if (currSegment) { 75 | imgArr.push(currSegment); 76 | } 77 | 78 | return imgArr.map((data, i) => (data.blank 79 | ? ( 80 |
89 | ) 90 | : ( 91 |
104 | ))); 105 | } 106 | -------------------------------------------------------------------------------- /src/components/ClipView/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | 5 | import { withStyles, IconButton } from '@material-ui/core'; 6 | import CloseIcon from '@material-ui/icons/Close'; 7 | 8 | import { fetchEvents } from '../../actions/cached'; 9 | import { clipsExit } from '../../actions/clips'; 10 | import Colors from '../../colors'; 11 | import ClipList from './ClipList'; 12 | import ClipCreate from './ClipCreate'; 13 | import ClipUpload from './ClipUpload'; 14 | import ClipDone from './ClipDone'; 15 | 16 | const styles = () => ({ 17 | window: { 18 | background: 'linear-gradient(to bottom, #30373B 0%, #272D30 10%, #1D2225 100%)', 19 | borderRadius: 8, 20 | display: 'flex', 21 | flexDirection: 'column', 22 | margin: 18, 23 | }, 24 | headerContext: { 25 | alignItems: 'center', 26 | justifyContent: 'space-between', 27 | display: 'flex', 28 | padding: 12, 29 | }, 30 | headerInfo: { 31 | color: Colors.white, 32 | fontSize: 18, 33 | fontWeight: 500, 34 | paddingLeft: 12, 35 | }, 36 | error: { 37 | color: Colors.white, 38 | fontSize: '0.9rem', 39 | padding: '12px 24px', 40 | }, 41 | }); 42 | 43 | class ClipView extends Component { 44 | componentDidMount() { 45 | this.componentDidUpdate({}, {}); 46 | } 47 | 48 | componentDidUpdate(prevProps) { 49 | const { currentRoute, dispatch } = this.props; 50 | if (prevProps.currentRoute !== currentRoute && currentRoute) { 51 | dispatch(fetchEvents(currentRoute)); 52 | } 53 | } 54 | 55 | render() { 56 | const { classes, clips, dispatch } = this.props; 57 | 58 | let title = 'Create a clip'; 59 | let text = null; 60 | if (clips.state === 'done') { 61 | title = 'View clip'; 62 | } else if (clips.state === 'list') { 63 | title = 'View clips'; 64 | } else if (clips.state === 'error') { 65 | title = 'View clip'; 66 | if (clips.error === 'clip_doesnt_exist') { 67 | text = 'Could not find this clip'; 68 | } 69 | } else if (clips.state === 'loading') { 70 | title = ''; 71 | } 72 | 73 | return ( 74 |
75 |
76 | dispatch(clipsExit()) }> 77 | 78 | 79 |
80 | { title } 81 |
82 |
83 |
84 | { clips.state === 'list' ? : null } 85 | { clips.state === 'create' ? : null } 86 | { clips.state === 'upload' ? : null } 87 | { clips.state === 'done' ? : null } 88 | { clips.state === 'error' ?
{ text }
: null } 89 |
90 | ); 91 | } 92 | } 93 | 94 | const stateToProps = Obstruction({ 95 | currentRoute: 'currentRoute', 96 | dongleId: 'dongleId', 97 | clips: 'clips', 98 | }); 99 | 100 | export default connect(stateToProps)(withStyles(styles)(ClipView)); 101 | -------------------------------------------------------------------------------- /src/icons/original/pin-home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 68 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/components/VisibilityHandler/index.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | import PropTypes from 'prop-types'; 5 | import debounce from 'debounce'; 6 | 7 | class VisibilityHandler extends Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.prevVisibleCall = 0; 12 | this.intervalHandle = null; 13 | this.handleVisibilityChange = this.handleVisibilityChange.bind(this); 14 | this.handleFocus = this.handleFocus.bind(this); 15 | this.handleBlur = this.handleBlur.bind(this); 16 | this.onVisibilityEvent = debounce(this.onVisibilityEvent.bind(this), 1000, true); 17 | } 18 | 19 | componentDidMount() { 20 | document.addEventListener('visibilitychange', this.handleVisibilityChange); 21 | document.addEventListener('focus', this.handleFocus); 22 | document.addEventListener('blur', this.handleBlur); 23 | this.prevVisibleCall = Date.now() / 1000; 24 | 25 | const { onInit, onInterval, onVisible } = this.props; 26 | if (onInit) { 27 | onVisible(); 28 | } 29 | if (onInterval) { 30 | this.intervalHandle = setInterval(this.handleVisibilityChange, onInterval * 1000); 31 | } 32 | } 33 | 34 | componentDidUpdate(prevProps) { 35 | const { dongleId, onDongleId, onVisible } = this.props; 36 | if (onDongleId && prevProps.dongleId !== dongleId) { 37 | this.prevVisibleCall = Date.now() / 1000; 38 | onVisible(); 39 | } 40 | } 41 | 42 | componentWillUnmount() { 43 | document.removeEventListener('visibilitychange', this.handleVisibilityChange); 44 | document.removeEventListener('focus', this.handleFocus); 45 | document.removeEventListener('blur', this.handleBlur); 46 | if (this.intervalHandle) { 47 | clearInterval(this.intervalHandle); 48 | this.intervalHandle = null; 49 | } 50 | } 51 | 52 | handleFocus() { 53 | this.onVisibilityEvent(true); 54 | } 55 | 56 | handleBlur() { 57 | this.onVisibilityEvent(false); 58 | } 59 | 60 | handleVisibilityChange() { 61 | if (document.visibilityState === 'visible') { 62 | this.onVisibilityEvent(true); 63 | } else if (document.visibilityState === 'hidden') { 64 | this.onVisibilityEvent(false); 65 | } 66 | } 67 | 68 | onVisibilityEvent(visible) { 69 | const { minInterval, onVisible, resetOnHidden } = this.props; 70 | 71 | const newDate = Date.now() / 1000; 72 | const dt = newDate - this.prevVisibleCall; 73 | if (visible && (!minInterval || dt > minInterval)) { 74 | this.prevVisibleCall = newDate; 75 | onVisible(); 76 | } 77 | 78 | if (!visible && resetOnHidden) { 79 | this.prevVisibleCall = newDate; 80 | } 81 | } 82 | 83 | render() { 84 | return null; 85 | } 86 | } 87 | 88 | const stateToProps = Obstruction({ 89 | dongleId: 'dongleId', 90 | }); 91 | 92 | VisibilityHandler.propTypes = { 93 | onVisible: PropTypes.func.isRequired, 94 | onInit: PropTypes.bool, 95 | onDongleId: PropTypes.bool, 96 | onInterval: PropTypes.number, 97 | minInterval: PropTypes.number, // in seconds, only for visibility changes 98 | resetOnHidden: PropTypes.bool, 99 | }; 100 | 101 | export default connect(stateToProps)(VisibilityHandler); 102 | -------------------------------------------------------------------------------- /src/components/AppDrawer/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | import { withStyles } from '@material-ui/core/styles'; 7 | import 'mapbox-gl/src/css/mapbox-gl.css'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import Drawer from '@material-ui/core/Drawer'; 10 | 11 | import DeviceList from '../Dashboard/DeviceList'; 12 | 13 | import { selectDevice } from '../../actions'; 14 | 15 | const styles = () => ({ 16 | header: { 17 | display: 'flex', 18 | height: 64, 19 | minHeight: 64, 20 | }, 21 | window: { 22 | display: 'flex', 23 | flexGrow: 1, 24 | minHeight: 0, 25 | }, 26 | logo: { 27 | alignItems: 'center', 28 | display: 'flex', 29 | textDecoration: 'none', 30 | minHeight: 64, 31 | }, 32 | logoImg: { 33 | height: 34, 34 | width: 18.9, 35 | margin: '0px 28px', 36 | }, 37 | logoText: { 38 | fontSize: 20, 39 | fontWeight: 800, 40 | }, 41 | drawerContent: { 42 | height: '100%', 43 | width: '100%', 44 | display: 'flex', 45 | flexDirection: 'column', 46 | background: 'linear-gradient(180deg, #1B2023 0%, #111516 100%)', 47 | }, 48 | sidebarHeader: { 49 | alignItems: 'center', 50 | padding: 14.5, 51 | color: '#fff', 52 | display: 'flex', 53 | width: '100%', 54 | paddingLeft: 0, 55 | backgroundColor: '#1D2225', 56 | borderBottom: '1px solid rgba(255, 255, 255, 0.1)', 57 | }, 58 | }); 59 | 60 | class AppDrawer extends Component { 61 | constructor(props) { 62 | super(props); 63 | 64 | this.handleDeviceSelected = this.handleDeviceSelected.bind(this); 65 | this.toggleDrawerOff = this.toggleDrawerOff.bind(this); 66 | } 67 | 68 | handleDeviceSelected(dongleId) { 69 | this.props.dispatch(selectDevice(dongleId)); 70 | this.toggleDrawerOff(); 71 | } 72 | 73 | toggleDrawerOff() { 74 | this.props.handleDrawerStateChanged(false); 75 | } 76 | 77 | render() { 78 | const { classes, isPermanent, drawerIsOpen, selectedDongleId } = this.props; 79 | 80 | return ( 81 | 87 |
88 | { !isPermanent 89 | && ( 90 | 91 | comma 92 | connect 93 | 94 | )} 95 | { isPermanent &&
} 96 | 101 |
102 | 103 | ); 104 | } 105 | } 106 | 107 | const stateToProps = Obstruction({ 108 | selectedDongleId: 'dongleId', 109 | device: 'device', 110 | }); 111 | 112 | export default connect(stateToProps)(withStyles(styles)(AppDrawer)); 113 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 16 15 | cache: 'yarn' 16 | 17 | - run: yarn install --immutable --immutable-cache --check-cache 18 | - run: yarn build:development 19 | 20 | - name: Run unit tests 21 | run: yarn test-ci 22 | 23 | - name: Run puppeteer tests 24 | run: yarn test-puppeteer-build 25 | 26 | bundle_size: 27 | runs-on: ubuntu-20.04 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: preactjs/compressed-size-action@v2 31 | with: 32 | repo-token: ${{ secrets.GITHUB_TOKEN }} 33 | build-script: build:production 34 | pattern: "./build/static/js/*.js" 35 | strip-hash: "\\b\\w{8}\\." 36 | minimum-change-threshold: 100 37 | 38 | lighthouse: 39 | runs-on: ubuntu-20.04 40 | if: github.repository == 'commaai/connect' 41 | steps: 42 | - uses: actions/checkout@v3 43 | - uses: actions/setup-node@v3 44 | with: 45 | node-version: 16 46 | cache: 'yarn' 47 | 48 | - run: yarn install --immutable --immutable-cache --check-cache 49 | - run: yarn build:production 50 | 51 | - name: run Lighthouse CI 52 | run: | 53 | npm install -g @lhci/cli@0.9.x 54 | lhci autorun 55 | env: 56 | LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} 57 | 58 | docker: 59 | runs-on: ubuntu-20.04 60 | needs: [test, bundle_size] 61 | if: github.repository == 'commaai/connect' 62 | permissions: 63 | packages: write 64 | contents: read 65 | steps: 66 | - uses: actions/checkout@v3 67 | - id: buildx 68 | uses: docker/setup-buildx-action@v2 69 | 70 | - name: Cache Docker layers 71 | uses: actions/cache@v2 72 | with: 73 | path: /tmp/.buildx-cache 74 | key: ${{ runner.os }}-buildx-${{ github.sha }} 75 | restore-keys: | 76 | ${{ runner.os }}-buildx- 77 | 78 | - name: login to container registry 79 | uses: docker/login-action@v2 80 | with: 81 | registry: ghcr.io 82 | username: ${{ github.actor }} 83 | password: ${{ secrets.GITHUB_TOKEN }} 84 | 85 | - id: meta 86 | uses: docker/metadata-action@v4 87 | with: 88 | images: ghcr.io/commaai/connect 89 | tags: | 90 | type=raw,value=latest,enable={{is_default_branch}} 91 | type=ref,event=branch 92 | type=ref,event=pr,prefix= 93 | type=sha,format=long,prefix= 94 | env: 95 | DOCKER_METADATA_PR_HEAD_SHA: true 96 | 97 | - name: Build and push 98 | uses: docker/build-push-action@v3 99 | with: 100 | build-args: SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} 101 | builder: ${{ steps.buildx.outputs.name }} 102 | context: . 103 | push: true 104 | tags: ${{ steps.meta.outputs.tags }} 105 | labels: ${{ steps.meta.outputs.labels }} 106 | cache-from: type=local,src=/tmp/.buildx-cache 107 | cache-to: type=local,dest=/tmp/.buildx-cache-new 108 | 109 | - name: Move cache 110 | run: | 111 | rm -rf /tmp/.buildx-cache 112 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 113 | -------------------------------------------------------------------------------- /src/components/utils/PullDownReload.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { withStyles } from '@material-ui/core'; 4 | import ReplayIcon from '@material-ui/icons/Replay'; 5 | 6 | import Colors from '../../colors'; 7 | 8 | const styles = () => ({ 9 | root: { 10 | position: 'absolute', 11 | zIndex: 5050, 12 | top: -48, 13 | left: 'calc(50% - 24px)', 14 | display: 'flex', 15 | alignItems: 'center', 16 | justifyContent: 'center', 17 | width: 48, 18 | height: 48, 19 | backgroundColor: Colors.grey100, 20 | borderRadius: 24, 21 | }, 22 | }); 23 | 24 | class PullDownReload extends Component { 25 | constructor(props) { 26 | super(props); 27 | 28 | this.state = { 29 | startY: null, 30 | reloading: false, 31 | }; 32 | 33 | this.dragEl = React.createRef(null); 34 | 35 | this.touchStart = this.touchStart.bind(this); 36 | this.touchMove = this.touchMove.bind(this); 37 | this.touchEnd = this.touchEnd.bind(this); 38 | } 39 | 40 | async componentDidMount() { 41 | if (window && window.navigator) { 42 | const isIos = /iphone|ipad|ipod/.test(window.navigator.userAgent.toLowerCase()); 43 | const isStandalone = window.navigator.standalone === true; 44 | if (isIos && isStandalone) { 45 | document.addEventListener('touchstart', this.touchStart, { passive: false }); 46 | document.addEventListener('touchmove', this.touchMove, { passive: false }); 47 | document.addEventListener('touchend', this.touchEnd, { passive: false }); 48 | } 49 | } 50 | } 51 | 52 | componentWillUnmount() { 53 | document.removeEventListener('touchstart', this.touchStart); 54 | document.removeEventListener('touchmove', this.touchMove); 55 | document.removeEventListener('touchend', this.touchEnd); 56 | } 57 | 58 | touchStart(ev) { 59 | if (document.scrollingElement.scrollTop !== 0 || ev.defaultPrevented) { 60 | return; 61 | } 62 | 63 | this.setState({ startY: ev.touches[0].pageY }); 64 | } 65 | 66 | touchMove(ev) { 67 | const { startY } = this.state; 68 | const { current: el } = this.dragEl; 69 | if (startY === null || !el) { 70 | return; 71 | } 72 | 73 | const top = Math.min((ev.touches[0].pageY - startY) / 2 - 48, 32); 74 | el.style.transition = 'unset'; 75 | el.style.top = `${top}px`; 76 | if (ev.touches[0].pageY - startY > 0) { 77 | ev.preventDefault(); 78 | } else { 79 | this.setState({ startY: null }); 80 | el.style.transition = 'top 0.1s'; 81 | el.style.top = '-48px'; 82 | } 83 | } 84 | 85 | touchEnd() { 86 | const { reloading, startY } = this.state; 87 | const { current: el } = this.dragEl; 88 | if (startY === null || !el) { 89 | return; 90 | } 91 | 92 | const top = parseInt(el.style.top.substring(0, el.style.top.length - 2), 10); 93 | if (top >= 32 && !reloading) { 94 | this.setState({ reloading: true }); 95 | window.location.reload(); 96 | } else { 97 | this.setState({ startY: null }); 98 | el.style.transition = 'top 0.1s'; 99 | el.style.top = '-48px'; 100 | } 101 | } 102 | 103 | render() { 104 | const { classes } = this.props; 105 | 106 | return ( 107 |
108 | 109 |
110 | ); 111 | } 112 | } 113 | 114 | export default withStyles(styles)(PullDownReload); 115 | -------------------------------------------------------------------------------- /src/components/Navigation/utils.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import * as Utils from './utils'; 3 | 4 | describe('navigation formatting utils', () => { 5 | describe('formats search results correctly', () => { 6 | const testCases = [ 7 | { 8 | // from mapbox api 9 | item: { 10 | title: 'Taco Bell', 11 | distance: 1234, 12 | address: { 13 | label: 'Taco Bell, 2011 Camino del Rio N, San Diego, CA 92108, United States', 14 | countryCode: 'USA', 15 | countryName: 'United States', 16 | stateCode: 'CA', 17 | state: 'California', 18 | county: 'San Diego', 19 | city: 'San Diego', 20 | district: 'Mission Valley East', 21 | street: 'Camino del Rio N', 22 | postalCode: '92108', 23 | houseNumber: '2011', 24 | }, 25 | }, 26 | 27 | // expected 28 | name: 'Taco Bell', 29 | address: '2011 Camino del Rio N, San Diego, CA 92108, United States', 30 | details: '2011 Camino del Rio N, San Diego', 31 | searchList: ', 2011 Camino del Rio N, San Diego (0.8 mi)', 32 | }, 33 | { 34 | // from mapbox api 35 | item: { 36 | title: '1441 State St, San Diego, CA 92101-3421, United States', 37 | distance: 1234, 38 | address: { 39 | label: '1441 State St, San Diego, CA 92101-3421, United States', 40 | countryCode: 'USA', 41 | countryName: 'United States', 42 | stateCode: 'CA', 43 | state: 'California', 44 | county: 'San Diego', 45 | city: 'San Diego', 46 | district: 'Little Italy', 47 | street: 'State St', 48 | postalCode: '92101-3421', 49 | houseNumber: '1441', 50 | }, 51 | }, 52 | 53 | // expected 54 | name: '1441 State St', 55 | address: '1441 State St, San Diego, CA 92101-3421, United States', 56 | details: 'San Diego', 57 | searchList: ', San Diego (0.8 mi)', 58 | }, 59 | ]; 60 | 61 | testCases.forEach((testCase) => { 62 | const { item } = testCase; 63 | 64 | expect(Utils.formatSearchName(item)).toBe(testCase.name); 65 | expect(Utils.formatSearchAddress(item)).toBe(testCase.address); 66 | expect(Utils.formatSearchDetails(item)).toBe(testCase.details); 67 | expect(Utils.formatSearchList(item)).toBe(testCase.searchList); 68 | }); 69 | }); 70 | 71 | it('formats favorites correctly', () => { 72 | const testCases = [ 73 | { 74 | // from favorites 75 | item: { 76 | title: '123 San Diego St', 77 | distance: 1234, 78 | address: { 79 | label: '123 San Diego St, San Diego, CA 92123, United States', 80 | }, 81 | }, 82 | 83 | // expected 84 | name: '123 San Diego St', 85 | address: '123 San Diego St, San Diego, CA 92123, United States', 86 | details: 'San Diego, CA 92123, United States', 87 | searchList: ', San Diego (0.8 mi)', 88 | }, 89 | ]; 90 | 91 | testCases.forEach((testCase) => { 92 | const { item } = testCase; 93 | 94 | expect(Utils.formatSearchName(item)).toBe(testCase.name); 95 | expect(Utils.formatSearchAddress(item)).toBe(testCase.address); 96 | expect(Utils.formatSearchDetails(item)).toBe(testCase.details); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/components/ServiceWorkerWrapper/index.js: -------------------------------------------------------------------------------- 1 | import { Button, CircularProgress, Snackbar, withStyles } from '@material-ui/core'; 2 | import React, { useEffect, useState } from 'react'; 3 | import * as Sentry from '@sentry/react'; 4 | 5 | import { register, unregister } from '../../serviceWorkerRegistration'; 6 | 7 | const styles = () => ({ 8 | button: { 9 | textTransform: 'uppercase', 10 | }, 11 | }); 12 | 13 | const ServiceWorkerWrapper = (props) => { 14 | const { classes } = props; 15 | 16 | const [showUpdate, setShowUpdate] = useState(false); 17 | const [loading, setLoading] = useState(false); 18 | 19 | const [waitingWorker, setWaitingWorker] = useState(null); 20 | const [refreshing, setRefreshing] = useState(false); 21 | 22 | const onSWUpdate = (registration) => { 23 | if (!registration.waiting) { 24 | Sentry.captureMessage('[ServiceWorkerWrapper] Update is available but there is no waiting service worker to install', 'warning'); 25 | return; 26 | } 27 | console.log('[ServiceWorkerWrapper] Update is available'); 28 | setWaitingWorker(registration.waiting); 29 | setShowUpdate(true); 30 | }; 31 | 32 | const onSWSuccess = () => { 33 | console.log('[ServiceWorkerWrapper] Update successful'); 34 | }; 35 | 36 | /* eslint-disable react-hooks/exhaustive-deps */ 37 | useEffect(() => { 38 | if (process.env.NODE_ENV === 'production' && process.env.REACT_APP_SERVICEWORKER) { 39 | console.log('[ServiceWorkerWrapper] Registering service worker...'); 40 | register({ 41 | // show update found message 42 | onUpdate: onSWUpdate, 43 | 44 | // TODO: show "connect now works offline" message 45 | onSuccess: onSWSuccess, 46 | }); 47 | } else { 48 | console.log('[ServiceWorkerWrapper] Unregistering service worker...'); 49 | unregister(); 50 | } 51 | }, []); 52 | /* eslint-enable react-hooks/exhaustive-deps */ 53 | 54 | const onReload = () => { 55 | if (!waitingWorker) { 56 | Sentry.captureMessage('[ServiceWorkerWrapper] No waiting worker found', 'error'); 57 | setShowUpdate(false); 58 | return; 59 | } 60 | setLoading(true); 61 | waitingWorker.postMessage({ type: 'SKIP_WAITING' }); 62 | setTimeout(() => { 63 | Sentry.captureMessage('[ServiceWorkerWrapper] Timed out waiting for controller change', 'error'); 64 | if (refreshing) return; 65 | setRefreshing(true); 66 | window.location.reload(); 67 | }, 60_000); 68 | }; 69 | 70 | const onDismiss = () => { 71 | setShowUpdate(false); 72 | }; 73 | 74 | const action = ( 75 | <> 76 | 85 | 94 | 95 | ); 96 | 97 | return ( 98 | 104 | ); 105 | }; 106 | 107 | export default withStyles(styles)(ServiceWorkerWrapper); 108 | -------------------------------------------------------------------------------- /src/components/DriveView/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | import fecha from 'fecha'; 5 | 6 | import { withStyles, IconButton, Typography } from '@material-ui/core'; 7 | import KeyboardBackspaceIcon from '@material-ui/icons/KeyboardBackspace'; 8 | import CloseIcon from '@material-ui/icons/Close'; 9 | 10 | import Media from './Media'; 11 | import Timeline from '../Timeline'; 12 | 13 | import { selectRange } from '../../actions'; 14 | import ResizeHandler from '../ResizeHandler'; 15 | import Colors from '../../colors'; 16 | import { filterRegularClick } from '../../utils'; 17 | 18 | const styles = () => ({ 19 | window: { 20 | background: 'linear-gradient(to bottom, #30373B 0%, #272D30 10%, #1D2225 100%)', 21 | borderRadius: 8, 22 | display: 'flex', 23 | flexDirection: 'column', 24 | margin: 18, 25 | }, 26 | headerContext: { 27 | alignItems: 'center', 28 | justifyContent: 'space-between', 29 | display: 'flex', 30 | padding: 12, 31 | }, 32 | headerInfo: { 33 | color: Colors.white, 34 | fontSize: 18, 35 | fontWeight: 500, 36 | paddingLeft: 12, 37 | }, 38 | }); 39 | 40 | class DriveView extends Component { 41 | constructor(props) { 42 | super(props); 43 | 44 | this.state = { 45 | windowWidth: window.innerWidth, 46 | }; 47 | 48 | this.close = this.close.bind(this); 49 | this.onResize = this.onResize.bind(this); 50 | } 51 | 52 | onResize(windowWidth) { 53 | this.setState({ windowWidth }); 54 | } 55 | 56 | close() { 57 | this.props.dispatch(selectRange(null, null)); 58 | } 59 | 60 | render() { 61 | const { classes, dongleId, zoom, routes } = this.props; 62 | const { windowWidth } = this.state; 63 | const viewerPadding = windowWidth < 768 ? 12 : 32; 64 | 65 | const viewEndTime = fecha.format(new Date(zoom.end), 'HH:mm'); 66 | const startTime = fecha.format(new Date(zoom.start), 'MMM D @ HH:mm'); 67 | let headerText = `${startTime} - ${viewEndTime}`; 68 | if (windowWidth >= 640) { 69 | const startDay = fecha.format(new Date(zoom.start), 'dddd'); 70 | headerText = `${startDay} ${headerText}`; 71 | } 72 | 73 | return ( 74 | <> 75 | 76 |
77 |
78 |
79 | window.history.back() }> 80 | 81 | 82 |
83 | { headerText } 84 |
85 | 90 | 91 | 92 |
93 | 94 |
95 |
96 | { (routes && routes.length === 0) 97 | ? Route does not exist. 98 | : } 99 |
100 |
101 | 102 | ); 103 | } 104 | } 105 | 106 | const stateToProps = Obstruction({ 107 | dongleId: 'dongleId', 108 | routes: 'routes', 109 | zoom: 'zoom', 110 | }); 111 | 112 | export default connect(stateToProps)(withStyles(styles)(DriveView)); 113 | -------------------------------------------------------------------------------- /src/icons/original/pin-car.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 68 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/timeline/segments.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { getCurrentRoute } from '.'; 3 | import { hasRoutesData, SEGMENT_LENGTH } from './segments'; 4 | import { getSegmentNumber } from '../utils'; 5 | 6 | const routes = [{ 7 | fullname: '99c94dc769b5d96e|2018-04-09--10-10-00', 8 | offset: 36600000, 9 | duration: 2558000, 10 | segment_numbers: Array.from(Array(43).keys()), 11 | segment_offsets: Array.from(Array(43).keys()).map((i) => i * SEGMENT_LENGTH + 36600000), 12 | events: [{ 13 | time: 36600123, 14 | type: 'event', 15 | }], 16 | }, { 17 | fullname: '99c94dc769b5d96e|2018-04-09--11-29-08', 18 | offset: 41348000, 19 | duration: 214000, 20 | segment_numbers: Array.from(Array(4).keys()), 21 | segment_offsets: Array.from(Array(4).keys()).map((i) => i * SEGMENT_LENGTH + 41348000), 22 | events: [{ 23 | time: 41348123, 24 | type: 'event', 25 | }], 26 | }]; 27 | 28 | describe('segments', () => { 29 | it('finds current segment', async () => { 30 | const [route] = routes; 31 | let r = getCurrentRoute({ 32 | routes, 33 | offset: route.offset, 34 | desiredPlaySpeed: 1, 35 | startTime: Date.now(), 36 | }); 37 | expect(r.fullname).toBe(route.fullname); 38 | expect(getSegmentNumber(r, route.offset)).toBe(0); 39 | 40 | r = getCurrentRoute({ 41 | routes, 42 | offset: route.offset + SEGMENT_LENGTH * 1.1, 43 | desiredPlaySpeed: 1, 44 | startTime: Date.now(), 45 | }); 46 | expect(getSegmentNumber(r, route.offset + SEGMENT_LENGTH * 1.1)).toBe(1); 47 | }); 48 | 49 | it('finds last segment of a route', async () => { 50 | const [route] = routes; 51 | const offset = route.offset + SEGMENT_LENGTH * (route.segment_offsets.length - 1) + 1000; 52 | const r = getCurrentRoute({ 53 | routes, 54 | offset, 55 | desiredPlaySpeed: 1, 56 | startTime: Date.now(), 57 | }); 58 | expect(r.fullname).toBe(route.fullname); 59 | expect(getSegmentNumber(r, offset)).toBe(route.segment_offsets.length - 1); 60 | }); 61 | 62 | it('ends last segment of a route', async () => { 63 | const [route] = routes; 64 | const offset = route.offset + route.duration - 10; 65 | const r = getCurrentRoute({ 66 | routes, 67 | offset, 68 | desiredPlaySpeed: 1, 69 | startTime: Date.now() - 50, 70 | }); 71 | expect(getSegmentNumber(r, offset)).toBe(null); 72 | }); 73 | 74 | it('can check if it has segment metadata', () => { 75 | expect(hasRoutesData()).toBe(false); 76 | expect(hasRoutesData({})).toBe(false); 77 | expect(hasRoutesData({ 78 | routesMeta: {}, 79 | })).toBe(false); 80 | expect(hasRoutesData({ 81 | routes: [], 82 | routesMeta: { 83 | dongleId: 'asdfasdf', 84 | }, 85 | })).toBe(false); 86 | expect(hasRoutesData({ 87 | routes: [], 88 | routesMeta: { 89 | dongleId: 'asdfasdf', 90 | start: 10, 91 | end: 20, 92 | }, 93 | filter: { 94 | start: 0, 95 | end: 30, 96 | }, 97 | dongleId: 'asdfasdf', 98 | })).toBe(false); 99 | expect(hasRoutesData({ 100 | routes: [], 101 | routesMeta: { 102 | dongleId: 'asdfasdf', 103 | start: 0, 104 | end: 20, 105 | }, 106 | filter: { 107 | start: 10, 108 | end: 30, 109 | }, 110 | dongleId: 'asdfasdf', 111 | })).toBe(false); 112 | expect(hasRoutesData({ 113 | routes: [], 114 | routesMeta: { 115 | dongleId: 'asdfasdf', 116 | start: 10, 117 | end: 30, 118 | }, 119 | filter: { 120 | start: 0, 121 | end: 20, 122 | }, 123 | dongleId: 'asdfasdf', 124 | })).toBe(false); 125 | expect(hasRoutesData({ 126 | routes: [], 127 | routesMeta: { 128 | dongleId: 'asdfasdf', 129 | start: 0, 130 | end: 30, 131 | }, 132 | filter: { 133 | start: 10, 134 | end: 20, 135 | }, 136 | dongleId: 'asdfasdf', 137 | })).toBe(true); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /src/theme.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | import Colors from './colors'; 3 | import { ChevronIcon } from './icons'; 4 | 5 | const theme = createMuiTheme({ 6 | typography: { 7 | fontFamily: "'Inter', sans-serif", 8 | }, 9 | overrides: { 10 | MuiButton: { 11 | root: { 12 | textTransform: 'none', 13 | }, 14 | }, 15 | MuiPaper: { 16 | root: { 17 | backgroundColor: '#30373B', 18 | }, 19 | }, 20 | MuiDrawer: { 21 | paper: { 22 | overflowY: null, 23 | }, 24 | paperAnchorDockedLeft: { 25 | borderRight: 'none', 26 | }, 27 | }, 28 | MuiSelect: { 29 | select: { 30 | padding: '12px', 31 | paddingRight: '48px', 32 | margin: '0px', 33 | '&>div': { 34 | margin: '0', 35 | }, 36 | '&:focus': { 37 | background: 'inherit', 38 | }, 39 | }, 40 | selectMenu: { 41 | paddingRight: 54, 42 | }, 43 | icon: { 44 | marginRight: 20, 45 | color: Colors.white30, 46 | }, 47 | }, 48 | MuiInput: { 49 | root: { 50 | position: 'relative', 51 | border: `1px solid ${Colors.grey800}`, 52 | borderRadius: 20, 53 | overflow: 'hidden', 54 | }, 55 | input: { 56 | padding: '12px 16px', 57 | '&::placeholder': { 58 | opacity: 1, 59 | color: Colors.white30, 60 | }, 61 | '&:focus': { 62 | outline: 'none', 63 | boxShadow: 'none', 64 | }, 65 | }, 66 | }, 67 | MuiInputLabel: { 68 | shrink: { 69 | transform: 'translate(0, -2px) scale(0.75)', 70 | }, 71 | }, 72 | MuiFormLabel: { 73 | root: { 74 | marginLeft: 16, 75 | marginTop: 4, 76 | }, 77 | }, 78 | MuiFormHelperText: { 79 | root: { 80 | marginLeft: 8, 81 | marginTop: 4, 82 | }, 83 | }, 84 | MuiTab: { 85 | root: { 86 | minHeight: 40, 87 | }, 88 | }, 89 | MuiListItem: { 90 | root: { 91 | '&:focus': { 92 | outline: 'none', 93 | boxShadow: 'none', 94 | }, 95 | }, 96 | }, 97 | MuiSnackbarContent: { 98 | root: { 99 | backgroundColor: Colors.grey700, 100 | color: Colors.white, 101 | }, 102 | }, 103 | }, 104 | props: { 105 | MuiSelect: { 106 | disableUnderline: true, 107 | IconComponent: ChevronIcon, 108 | }, 109 | MuiInput: { 110 | disableUnderline: true, 111 | }, 112 | }, 113 | palette: { 114 | type: 'dark', 115 | placeholder: Colors.white30, 116 | background: { 117 | default: Colors.grey999, 118 | }, 119 | primary: { 120 | light: Colors.lightBlue700, 121 | main: Colors.lightBlue900, 122 | dark: Colors.blue100, 123 | }, 124 | secondary: { 125 | light: Colors.green100, 126 | main: Colors.green200, 127 | dark: Colors.green500, 128 | }, 129 | states: { 130 | drivingBlue: Colors.blue500, 131 | engagedGreen: Colors.green400, 132 | engagedGrey: '#919b95', 133 | alertOrange: Colors.orange50, 134 | alertRed: Colors.red100, 135 | userFlag: Colors.yellow500, 136 | }, 137 | grey: { 138 | 50: Colors.grey50, 139 | 100: Colors.grey100, 140 | 200: Colors.grey200, 141 | 300: Colors.grey300, 142 | 400: Colors.grey400, 143 | 500: Colors.grey500, 144 | 600: Colors.grey600, 145 | 700: Colors.grey700, 146 | 800: Colors.grey800, 147 | 900: Colors.grey900, 148 | 950: Colors.grey950, 149 | 999: Colors.grey999, 150 | }, 151 | lightGrey: { 152 | 200: Colors.lightGrey200, 153 | }, 154 | white: { 155 | 10: Colors.white10, 156 | 12: Colors.white12, 157 | 20: Colors.white20, 158 | 30: Colors.white30, 159 | 40: Colors.white40, 160 | 50: Colors.white50, 161 | }, 162 | error: { 163 | main: 'rgba(209, 35, 35, 0.72)', 164 | }, 165 | }, 166 | }); 167 | 168 | export default theme; 169 | -------------------------------------------------------------------------------- /src/components/utils/SwitchLoading.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { withStyles, Switch, FormControlLabel, Popper, Typography } from '@material-ui/core'; 5 | import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; 6 | 7 | import Colors from '../../colors'; 8 | import InfoTooltip from './InfoTooltip'; 9 | 10 | const styles = () => ({ 11 | root: { 12 | display: 'flex', 13 | alignItems: 'center', 14 | }, 15 | switchThumbLoading: { 16 | '&::before': { 17 | content: '\'\'', 18 | display: 'inline-block', 19 | height: '100%', 20 | width: '100%', 21 | backgroundImage: 'url(\'data:image/svg+xml;utf8,' 22 | + '' 23 | + '\')', 25 | strokeDasharray: '80px, 200px', 26 | animation: 'circular-rotate 1s linear infinite', 27 | }, 28 | }, 29 | errorIcon: { 30 | color: Colors.red300, 31 | }, 32 | copiedPopover: { 33 | borderRadius: 16, 34 | padding: '8px 16px', 35 | border: `1px solid ${Colors.white10}`, 36 | backgroundColor: Colors.grey800, 37 | marginTop: 12, 38 | zIndex: 50000, 39 | maxWidth: '95%', 40 | '& p': { 41 | maxWidth: 400, 42 | fontSize: '0.9rem', 43 | color: Colors.white, 44 | margin: 0, 45 | }, 46 | }, 47 | }); 48 | 49 | class SwitchLoading extends Component { 50 | constructor(props) { 51 | super(props); 52 | 53 | this.state = { 54 | loading: false, 55 | checked: null, 56 | error: null, 57 | errorPopper: null, 58 | }; 59 | 60 | this.onChange = this.onChange.bind(this); 61 | } 62 | 63 | async onChange(ev) { 64 | if (this.state.loading) { 65 | return; 66 | } 67 | 68 | this.setState({ 69 | loading: true, 70 | checked: ev.target.checked, 71 | error: null, 72 | }); 73 | 74 | const res = await this.props.onChange(ev); 75 | if (res?.error) { 76 | this.setState({ 77 | loading: false, 78 | checked: null, 79 | error: res.error, 80 | }); 81 | return; 82 | } 83 | 84 | this.setState({ 85 | loading: false, 86 | checked: null, 87 | error: null, 88 | }); 89 | } 90 | 91 | render() { 92 | const { classes, checked, label, loading, tooltip } = this.props; 93 | 94 | const isChecked = this.state.checked !== null ? this.state.checked : checked; 95 | const loadingCls = (loading || this.state.loading) ? { icon: classes.switchThumbLoading } : {}; 96 | 97 | const switchEl = ( 98 | 105 | ); 106 | 107 | return ( 108 |
109 | 110 | { tooltip && } 111 | { Boolean(this.state.error) && ( 112 | <> 113 | this.setState({ errorPopper: null }) } 116 | onMouseEnter={ (ev) => this.setState({ errorPopper: ev.target }) } 117 | /> 118 | 124 | { this.state.error } 125 | 126 | 127 | )} 128 |
129 | ); 130 | } 131 | } 132 | 133 | SwitchLoading.propTypes = { 134 | checked: PropTypes.bool.isRequired, 135 | onChange: PropTypes.func.isRequired, 136 | loading: PropTypes.bool, 137 | label: PropTypes.string, 138 | tooltip: PropTypes.string, 139 | }; 140 | 141 | export default withStyles(styles)(SwitchLoading); 142 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, lazy, Suspense } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { Route, Switch, Redirect } from 'react-router'; 4 | import { ConnectedRouter } from 'connected-react-router'; 5 | import qs from 'query-string'; 6 | import localforage from 'localforage'; 7 | import * as Sentry from '@sentry/react'; 8 | 9 | import { CircularProgress, Grid } from '@material-ui/core'; 10 | 11 | import MyCommaAuth, { config as AuthConfig, storage as AuthStorage } from '@commaai/my-comma-auth'; 12 | import { athena as Athena, auth as Auth, billing as Billing, request as Request } from '@commaai/api'; 13 | 14 | import { getZoom, getClipsNav } from './url'; 15 | import { isDemo } from './demo'; 16 | import store, { history } from './store'; 17 | import { analyticsEvent } from './actions' 18 | 19 | const Explorer = lazy(() => import('./components/explorer')); 20 | const AnonymousLanding = lazy(() => import('./components/anonymous')); 21 | 22 | class App extends Component { 23 | constructor(props) { 24 | super(props); 25 | 26 | this.state = { 27 | initialized: false, 28 | }; 29 | 30 | let pairToken; 31 | if (window.location) { 32 | pairToken = qs.parse(window.location.search).pair; 33 | } 34 | 35 | if (pairToken) { 36 | try { 37 | localforage.setItem('pairToken', pairToken); 38 | } catch (err) { 39 | console.error(err); 40 | } 41 | } 42 | } 43 | 44 | async componentDidMount() { 45 | if (window.location) { 46 | if (window.location.pathname === AuthConfig.AUTH_PATH) { 47 | try { 48 | const { code, provider } = qs.parse(window.location.search); 49 | const token = await Auth.refreshAccessToken(code, provider); 50 | if (token) { 51 | AuthStorage.setCommaAccessToken(token); 52 | } 53 | } catch (err) { 54 | console.error(err); 55 | Sentry.captureException(err, { fingerprint: 'app_auth_refresh_token' }); 56 | } 57 | } 58 | } 59 | 60 | const token = await MyCommaAuth.init(); 61 | if (token) { 62 | Request.configure(token); 63 | Billing.configure(token); 64 | Athena.configure(token); 65 | } 66 | 67 | this.setState({ initialized: true }); 68 | 69 | // set up analytics, low priority, so we do this last 70 | import('./analytics-v2'); 71 | } 72 | 73 | redirectLink() { 74 | let url = '/'; 75 | if (typeof window.sessionStorage !== 'undefined' && sessionStorage.getItem('redirectURL') !== null) { 76 | url = sessionStorage.getItem('redirectURL'); 77 | sessionStorage.removeItem('redirectURL'); 78 | } 79 | return url; 80 | } 81 | 82 | authRoutes() { 83 | return ( 84 | 85 | 86 | 87 | 88 | 89 | 90 | ); 91 | } 92 | 93 | anonymousRoutes() { 94 | return ( 95 | 96 | 97 | 98 | 99 | 100 | 101 | ); 102 | } 103 | 104 | renderLoading() { 105 | return ( 106 | 107 | 108 | 109 | 110 | 111 | ); 112 | } 113 | 114 | render() { 115 | if (!this.state.initialized) { 116 | return this.renderLoading(); 117 | } 118 | 119 | const showLogin = !MyCommaAuth.isAuthenticated() && !isDemo() && !getZoom(window.location.pathname) 120 | && !getClipsNav(window.location.pathname)?.clip_id; 121 | return ( 122 | 123 | 124 | 125 | { showLogin ? this.anonymousRoutes() : this.authRoutes() } 126 | 127 | 128 | 129 | ); 130 | } 131 | } 132 | 133 | export default App; 134 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | connect 49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 | 57 | 58 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/components/utils/InfoTooltip.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ClickAwayListener, Tooltip, Typography, withStyles } from '@material-ui/core'; 4 | import InfoIcon from '@material-ui/icons/InfoOutline'; 5 | 6 | const styles = (theme) => ({ 7 | arrowPopper: { 8 | opacity: 1, 9 | '&[x-placement*="bottom"] $arrowArrow': { 10 | top: 0, 11 | left: 0, 12 | marginTop: '-0.9em', 13 | width: '3em', 14 | height: '1em', 15 | '&::before': { 16 | borderWidth: '0 1em 1em 1em', 17 | borderColor: `transparent transparent ${theme.palette.grey[900]} transparent`, 18 | }, 19 | }, 20 | '&[x-placement*="top"] $arrowArrow': { 21 | bottom: 0, 22 | left: 0, 23 | marginBottom: '-0.9em', 24 | width: '3em', 25 | height: '1em', 26 | '&::before': { 27 | borderWidth: '1em 1em 0 1em', 28 | borderColor: `${theme.palette.grey[900]} transparent transparent transparent`, 29 | }, 30 | }, 31 | '&[x-placement*="right"] $arrowArrow': { 32 | left: 0, 33 | marginLeft: '-0.9em', 34 | height: '3em', 35 | width: '1em', 36 | '&::before': { 37 | borderWidth: '1em 1em 1em 0', 38 | borderColor: `transparent ${theme.palette.grey[900]} transparent transparent`, 39 | }, 40 | }, 41 | '&[x-placement*="left"] $arrowArrow': { 42 | right: 0, 43 | marginRight: '-0.9em', 44 | height: '3em', 45 | width: '1em', 46 | '&::before': { 47 | borderWidth: '1em 0 1em 1em', 48 | borderColor: `transparent transparent transparent ${theme.palette.grey[900]}`, 49 | }, 50 | }, 51 | }, 52 | arrowArrow: { 53 | position: 'absolute', 54 | fontSize: 7, 55 | width: '3em', 56 | height: '3em', 57 | '&::before': { 58 | content: '""', 59 | margin: 'auto', 60 | display: 'block', 61 | width: 0, 62 | height: 0, 63 | borderStyle: 'solid', 64 | }, 65 | }, 66 | tooltip: { 67 | background: theme.palette.grey[900], 68 | marginBottom: 8, 69 | }, 70 | icon: { 71 | marginLeft: theme.spacing.unit, 72 | fontSize: 18, 73 | }, 74 | }); 75 | 76 | class InfoTooltip extends Component { 77 | constructor(props) { 78 | super(props); 79 | 80 | this.state = { 81 | arrowRef: null, 82 | open: false, 83 | }; 84 | 85 | this.handleArrowRef = this.handleArrowRef.bind(this); 86 | this.onTooltipOpen = this.onTooltipOpen.bind(this); 87 | this.onTooltipClose = this.onTooltipClose.bind(this); 88 | } 89 | 90 | handleArrowRef(node) { 91 | this.setState({ 92 | arrowRef: node, 93 | }); 94 | } 95 | 96 | onTooltipOpen() { 97 | this.setState({ open: true }); 98 | } 99 | 100 | onTooltipClose() { 101 | this.setState({ open: false }); 102 | } 103 | 104 | render() { 105 | const { 106 | classes, 107 | title, 108 | placement = 'top', 109 | } = this.props; 110 | const { arrowRef, open } = this.state; 111 | 112 | return ( 113 | 114 | 127 | {title} 128 | 129 | 130 | )} 131 | onOpen={this.onTooltipOpen} 132 | onClose={this.onTooltipClose} 133 | open={open} 134 | classes={{ tooltip: classes.tooltip, popper: classes.arrowPopper }} 135 | placement={placement} 136 | > 137 | 138 | 139 | 140 | ); 141 | } 142 | } 143 | 144 | InfoTooltip.propTypes = { 145 | title: PropTypes.string.isRequired, 146 | placement: PropTypes.string, 147 | }; 148 | 149 | export default withStyles(styles)(InfoTooltip); 150 | -------------------------------------------------------------------------------- /src/actions/history.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | /* eslint-disable no-import-assign */ 3 | import { routerMiddleware, LOCATION_CHANGE } from 'connected-react-router'; 4 | import thunk from 'redux-thunk'; 5 | 6 | import { history } from '../store'; 7 | import { onHistoryMiddleware } from './history'; 8 | import * as actionsIndex from './index'; 9 | 10 | const create = (initialState) => { 11 | const store = { 12 | getState: jest.fn(() => initialState), 13 | dispatch: jest.fn(), 14 | }; 15 | const next = jest.fn(); 16 | 17 | const middleware = (s) => (n) => (action) => { 18 | routerMiddleware(history)(s)(n)(action); 19 | onHistoryMiddleware(s)(n)(action); 20 | thunk(s)(n)(action); 21 | }; 22 | const invoke = (action) => middleware(store)(next)(action); 23 | 24 | return { store, next, invoke }; 25 | }; 26 | 27 | describe('history middleware', () => { 28 | it('passes through non-function action', () => { 29 | const { next, invoke } = create(); 30 | const action = { type: 'TEST' }; 31 | invoke(action); 32 | expect(next).toHaveBeenCalledWith(action); 33 | }); 34 | 35 | it('calls the function', () => { 36 | const { invoke } = create(); 37 | const fn = jest.fn(); 38 | invoke(fn); 39 | expect(fn).toHaveBeenCalled(); 40 | }); 41 | 42 | it('passes dispatch and getState', () => { 43 | const { store, invoke } = create(); 44 | invoke((dispatch, getState) => { 45 | dispatch('TEST DISPATCH'); 46 | getState(); 47 | }); 48 | expect(store.dispatch).toHaveBeenCalledWith('TEST DISPATCH'); 49 | }); 50 | 51 | it('should call select dongle with history', async () => { 52 | const fakeInner = { id: 'kahjfiowenv' }; 53 | actionsIndex.selectDevice = jest.fn(() => fakeInner); 54 | 55 | const { store, next, invoke } = create({ 56 | dongleId: null, 57 | zoom: null, 58 | primeNav: false, 59 | }); 60 | 61 | const action = { 62 | type: LOCATION_CHANGE, 63 | payload: { 64 | action: 'POP', 65 | location: { pathname: '0000aaaa0000aaaa' }, 66 | }, 67 | }; 68 | invoke(action); 69 | expect(next).toHaveBeenCalledWith(action); 70 | expect(store.dispatch).toHaveBeenCalledTimes(1); 71 | expect(store.dispatch).toHaveBeenCalledWith(fakeInner); 72 | expect(actionsIndex.selectDevice).toHaveBeenCalledWith('0000aaaa0000aaaa', false); 73 | }); 74 | 75 | it('should call select zoom with history', async () => { 76 | const fakeInner = { id: 'asdfsd83242' }; 77 | actionsIndex.selectRange = jest.fn(() => fakeInner); 78 | 79 | const { store, next, invoke } = create({ 80 | dongleId: '0000aaaa0000aaaa', 81 | zoom: null, 82 | primeNav: false, 83 | }); 84 | 85 | const action = { 86 | type: LOCATION_CHANGE, 87 | payload: { 88 | action: 'POP', 89 | location: { pathname: '0000aaaa0000aaaa/1230/1234' }, 90 | }, 91 | }; 92 | invoke(action); 93 | expect(next).toHaveBeenCalledWith(action); 94 | expect(store.dispatch).toHaveBeenCalledTimes(1); 95 | expect(store.dispatch).toHaveBeenCalledWith(fakeInner); 96 | expect(actionsIndex.selectRange).toHaveBeenCalledWith(1230, 1234, false); 97 | }); 98 | 99 | it('should call prime nav with history', async () => { 100 | const fakeInner = { id: 'n27u3n9va' }; 101 | const fakeInner2 = { id: 'vmklxmsd' }; 102 | actionsIndex.selectRange = jest.fn(() => fakeInner); 103 | actionsIndex.primeNav = jest.fn(() => fakeInner2); 104 | 105 | const { store, next, invoke } = create({ 106 | dongleId: '0000aaaa0000aaaa', 107 | zoom: { start: 1230, end: 1234 }, 108 | primeNav: false, 109 | }); 110 | 111 | const action = { 112 | type: LOCATION_CHANGE, 113 | payload: { 114 | action: 'POP', 115 | location: { pathname: '0000aaaa0000aaaa/prime' }, 116 | }, 117 | }; 118 | invoke(action); 119 | expect(next).toHaveBeenCalledWith(action); 120 | expect(store.dispatch).toHaveBeenCalledTimes(2); 121 | expect(store.dispatch).toHaveBeenCalledWith(fakeInner); 122 | expect(store.dispatch).toHaveBeenCalledWith(fakeInner2); 123 | expect(actionsIndex.selectRange).toHaveBeenCalledWith(undefined, undefined, false); 124 | expect(actionsIndex.primeNav).toHaveBeenCalledWith(true); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/components/Misc/DeviceSelect.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import { withStyles, Divider, Typography, Button, Modal, Paper } from '@material-ui/core'; 7 | 8 | import { deviceIsOnline, deviceTypePretty, filterRegularClick } from '../../utils'; 9 | import Colors from '../../colors'; 10 | 11 | const styles = (theme) => ({ 12 | modal: { 13 | position: 'absolute', 14 | width: 'max-content', 15 | maxWidth: '90%', 16 | left: '50%', 17 | top: '50%', 18 | maxHeight: '80vh', 19 | transform: 'translate(-50%, -50%)', 20 | outline: 'none', 21 | display: 'flex', 22 | flexDirection: 'column', 23 | }, 24 | deviceList: { 25 | overflowY: 'auto', 26 | }, 27 | titleContainer: { 28 | margin: theme.spacing.unit * 2, 29 | }, 30 | cancelButton: { 31 | margin: theme.spacing.unit * 2, 32 | alignSelf: 'flex-end', 33 | '&:hover': { 34 | backgroundColor: Colors.white10, 35 | color: Colors.white, 36 | }, 37 | }, 38 | device: { 39 | cursor: 'pointer', 40 | textDecoration: 'none', 41 | alignItems: 'center', 42 | display: 'flex', 43 | justifyContent: 'space-between', 44 | padding: '16px 32px', 45 | '&:hover': { 46 | backgroundColor: Colors.darken10, 47 | }, 48 | }, 49 | deviceOnline: { 50 | width: 6, 51 | height: 6, 52 | borderRadius: 3, 53 | backgroundColor: Colors.green400, 54 | }, 55 | deviceOffline: { 56 | backgroundColor: Colors.grey400, 57 | }, 58 | deviceInfo: { 59 | display: 'flex', 60 | alignItems: 'center', 61 | }, 62 | deviceName: { 63 | display: 'flex', 64 | flexDirection: 'column', 65 | justifyContent: 'center', 66 | marginLeft: 16, 67 | }, 68 | deviceAlias: { 69 | fontWeight: 600, 70 | }, 71 | deviceId: { 72 | color: Colors.grey300, 73 | }, 74 | }); 75 | 76 | class DeviceSelect extends Component { 77 | constructor(props) { 78 | super(props); 79 | 80 | this.renderDevice = this.renderDevice.bind(this); 81 | } 82 | 83 | renderDevice(device) { 84 | const { classes, onSelect, deviceHref } = this.props; 85 | const alias = device.alias || deviceTypePretty(device.device_type); 86 | const offlineCls = !deviceIsOnline(device) ? classes.deviceOffline : ''; 87 | return ( 88 | onSelect(device)) } 92 | href={ deviceHref ? deviceHref(device) : null } 93 | > 94 |
95 |
 
96 |
97 | 98 | { alias } 99 | 100 | 101 | { device.dongle_id } 102 | 103 |
104 |
105 |
106 | ); 107 | } 108 | 109 | render() { 110 | const { classes, devices, deviceFilter, onClose, open } = this.props; 111 | if (!devices) { 112 | return null; 113 | } 114 | 115 | return ( 116 | 117 | 118 |
119 | Select device 120 |
121 | 122 |
123 | {devices.filter(deviceFilter || (() => true)).map(this.renderDevice)} 124 |
125 | 128 |
129 |
130 | ); 131 | } 132 | } 133 | 134 | const stateToProps = Obstruction({ 135 | devices: 'devices', 136 | }); 137 | 138 | DeviceSelect.propTypes = { 139 | open: PropTypes.bool.isRequired, 140 | onSelect: PropTypes.func.isRequired, 141 | deviceHref: PropTypes.func, 142 | onClose: PropTypes.func, 143 | deviceFilter: PropTypes.func, 144 | }; 145 | 146 | export default connect(stateToProps)(withStyles(styles)(DeviceSelect)); 147 | -------------------------------------------------------------------------------- /src/timeline/playback.js: -------------------------------------------------------------------------------- 1 | // basic helper functions for controlling playback 2 | // we shouldn't want to edit the raw state most of the time, helper functions are better 3 | import * as Types from '../actions/types'; 4 | import { currentOffset } from '.'; 5 | 6 | export function reducer(_state, action) { 7 | let state = { ..._state }; 8 | let loopOffset = null; 9 | if (state.loop && state.loop.startTime !== null) { 10 | loopOffset = state.loop.startTime - state.filter.start; 11 | } 12 | switch (action.type) { 13 | case Types.ACTION_SEEK: 14 | state = { 15 | ...state, 16 | offset: action.offset, 17 | startTime: Date.now(), 18 | }; 19 | 20 | if (loopOffset !== null) { 21 | if (state.offset < loopOffset) { 22 | state.offset = loopOffset; 23 | } else if (state.offset > (loopOffset + state.loop.duration)) { 24 | state.offset = loopOffset + state.loop.duration; 25 | } 26 | } 27 | break; 28 | case Types.ACTION_PAUSE: 29 | state = { 30 | ...state, 31 | offset: currentOffset(state), 32 | startTime: Date.now(), 33 | desiredPlaySpeed: 0, 34 | }; 35 | break; 36 | case Types.ACTION_PLAY: 37 | if (action.speed !== state.desiredPlaySpeed) { 38 | state = { 39 | ...state, 40 | offset: currentOffset(state), 41 | desiredPlaySpeed: action.speed, 42 | startTime: Date.now(), 43 | }; 44 | } 45 | break; 46 | case Types.ACTION_LOOP: 47 | if (action.start && action.end) { 48 | state.loop = { 49 | startTime: action.start, 50 | duration: action.end - action.start, 51 | }; 52 | } else { 53 | state.loop = null; 54 | } 55 | break; 56 | case Types.ACTION_BUFFER_VIDEO: 57 | state = { 58 | ...state, 59 | isBufferingVideo: action.buffering, 60 | offset: currentOffset(state), 61 | startTime: Date.now(), 62 | }; 63 | break; 64 | case Types.ACTION_RESET: 65 | state = { 66 | ...state, 67 | desiredPlaySpeed: 1, 68 | isBufferingVideo: true, 69 | offset: 0, 70 | startTime: Date.now(), 71 | }; 72 | break; 73 | default: 74 | break; 75 | } 76 | 77 | if (state.currentRoute && state.currentRoute.videoStartOffset && state.loop && state.zoom && state.filter 78 | && state.loop.startTime === state.zoom.start && state.filter.start + state.currentRoute.offset === state.zoom.start) { 79 | const loopRouteOffset = state.loop.startTime - state.zoom.start; 80 | if (state.currentRoute.videoStartOffset > loopRouteOffset) { 81 | state.loop = { 82 | startTime: state.zoom.start + state.currentRoute.videoStartOffset, 83 | duration: state.loop.duration - (state.currentRoute.videoStartOffset - loopRouteOffset), 84 | }; 85 | } 86 | } 87 | 88 | // normalize over loop 89 | if (state.offset !== null && state.loop?.startTime) { 90 | const playSpeed = state.isBufferingVideo ? 0 : state.desiredPlaySpeed; 91 | const offset = state.offset + (Date.now() - state.startTime) * playSpeed; 92 | loopOffset = state.loop.startTime - state.filter.start; 93 | // has loop, trap offset within the loop 94 | if (offset < loopOffset) { 95 | state.startTime = Date.now(); 96 | state.offset = loopOffset; 97 | } else if (offset > loopOffset + state.loop.duration) { 98 | state.offset = ((offset - loopOffset) % state.loop.duration) + loopOffset; 99 | state.startTime = Date.now(); 100 | } 101 | } 102 | 103 | state.isBufferingVideo = Boolean(state.isBufferingVideo); 104 | 105 | return state; 106 | } 107 | 108 | // seek to a specific offset 109 | export function seek(offset) { 110 | return { 111 | type: Types.ACTION_SEEK, 112 | offset, 113 | }; 114 | } 115 | 116 | // pause the playback 117 | export function pause() { 118 | return { 119 | type: Types.ACTION_PAUSE, 120 | }; 121 | } 122 | 123 | // resume / change play speed 124 | export function play(speed = 1) { 125 | return { 126 | type: Types.ACTION_PLAY, 127 | speed, 128 | }; 129 | } 130 | 131 | export function selectLoop(start, end) { 132 | return { 133 | type: Types.ACTION_LOOP, 134 | start, 135 | end, 136 | }; 137 | } 138 | 139 | // update video buffering state 140 | export function bufferVideo(buffering) { 141 | return { 142 | type: Types.ACTION_BUFFER_VIDEO, 143 | buffering, 144 | }; 145 | } 146 | 147 | export function resetPlayback() { 148 | return { 149 | type: Types.ACTION_RESET, 150 | }; 151 | } 152 | -------------------------------------------------------------------------------- /src/components/IosPwaPopup/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | import localforage from 'localforage'; 5 | import { withStyles, Typography } from '@material-ui/core'; 6 | import { Clear } from '@material-ui/icons'; 7 | 8 | import MyCommaAuth from '@commaai/my-comma-auth'; 9 | 10 | import Colors from '../../colors'; 11 | import { IosShareIcon } from '../../icons'; 12 | import ResizeHandler from '../ResizeHandler'; 13 | 14 | const styles = () => ({ 15 | container: { 16 | position: 'fixed', 17 | bottom: 10, 18 | left: 10, 19 | right: 10, 20 | }, 21 | box: { 22 | margin: '0 auto', 23 | borderRadius: 22, 24 | padding: '12px 20px', 25 | color: Colors.white, 26 | display: 'flex', 27 | flexDirection: 'column', 28 | backgroundColor: Colors.grey500, 29 | border: `1px solid ${Colors.grey700}`, 30 | }, 31 | hide: { 32 | cursor: 'pointer', 33 | padding: 5, 34 | fontSize: 20, 35 | position: 'relative', 36 | left: -30, 37 | top: -24, 38 | marginBottom: -32, 39 | height: 32, 40 | width: 32, 41 | borderRadius: 16, 42 | backgroundColor: Colors.grey900, 43 | color: Colors.white, 44 | border: `1px solid ${Colors.grey600}`, 45 | }, 46 | title: { 47 | lineHeight: '31px', 48 | fontSize: 20, 49 | fontWeight: 600, 50 | }, 51 | icon: { 52 | display: 'inline', 53 | verticalAlign: 'text-bottom', 54 | margin: '0 3px', 55 | }, 56 | }); 57 | 58 | class IosPwaPopup extends Component { 59 | constructor(props) { 60 | super(props); 61 | 62 | this.state = { 63 | windowWidth: window.innerWidth, 64 | show: false, 65 | }; 66 | 67 | this.hide = this.hide.bind(this); 68 | this.onWindowClick = this.onWindowClick.bind(this); 69 | 70 | this.windowEvents = 0; 71 | } 72 | 73 | async componentDidMount() { 74 | if (window && window.navigator) { 75 | const isIos = /iphone|ipad|ipod/.test(window.navigator.userAgent.toLowerCase()); 76 | const isStandalone = window.navigator.standalone === true; 77 | if (isIos && !isStandalone && MyCommaAuth.isAuthenticated()) { 78 | let isHidden; 79 | try { 80 | isHidden = await localforage.getItem('hideIosPwaPopup'); 81 | } catch (err) { 82 | isHidden = true; 83 | } 84 | this.setState({ show: !isHidden }); 85 | } 86 | } 87 | } 88 | 89 | async componentDidUpdate(prevProps, prevState) { 90 | if (!prevState.show && this.state.show) { 91 | window.addEventListener('click', this.onWindowClick); 92 | } else if (prevState.show && !this.state.show) { 93 | window.removeEventListener('click', this.onWindowClick); 94 | } 95 | 96 | if (prevProps.pathname !== this.props.pathname) { 97 | this.hide(); 98 | } 99 | } 100 | 101 | componentWillUnmount() { 102 | window.removeEventListener('click', this.onWindowClick); 103 | } 104 | 105 | onWindowClick() { 106 | this.windowEvents += 1; 107 | if (this.windowEvents >= 3) { 108 | this.hide(); 109 | } 110 | } 111 | 112 | hide() { 113 | try { 114 | localforage.setItem('hideIosPwaPopup', true); 115 | } catch (err) {} 116 | this.setState({ show: false }); 117 | } 118 | 119 | render() { 120 | const { classes } = this.props; 121 | 122 | if (!this.state.show) { 123 | return null; 124 | } 125 | 126 | const boxWidth = this.state.windowWidth <= 400 ? 'auto' : 'fit-content'; 127 | 128 | return ( 129 | <> 130 | this.setState({ windowWidth }) } /> 131 |
132 |
133 | 134 | Add to home screen 135 | 136 | Install this webapp on your home screen: 137 | {' '} 138 |
139 | tap 140 | {' '} 141 | 142 | {' '} 143 | and then ‘Add to Home Screen’ 144 |
145 |
146 |
147 | 148 | ); 149 | } 150 | } 151 | 152 | const stateToProps = Obstruction({ 153 | pathname: 'router.location.pathname', 154 | }); 155 | 156 | export default connect(stateToProps)(withStyles(styles)(IosPwaPopup)); 157 | -------------------------------------------------------------------------------- /src/timeline/playback.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { asyncSleep } from '../utils'; 3 | import { currentOffset } from '.'; 4 | import { bufferVideo, pause, play, reducer, seek, selectLoop } from './playback'; 5 | 6 | const makeDefaultStruct = function makeDefaultStruct() { 7 | return { 8 | filter: { 9 | start: Date.now(), 10 | end: Date.now() + 100000, 11 | }, 12 | desiredPlaySpeed: 1, // 0 = stopped, 1 = playing, 2 = 2x speed... multiplier on speed 13 | offset: 0, // in miliseconds from the start 14 | startTime: Date.now(), // millisecond timestamp in which play began 15 | 16 | isBuffering: true, 17 | }; 18 | }; 19 | 20 | // make Date.now super stable for tests 21 | let mostRecentNow = Date.now(); 22 | const oldNow = Date.now; 23 | Date.now = function now() { 24 | return mostRecentNow; 25 | }; 26 | function newNow() { 27 | mostRecentNow = oldNow(); 28 | return mostRecentNow; 29 | } 30 | 31 | describe('playback', () => { 32 | it('has playback controls', async () => { 33 | newNow(); 34 | let state = makeDefaultStruct(); 35 | 36 | // should do nothing 37 | state = reducer(state, pause()); 38 | expect(state.desiredPlaySpeed).toEqual(0); 39 | 40 | // start playing, should set start time and such 41 | let playTime = newNow(); 42 | state = reducer(state, play()); 43 | // this is a (usually 1ms) race condition 44 | expect(state.startTime).toEqual(playTime); 45 | expect(state.desiredPlaySpeed).toEqual(1); 46 | 47 | await asyncSleep(100 + Math.random() * 200); 48 | // should update offset 49 | let ellapsed = newNow() - playTime; 50 | state = reducer(state, pause()); 51 | 52 | expect(state.offset).toEqual(ellapsed); 53 | 54 | // start playing, should set start time and such 55 | playTime = newNow(); 56 | state = reducer(state, play(0.5)); 57 | // this is a (usually 1ms) race condition 58 | expect(state.startTime).toEqual(playTime); 59 | expect(state.desiredPlaySpeed).toEqual(0.5); 60 | 61 | await asyncSleep(100 + Math.random() * 200); 62 | // should update offset, playback speed 1/2 63 | ellapsed += (newNow() - playTime) / 2; 64 | expect(currentOffset(state)).toEqual(ellapsed); 65 | state = reducer(state, pause()); 66 | 67 | expect(state.offset).toEqual(ellapsed); 68 | 69 | // seek! 70 | newNow(); 71 | state = reducer(state, seek(123)); 72 | expect(state.offset).toEqual(123); 73 | expect(state.startTime).toEqual(Date.now()); 74 | expect(currentOffset(state)).toEqual(123); 75 | }); 76 | 77 | it('should clamp loop when seeked after loop end time', () => { 78 | newNow(); 79 | let state = makeDefaultStruct(); 80 | 81 | // set up loop 82 | state = reducer(state, play()); 83 | state = reducer(state, selectLoop( 84 | state.filter.start + 1000, 85 | state.filter.start + 2000, 86 | )); 87 | expect(state.loop.startTime).toEqual(state.filter.start + 1000); 88 | 89 | // seek past loop end boundary a 90 | state = reducer(state, seek(3000)); 91 | expect(state.loop.startTime).toEqual(state.filter.start + 1000); 92 | expect(state.offset).toEqual(2000); 93 | }); 94 | 95 | it('should clamp loop when seeked before loop start time', () => { 96 | newNow(); 97 | let state = makeDefaultStruct(); 98 | 99 | // set up loop 100 | state = reducer(state, play()); 101 | state = reducer(state, selectLoop( 102 | state.filter.start + 1000, 103 | state.filter.start + 2000, 104 | )); 105 | expect(state.loop.startTime).toEqual(state.filter.start + 1000); 106 | 107 | // seek past loop end boundary a 108 | state = reducer(state, seek(0)); 109 | expect(state.loop.startTime).toEqual(state.filter.start + 1000); 110 | expect(state.offset).toEqual(1000); 111 | }); 112 | 113 | it('should buffer video and data', async () => { 114 | newNow(); 115 | let state = makeDefaultStruct(); 116 | 117 | state = reducer(state, play()); 118 | expect(state.desiredPlaySpeed).toEqual(1); 119 | 120 | // claim the video is buffering 121 | state = reducer(state, bufferVideo(true)); 122 | expect(state.desiredPlaySpeed).toEqual(1); 123 | expect(state.isBufferingVideo).toEqual(true); 124 | 125 | state = reducer(state, play(0.5)); 126 | expect(state.desiredPlaySpeed).toEqual(0.5); 127 | expect(state.isBufferingVideo).toEqual(true); 128 | 129 | expect(state.desiredPlaySpeed).toEqual(0.5); 130 | 131 | state = reducer(state, play(2)); 132 | state = reducer(state, bufferVideo(false)); 133 | expect(state.desiredPlaySpeed).toEqual(2); 134 | expect(state.isBufferingVideo).toEqual(false); 135 | 136 | expect(state.desiredPlaySpeed).toEqual(2); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/colors.js: -------------------------------------------------------------------------------- 1 | const Colors = { 2 | transparent: 'transparent', 3 | white: '#fff', 4 | darken10: 'rgba(0, 0, 0, 0.1)', 5 | darken20: 'rgba(0, 0, 0, 0.2)', 6 | darken30: 'rgba(0, 0, 0, 0.3)', 7 | darken40: 'rgba(0, 0, 0, 0.4)', 8 | darken50: 'rgba(0, 0, 0, 0.5)', 9 | darken60: 'rgba(0, 0, 0, 0.6)', 10 | darken70: 'rgba(0, 0, 0, 0.7)', 11 | darken80: 'rgba(0, 0, 0, 0.8)', 12 | darken90: 'rgba(0, 0, 0, 0.9)', 13 | white03: 'rgba(255, 255, 255, 0.03)', 14 | white05: 'rgba(255, 255, 255, 0.05)', 15 | white08: 'rgba(255, 255, 255, 0.08)', 16 | white10: 'rgba(255, 255, 255, 0.1)', 17 | white12: 'rgba(255, 255, 255, 0.12)', 18 | white20: 'rgba(255, 255, 255, 0.2)', 19 | white30: 'rgba(255, 255, 255, 0.3)', 20 | white40: 'rgba(255, 255, 255, 0.4)', 21 | white50: 'rgba(255, 255, 255, 0.5)', 22 | white60: 'rgba(255, 255, 255, 0.6)', 23 | white70: 'rgba(255, 255, 255, 0.7)', 24 | white80: 'rgba(255, 255, 255, 0.8)', 25 | white90: 'rgba(255, 255, 255, 0.9)', 26 | black: '#030404', 27 | blue50: '#258FDA', 28 | blue100: '#2284c9', 29 | blue200: '#1f79b8', 30 | blue300: '#1c6ea8', 31 | blue400: '#1a6397', 32 | blue500: '#175886', 33 | blue600: '#144d75', 34 | blue700: '#114265', 35 | blue800: '#0e3754', 36 | blue900: '#0b2c43', 37 | blue950: '#061622', 38 | blue999: '#030b11', 39 | lightBlue50: '#eef6fc', 40 | lightBlue100: '#ddeef9', 41 | lightBlue200: '#cde5f6', 42 | lightBlue300: '#bcddf4', 43 | lightBlue400: '#abd4f1', 44 | lightBlue500: '#9acbee', 45 | lightBlue600: '#8ac3eb', 46 | lightBlue700: '#79bae8', 47 | lightBlue800: '#68b1e5', 48 | lightBlue900: '#57a9e3', 49 | desatBlue800: '#657f92', 50 | primeBlue50: '#5e8bff', 51 | primeBlue100: '#5984F2', 52 | primeBlue200: '#547de6', 53 | primeBlue300: '#4b6fcc', 54 | primeBlue400: '#4261b3', 55 | primeBlue500: '#385399', 56 | primeBlue600: '#2f4680', 57 | primeBlue700: '#263866', 58 | primeBlue800: '#1c2a4d', 59 | primeBlue900: '#131c33', 60 | red50: '#da2535', 61 | red100: '#c92231', 62 | red200: '#b81f2d', 63 | red300: '#971a25', 64 | red400: '#861721', 65 | red500: '#75141d', 66 | red600: '#651118', 67 | red700: '#540e14', 68 | red800: '#430b10', 69 | red900: '#32090c', 70 | lightRed50: '#fceeef', 71 | lightRed100: '#f9dde0', 72 | lightRed200: '#f6cdd0', 73 | lightRed300: '#f4bcc1', 74 | lightRed400: '#f1abb1', 75 | lightRed500: '#ee9aa2', 76 | lightRed600: '#eb8a92', 77 | lightRed700: '#e87983', 78 | lightRed800: '#e56873', 79 | lightRed900: '#e35764', 80 | lightRed950: '#e04754', 81 | lightRed999: '#dd3645', 82 | green50: '#22c967', 83 | green100: '#20b85f', 84 | green200: '#1da756', 85 | green300: '#1a974e', 86 | green400: '#178645', 87 | green500: '#14753c', 88 | green600: '#116534', 89 | green700: '#0e542b', 90 | green800: '#0c4323', 91 | green900: '#09321a', 92 | lightGreen50: '#eefcf4', 93 | lightGreen100: '#def9e9', 94 | lightGreen200: '#cdf6de', 95 | lightGreen300: '#bcf4d3', 96 | lightGreen400: '#abf1c8', 97 | lightGreen500: '#9beebd', 98 | lightGreen600: '#8aebb2', 99 | lightGreen700: '#79e8a7', 100 | lightGreen800: '#68e59c', 101 | lightGreen900: '#25da70', 102 | orange50: '#da6f25', 103 | orange100: '#c96722', 104 | orange200: '#b85e1f', 105 | orange300: '#a7551c', 106 | orange400: '#964d19', 107 | orange500: '#864417', 108 | orange600: '#753c14', 109 | orange700: '#643311', 110 | orange800: '#532b0e', 111 | orange900: '#42220b', 112 | lightOrange50: '#f9e8dd', 113 | lightOrange100: '#f6ddcc', 114 | lightOrange200: '#f4d2bb', 115 | lightOrange300: '#f1c7aa', 116 | lightOrange400: '#eebc9a', 117 | lightOrange500: '#ebb189', 118 | lightOrange600: '#e8a678', 119 | lightOrange700: '#e59b67', 120 | lightOrange800: '#e08546', 121 | lightOrange900: '#dd7a35', 122 | yellow50: '#f1ebaa', 123 | yellow100: '#eee79a', 124 | yellow200: '#ebe389', 125 | yellow300: '#e8df78', 126 | yellow400: '#e5db67', 127 | yellow500: '#e3d756', 128 | yellow600: '#e0d346', 129 | yellow700: '#ddcf35', 130 | yellow800: '#daca25', 131 | yellow900: '#c9bb22', 132 | grey50: '#6e7d84', 133 | grey100: '#65737a', 134 | grey200: '#5c696f', 135 | grey300: '#535f64', 136 | grey400: '#4b5559', 137 | grey500: '#424a4f', 138 | grey600: '#394044', 139 | grey700: '#303639', 140 | grey800: '#272c2f', 141 | grey900: '#1e2224', 142 | grey950: '#151819', 143 | grey999: '#0c0e0f', 144 | lightGrey50: '#f8f9f9', 145 | lightGrey100: '#eeeff0', 146 | lightGrey200: '#e3e6e8', 147 | lightGrey300: '#d8dcdf', 148 | lightGrey400: '#cdd3d6', 149 | lightGrey500: '#c3c9cd', 150 | lightGrey600: '#b8c0c4', 151 | lightGrey700: '#adb6bb', 152 | lightGrey800: '#a3adb2', 153 | lightGrey900: '#98a3a9', 154 | lightGrey950: '#8d9aa1', 155 | lightGrey999: '#77878f', 156 | }; 157 | 158 | export default Colors; 159 | -------------------------------------------------------------------------------- /src/components/Timeline/thumbnails.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react'; 3 | import { render, screen } from '@testing-library/react'; 4 | import Thumbnails from './thumbnails'; 5 | 6 | const screenHeight = 1000; 7 | const screenWidth = 1600; 8 | const gutter = 20; 9 | const percentToOffsetMock = jest.fn(); 10 | const getCurrentRouteMock = jest.fn(); 11 | 12 | const thumbnailBounds = { 13 | top: 100, 14 | bottom: screenHeight - (100 + 100), // top + height 15 | left: gutter, 16 | right: screenWidth - gutter, 17 | 18 | width: screenWidth - (gutter * 2), 19 | height: 100, 20 | }; 21 | 22 | const heightWithBlackBorder = 120; 23 | 24 | describe('timeline thumbnails', () => { 25 | beforeEach(() => { 26 | percentToOffsetMock.mockImplementation((percent) => Math.round(percent * 30000)); 27 | getCurrentRouteMock.mockImplementation((offset) => { 28 | if (offset < 1600 || offset > 20000) { 29 | return null; 30 | } 31 | return { 32 | offset: 1600, 33 | segment_numbers: Array.from(Array(4).keys()), 34 | segment_offsets: Array.from(Array(4).keys()).map((i) => i * 60), 35 | }; 36 | }); 37 | }); 38 | 39 | it('should check the segment for every image', () => { 40 | render( 41 | , 46 | ); 47 | 48 | expect(percentToOffsetMock.mock.calls.length).toBe(10); 49 | expect(getCurrentRouteMock.mock.calls.length).toBe(10); 50 | const imageEntries = screen.getAllByRole('img'); 51 | expect(imageEntries).toHaveLength(5); 52 | 53 | imageEntries.forEach((entry, i) => { 54 | expect([...entry.classList].indexOf('thumbnailImage')).toBeGreaterThan(-1); 55 | if (i === 0 || i === 4) { 56 | expect([...entry.classList].indexOf('blank')).toBeGreaterThan(-1); 57 | } else { 58 | const backgroundParts = entry.style.backgroundSize.split(' '); 59 | const height = Number(backgroundParts[1].replace('px', '')); 60 | expect(height).toBe(heightWithBlackBorder); 61 | // never stretch thumbnail images 62 | expect(backgroundParts[0]).toBe('auto'); 63 | } 64 | }); 65 | }); 66 | 67 | it('doesn\'t render before bounds are set', () => { 68 | render( 69 | , 81 | ); 82 | 83 | expect(screen.queryAllByRole('img')).toHaveLength(0); 84 | }); 85 | 86 | it('works when theres no blank at the end', () => { 87 | getCurrentRouteMock.mockImplementation((offset) => { 88 | if (offset < 1600) { 89 | return null; 90 | } 91 | return { 92 | offset: 1600, 93 | segment_numbers: Array.from(Array(4).keys()), 94 | segment_offsets: Array.from(Array(4).keys()).map((i) => i * 60), 95 | }; 96 | }); 97 | 98 | render( 99 | , 104 | ); 105 | 106 | expect(percentToOffsetMock.mock.calls.length).toBe(10); 107 | expect(getCurrentRouteMock.mock.calls.length).toBe(10); 108 | const imageEntries = screen.getAllByRole('img'); 109 | expect(imageEntries).toHaveLength(5); 110 | 111 | imageEntries.forEach((entry, i) => { 112 | expect([...entry.classList].indexOf('thumbnailImage')).toBeGreaterThan(-1); 113 | if (i === 0) { 114 | expect([...entry.classList].indexOf('blank')).toBeGreaterThan(-1); 115 | } else { 116 | const backgroundParts = entry.style.backgroundSize.split(' '); 117 | const height = Number(backgroundParts[1].replace('px', '')); 118 | expect(height).toBe(heightWithBlackBorder); 119 | 120 | // never stretch thumbnail images 121 | expect(backgroundParts[0]).toBe('auto'); 122 | } 123 | }); 124 | }); 125 | 126 | it('works when its supermegaskinny', () => { 127 | render( 128 | , 140 | ); 141 | 142 | expect(screen.queryAllByRole('img')).toHaveLength(0); 143 | expect(percentToOffsetMock.mock.calls.length).toBe(0); 144 | expect(getCurrentRouteMock.mock.calls.length).toBe(0); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/icons/original/360-degrees-video.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 44 | 47 | 50 | 53 | 57 | 61 | 65 | 69 | 73 | 77 | 78 | 79 | 80 | 82 | 83 | 85 | 86 | 88 | 89 | 91 | 92 | 94 | 95 | 97 | 98 | 100 | 101 | 103 | 104 | 106 | 107 | 109 | 110 | 112 | 113 | 115 | 116 | 118 | 119 | 121 | 122 | 124 | 125 | -------------------------------------------------------------------------------- /src/utils/geocode.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | import qs from 'query-string'; 3 | import { WebMercatorViewport } from 'react-map-gl'; 4 | 5 | export const MAPBOX_TOKEN = 'pk.eyJ1IjoiY29tbWFhaSIsImEiOiJjangyYXV0c20wMGU2NDluMWR4amUydGl5In0.6Vb11S6tdX6Arpj6trRE_g'; 6 | const HERE_API_KEY = 'FzdKQBdDlWNQfvlvreB9ukezD-fYi7uKW0rM_K9eE2E'; 7 | 8 | const mbxGeocoding = require('@mapbox/mapbox-sdk/services/geocoding'); 9 | const mbxDirections = require('@mapbox/mapbox-sdk/services/directions'); 10 | 11 | const geocodingClient = mbxGeocoding({ accessToken: MAPBOX_TOKEN }); 12 | const directionsClient = mbxDirections({ accessToken: MAPBOX_TOKEN }); 13 | 14 | export function getFilteredContexts(context) { 15 | const includeCtxs = ['region', 'district', 'place', 'locality', 'neighborhood']; 16 | return context.filter((ctx) => includeCtxs.some((c) => ctx.id.indexOf(c) !== -1)); 17 | } 18 | 19 | function getContextString(context) { 20 | if (context.id.indexOf('region') !== -1 && context.short_code) { 21 | if (context.short_code.indexOf('US-') !== -1) { 22 | return context.short_code.substr(3); 23 | } 24 | return context.short_code; 25 | } 26 | return context.text; 27 | } 28 | 29 | function getContextMap(context) { 30 | const map = {}; 31 | context.forEach((ctx) => { 32 | const key = ctx.id.split('.', 1)[0]; 33 | map[key] = getContextString(ctx); 34 | }); 35 | return map; 36 | } 37 | 38 | export function priorityGetContext(contexts) { 39 | const priority = ['place', 'locality', 'district']; 40 | return priority.flatMap((prio) => contexts.filter((ctx) => ctx.id.indexOf(prio) !== -1))[0]; 41 | } 42 | 43 | export async function reverseLookup(coords, navFormat = false) { 44 | if (geocodingClient === null || (coords[0] === 0 && coords[1] === 0)) { 45 | return null; 46 | } 47 | 48 | const endpoint = 'https://api.mapbox.com/geocoding/v5/mapbox.places/'; 49 | const params = { 50 | access_token: MAPBOX_TOKEN, 51 | limit: 1, 52 | }; 53 | 54 | let resp; 55 | try { 56 | resp = await fetch(`${endpoint}${coords[0]},${coords[1]}.json?${qs.stringify(params)}`, { 57 | method: 'GET', 58 | cache: 'force-cache', 59 | }); 60 | if (!resp.ok) { 61 | return null; 62 | } 63 | } catch (err) { 64 | console.error(err); 65 | return null; 66 | } 67 | 68 | try { 69 | const { features } = await resp.json(); 70 | if (features.length && features[0].context) { 71 | if (navFormat) { 72 | // Used for navigation locations API (saving favorites) 73 | // Try to format location similarly to HERE, which is where the search results come from 74 | 75 | // e.g. Mapbox returns "Street", "Avenue", etc. 76 | const context = getContextMap(features[0].context); 77 | // e.g. "State St", TODO: Street -> St, Avenue -> Ave, etc. 78 | const place = features[0].text; 79 | // e.g. "San Diego, CA 92101, United States" 80 | const details = `${context.place}, ${context.region} ${context.postcode}, ${context.country}`; 81 | 82 | return { place, details }; 83 | } 84 | const contexts = getFilteredContexts(features[0].context); 85 | 86 | // Used for location name/area in drive list 87 | // e.g. "Little Italy" 88 | let place = ''; 89 | // e.g. "San Diego, CA" 90 | let details = ''; 91 | if (contexts.length > 0) { 92 | place = getContextString(contexts.shift()); 93 | } 94 | if (contexts.length > 0) { 95 | details = getContextString(contexts.pop()); 96 | } 97 | if (contexts.length > 0) { 98 | details = `${getContextString(priorityGetContext(contexts))}, ${details}`; 99 | } 100 | 101 | return { place, details }; 102 | } 103 | } catch (err) { 104 | Sentry.captureException(err, { fingerprint: 'geocode_reverse_parse' }); 105 | } 106 | 107 | return null; 108 | } 109 | 110 | export async function forwardLookup(query, proximity, viewport) { 111 | if (!query) { 112 | return []; 113 | } 114 | 115 | const params = { 116 | apiKey: HERE_API_KEY, 117 | q: query, 118 | limit: 20, 119 | show: ['details'], 120 | }; 121 | if (proximity) { 122 | params.at = `${proximity[1]},${proximity[0]}`; 123 | } else if (viewport) { 124 | const bbox = new WebMercatorViewport(viewport).getBounds(); 125 | const vals = [ 126 | Math.max(-180, bbox[0][0]), 127 | Math.max(-90, bbox[0][1]), 128 | Math.min(180, bbox[1][0]), 129 | Math.min(90, bbox[1][1]), 130 | ]; 131 | params.in = `bbox:${vals.join(',')}`; 132 | } else { 133 | params.in = 'bbox:-180,-90,180,90'; 134 | } 135 | 136 | const resp = await fetch(`https://autosuggest.search.hereapi.com/v1/autosuggest?${qs.stringify(params)}`, { 137 | method: 'GET', 138 | }); 139 | if (!resp.ok) { 140 | console.error(resp); 141 | return []; 142 | } 143 | 144 | const json = await resp.json(); 145 | return json.items; 146 | } 147 | 148 | export async function networkPositioning(req) { 149 | const resp = await fetch(`https://positioning.hereapi.com/v2/locate?apiKey=${HERE_API_KEY}&fallback=any,singleWifi`, { 150 | method: 'POST', 151 | headers: new Headers({ 'Content-Type': 'application/json' }), 152 | body: JSON.stringify(req), 153 | }); 154 | if (!resp.ok) { 155 | console.error(resp); 156 | return null; 157 | } 158 | const json = await resp.json(); 159 | return json.location; 160 | } 161 | 162 | export async function getDirections(points) { 163 | if (!directionsClient) { 164 | return null; 165 | } 166 | 167 | const resp = await directionsClient.getDirections({ 168 | profile: 'driving-traffic', 169 | waypoints: points.map((p) => ({ coordinates: p })), 170 | annotations: ['distance', 'duration'], 171 | geometries: 'geojson', 172 | overview: 'full', 173 | }).send(); 174 | 175 | return resp.body.routes; 176 | } 177 | -------------------------------------------------------------------------------- /src/actions/clips.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | import { push } from 'connected-react-router'; 3 | import MyCommaAuth from '@commaai/my-comma-auth'; 4 | import { clips as Clips } from '@commaai/api'; 5 | 6 | import { checkRoutesData, selectDevice, urlForState } from '.'; 7 | import { getClipsNav } from '../url'; 8 | import * as Types from './types'; 9 | 10 | export function clipsExit() { 11 | return (dispatch, getState) => { 12 | const { dongleId, clips, zoom } = getState(); 13 | 14 | const shouldPathChange = Boolean(getClipsNav(window.location.pathname)); 15 | dispatch({ 16 | type: Types.ACTION_CLIPS_EXIT, 17 | dongleId, 18 | }); 19 | 20 | if (shouldPathChange) { 21 | if (clips.state !== 'list' && clips.list) { 22 | dispatch(push(`/${dongleId}/clips`)); 23 | } else { 24 | dispatch(push(urlForState(dongleId, zoom?.start, zoom?.end, false))); 25 | } 26 | } 27 | 28 | dispatch(checkRoutesData()); 29 | }; 30 | } 31 | 32 | export function fetchClipsList(dongleId) { 33 | return async (dispatch, getState) => { 34 | const { globalDongleId } = getState(); 35 | try { 36 | if (globalDongleId !== dongleId) { 37 | dispatch(selectDevice(dongleId, false)); 38 | } 39 | 40 | dispatch({ 41 | type: Types.ACTION_CLIPS_LIST, 42 | dongleId, 43 | list: null, 44 | }); 45 | dispatch(push(`/${dongleId}/clips`)); 46 | 47 | const resp = await Clips.clipsList(dongleId); 48 | 49 | dispatch({ 50 | type: Types.ACTION_CLIPS_LIST, 51 | dongleId, 52 | list: resp, 53 | }); 54 | } catch (err) { 55 | console.error(err); 56 | Sentry.captureException(err, { fingerprint: 'clips_fetch_list' }); 57 | } 58 | }; 59 | } 60 | 61 | export function clipsInit() { 62 | return (dispatch, getState) => { 63 | const { dongleId, currentRoute } = getState(); 64 | dispatch({ 65 | type: Types.ACTION_CLIPS_INIT, 66 | dongleId, 67 | route: currentRoute.fullname, 68 | }); 69 | }; 70 | } 71 | 72 | export function clipsCreate(clip_id, video_type, title, isPublic) { 73 | return (dispatch, getState) => { 74 | const { dongleId, loop, currentRoute } = getState(); 75 | dispatch({ 76 | type: Types.ACTION_CLIPS_CREATE, 77 | dongleId, 78 | clip_id, 79 | start_time: loop.startTime, 80 | end_time: loop.startTime + loop.duration, 81 | video_type, 82 | title, 83 | is_public: isPublic, 84 | route: currentRoute.fullname, 85 | }); 86 | 87 | dispatch(push(`/${dongleId}/clips/${clip_id}`)); 88 | }; 89 | } 90 | 91 | export function navToClips(clip_id, state) { 92 | return async (dispatch, getState) => { 93 | const { dongleId } = getState(); 94 | if (state === 'done') { 95 | dispatch({ 96 | type: Types.ACTION_CLIPS_DONE, 97 | dongleId, 98 | clip_id, 99 | }); 100 | } else if (state === 'upload') { 101 | dispatch({ 102 | type: Types.ACTION_CLIPS_CREATE, 103 | dongleId, 104 | clip_id, 105 | }); 106 | } 107 | dispatch(push(`/${dongleId}/clips/${clip_id}`)); 108 | dispatch(fetchClipsDetails(clip_id)); 109 | }; 110 | } 111 | 112 | export function fetchClipsDetails(clip_id) { 113 | return async (dispatch, getState) => { 114 | const { dongleId, clips } = getState(); 115 | try { 116 | if (!clips) { 117 | dispatch({ 118 | type: Types.ACTION_CLIPS_LOADING, 119 | dongleId, 120 | clip_id, 121 | }); 122 | } 123 | 124 | const resp = await Clips.clipsDetails(dongleId, clip_id); 125 | 126 | if (resp.status === 'pending') { 127 | dispatch({ 128 | type: Types.ACTION_CLIPS_CREATE, 129 | dongleId, 130 | clip_id, 131 | start_time: resp.start_time, 132 | end_time: resp.end_time, 133 | video_type: resp.video_type, 134 | title: resp.title, 135 | is_public: resp.is_public, 136 | route: resp.route_name, 137 | pending_status: resp.pending_status, 138 | pending_progress: resp.pending_progress, 139 | }); 140 | } else if (resp.status === 'done') { 141 | dispatch({ 142 | type: Types.ACTION_CLIPS_DONE, 143 | dongleId, 144 | clip_id, 145 | start_time: resp.start_time, 146 | end_time: resp.end_time, 147 | video_type: resp.video_type, 148 | title: resp.title, 149 | is_public: resp.is_public, 150 | route: resp.route_name, 151 | url: resp.url, 152 | thumbnail: resp.thumbnail, 153 | }); 154 | } else if (resp.status === 'failed') { 155 | dispatch(fetchClipsList(dongleId)); 156 | } 157 | } catch (err) { 158 | if (err.resp && err.resp.status === 404) { 159 | if (!MyCommaAuth.isAuthenticated()) { 160 | window.location = `/?r=${encodeURI(window.location.pathname)}`; // redirect to login 161 | } else { 162 | dispatch({ 163 | type: Types.ACTION_CLIPS_ERROR, 164 | dongleId, 165 | clip_id, 166 | error: 'clip_doesnt_exist', 167 | }); 168 | } 169 | return; 170 | } 171 | 172 | console.error(err); 173 | Sentry.captureException(err, { fingerprint: 'clips_fetch_details' }); 174 | } 175 | }; 176 | } 177 | 178 | export function clipsUpdateIsPublic(clip_id, is_public) { 179 | return (dispatch, getState) => { 180 | const { dongleId } = getState(); 181 | dispatch({ 182 | type: Types.ACTION_CLIPS_UPDATE, 183 | dongleId, 184 | clip_id, 185 | is_public, 186 | }); 187 | }; 188 | } 189 | 190 | export function clipsDelete(clip_id) { 191 | return (dispatch, getState) => { 192 | const { dongleId } = getState(); 193 | dispatch({ 194 | type: Types.ACTION_CLIPS_DELETE, 195 | dongleId, 196 | clip_id, 197 | }); 198 | dispatch(clipsExit()); 199 | }; 200 | } 201 | -------------------------------------------------------------------------------- /src/components/anonymous.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Obstruction from 'obstruction'; 4 | import window from 'global/window'; 5 | import PropTypes from 'prop-types'; 6 | import qs from 'query-string'; 7 | 8 | import { withStyles } from '@material-ui/core/styles'; 9 | import Typography from '@material-ui/core/Typography'; 10 | 11 | import { config as AuthConfig } from '@commaai/my-comma-auth'; 12 | 13 | import { AuthAppleIcon, AuthGithubIcon, AuthGoogleIcon } from '../icons'; 14 | import Colors from '../colors'; 15 | 16 | const demoDevices = require('../demo/devices.json'); 17 | 18 | const styles = () => ({ 19 | baseContainer: { 20 | width: '100%', 21 | height: '100vh', 22 | display: 'flex', 23 | flexDirection: 'column', 24 | alignItems: 'center', 25 | justifyContent: 'center', 26 | }, 27 | base: { 28 | overflowY: 'auto', 29 | padding: 20, 30 | display: 'flex', 31 | flexDirection: 'column', 32 | alignItems: 'center', 33 | width: '100%', 34 | }, 35 | logoImg: { 36 | height: 45, 37 | width: 'auto', 38 | }, 39 | logoContainer: { 40 | width: 84, 41 | height: 84, 42 | backgroundColor: Colors.grey900, 43 | borderRadius: 17, 44 | display: 'flex', 45 | alignItems: 'center', 46 | justifyContent: 'center', 47 | flexShrink: 0, 48 | }, 49 | logoSpacer: { 50 | height: 60, 51 | flexShrink: 2, 52 | }, 53 | logoText: { 54 | fontSize: 36, 55 | fontWeight: 800, 56 | textAlign: 'center', 57 | }, 58 | tagline: { 59 | width: 380, 60 | maxWidth: '90%', 61 | textAlign: 'center', 62 | margin: '10px 0 30px', 63 | fontSize: '18px', 64 | }, 65 | logInButton: { 66 | cursor: 'pointer', 67 | alignItems: 'center', 68 | background: '#ffffff', 69 | display: 'flex', 70 | borderRadius: 80, 71 | fontSize: 21, 72 | height: 80, 73 | justifyContent: 'center', 74 | textDecoration: 'none', 75 | width: 400, 76 | maxWidth: '90%', 77 | marginBottom: 10, 78 | '&:hover': { 79 | background: '#eee', 80 | }, 81 | }, 82 | buttonText: { 83 | fontSize: 18, 84 | width: 190, 85 | textAlign: 'center', 86 | color: 'black', 87 | fontWeight: 600, 88 | }, 89 | buttonImage: { 90 | height: 40, 91 | }, 92 | demoLink: { 93 | textDecoration: 'none', 94 | justifyContent: 'center', 95 | height: '40px', 96 | display: 'flex', 97 | }, 98 | demoLinkText: { 99 | textDecoration: 'underline', 100 | fontSize: '16px', 101 | }, 102 | }); 103 | 104 | class AnonymousLanding extends Component { 105 | componentWillMount() { 106 | if (typeof window.sessionStorage !== 'undefined' && sessionStorage.getItem('redirectURL') === null) { 107 | sessionStorage.setItem('redirectURL', this.props.pathname); 108 | } 109 | } 110 | 111 | componentDidMount() { 112 | const q = new URLSearchParams(window.location.search); 113 | if (q.has('r')) { 114 | sessionStorage.setItem('redirectURL', q.get('r')); 115 | } 116 | 117 | const script = document.createElement('script'); 118 | document.body.appendChild(script); 119 | script.onload = () => { 120 | AppleID.auth.init({ 121 | clientId: AuthConfig.APPLE_CLIENT_ID, 122 | scope: AuthConfig.APPLE_SCOPES, 123 | redirectURI: AuthConfig.APPLE_REDIRECT_URI, 124 | state: AuthConfig.APPLE_STATE, 125 | }); 126 | }; 127 | script.src = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js'; 128 | script.async = true; 129 | document.addEventListener('AppleIDSignInOnSuccess', (data) => { 130 | const { code, state } = data.detail.authorization; 131 | window.location = [AuthConfig.APPLE_REDIRECT_PATH, qs.stringify({ code, state })].join('?'); 132 | }); 133 | document.addEventListener('AppleIDSignInOnFailure', console.log); 134 | } 135 | 136 | render() { 137 | const { classes } = this.props; 138 | 139 | return ( 140 |
141 |
142 |
143 | comma 144 |
145 |
 
146 | comma connect 147 | 148 | Manage your comma device, view your drives, and comma prime features 149 | 150 | 151 | 152 | Sign in with Google 153 | 154 | AppleID.auth.signIn() } className={classes.logInButton}> 155 | 156 | Sign in with Apple 157 | 158 | 159 | 160 | Sign in with GitHub 161 | 162 | 163 | Try the demo 164 | 165 |
166 |
167 | ); 168 | } 169 | } 170 | 171 | AnonymousLanding.propTypes = { 172 | pathname: PropTypes.string.isRequired, 173 | classes: PropTypes.object.isRequired, 174 | }; 175 | 176 | const stateToProps = Obstruction({ 177 | pathname: 'router.location.pathname', 178 | }); 179 | 180 | export default connect(stateToProps)(withStyles(styles)(AnonymousLanding)); 181 | -------------------------------------------------------------------------------- /src/components/Dashboard/DriveListItem.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import fecha from 'fecha'; 4 | 5 | import { withStyles, Grid, Typography } from '@material-ui/core'; 6 | 7 | import { selectRange } from '../../actions'; 8 | import { fetchEvents, fetchLocations } from '../../actions/cached'; 9 | import Colors from '../../colors'; 10 | import { useWindowWidth } from '../../hooks/window'; 11 | import { RightArrow } from '../../icons'; 12 | import { formatDriveDuration, filterRegularClick } from '../../utils'; 13 | import { isMetric, KM_PER_MI } from '../../utils/conversions'; 14 | import Timeline from '../Timeline'; 15 | 16 | const styles = () => ({ 17 | drive: { 18 | background: 'linear-gradient(to bottom, #30373B 0%, #1D2225 100%)', 19 | borderTop: '1px solid rgba(255, 255, 255, .05)', 20 | borderRadius: 8, 21 | display: 'flex', 22 | flexDirection: 'column', 23 | marginBottom: 12, 24 | overflow: 'hidden', 25 | padding: 0, 26 | transition: 'background .2s', 27 | textDecoration: 'none', 28 | '&:hover': {}, 29 | }, 30 | driveHeader: { 31 | alignItems: 'center', 32 | }, 33 | driveHeaderIntro: { 34 | display: 'flex', 35 | }, 36 | driveGridItem: { 37 | flexGrow: 1, 38 | }, 39 | driveGridItemRightAlign: { 40 | textAlign: 'right', 41 | }, 42 | driveHeaderIntroSmall: { 43 | justifyContent: 'center', 44 | }, 45 | driveArrow: { 46 | color: Colors.grey500, 47 | height: '100%', 48 | marginLeft: '25%', 49 | width: 32, 50 | }, 51 | firstLine: { 52 | fontWeight: 600, 53 | }, 54 | }); 55 | 56 | const DriveListItem = (props) => { 57 | const el = useRef(); 58 | const [isVisible, setVisible] = useState(false); 59 | const windowWidth = useWindowWidth(); 60 | const { classes, dispatch, drive } = props; 61 | 62 | useEffect(() => { 63 | const onScroll = () => { 64 | if (!isVisible && el.current && window && (!window.visualViewport 65 | || window.visualViewport.height >= el.current.getBoundingClientRect().y - 300) 66 | ) { 67 | setVisible(true); 68 | dispatch(fetchEvents(drive)); 69 | dispatch(fetchLocations(drive)); 70 | 71 | window.removeEventListener('scroll', onScroll); 72 | window.removeEventListener('resize', onScroll); 73 | } 74 | }; 75 | 76 | window.addEventListener('scroll', onScroll); 77 | window.addEventListener('resize', onScroll); 78 | onScroll(); 79 | 80 | return () => { 81 | window.removeEventListener('scroll', onScroll); 82 | window.removeEventListener('resize', onScroll); 83 | }; 84 | }, [drive, dispatch, isVisible, el]); 85 | 86 | const onClick = filterRegularClick( 87 | () => dispatch(selectRange(drive.start_time_utc_millis, drive.end_time_utc_millis)), 88 | ); 89 | 90 | const small = windowWidth < 580; 91 | const startTime = fecha.format(new Date(drive.start_time_utc_millis), 'HH:mm'); 92 | const startDate = fecha.format(new Date(drive.start_time_utc_millis), small ? 'ddd, MMM D' : 'dddd, MMM D'); 93 | const endTime = fecha.format(new Date(drive.end_time_utc_millis), 'HH:mm'); 94 | const duration = formatDriveDuration(drive.duration); 95 | 96 | const distance = isMetric() 97 | ? `${+(drive.length * KM_PER_MI).toFixed(1)} km` 98 | : `${+drive.length.toFixed(1)} mi`; 99 | 100 | /* eslint-disable key-spacing, no-multi-spaces */ 101 | const gridStyle = small ? { 102 | date: { order: 1, maxWidth: '72%', flexBasis: '72%', marginBottom: 12 }, 103 | dur: { order: 2, maxWidth: '28%', flexBasis: '28%', marginBottom: 12 }, 104 | origin: { order: 3, maxWidth: '50%', flexBasis: '50%' }, 105 | dest: { order: 4, maxWidth: '50%', flexBasis: '50%' }, 106 | } : { 107 | date: { order: 1, maxWidth: '28%', flexBasis: '26%' }, 108 | dur: { order: 2, maxWidth: '14%', flexBasis: '14%' }, 109 | origin: { order: 3, maxWidth: '26%', flexBasis: '22%' }, 110 | dest: { order: 4, maxWidth: '26%', flexBasis: '22%' }, 111 | arrow: { order: 5, maxWidth: '6%', flexBasis: '6%' }, 112 | }; 113 | /* eslint-enable key-spacing, no-multi-spaces */ 114 | 115 | return ( 116 | 123 |
124 | 125 |
126 | {startDate} 127 | {`${startTime} to ${endTime}`} 128 |
129 |
130 | {duration} 131 | {distance} 132 |
133 |
134 | {drive.startLocation?.place} 135 | {drive.startLocation?.details} 136 |
137 |
138 | {drive.endLocation?.place} 139 | {drive.endLocation?.details} 140 |
141 | {!small && ( 142 |
143 | 144 |
145 | )} 146 |
147 |
148 | 152 |
153 | ); 154 | }; 155 | 156 | export default connect(() => ({}))(withStyles(styles)(DriveListItem)); 157 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | import fecha from 'fecha'; 3 | import decodeJwt, { InvalidTokenError } from 'jwt-decode'; 4 | 5 | import { currentOffset } from '../timeline'; 6 | 7 | export const emptyDevice = { 8 | alias: 'Shared device', 9 | create_time: 1513041169, 10 | device_type: 'unknown', 11 | dongle_id: undefined, 12 | imei: '000000000000000', 13 | is_owner: false, 14 | shared: true, 15 | serial: '00000000', 16 | }; 17 | 18 | export function asyncSleep(ms) { 19 | return new Promise((resolve) => { 20 | setTimeout(resolve, ms); 21 | }); 22 | } 23 | 24 | export function toBool(item) { 25 | switch (typeof item) { 26 | case 'boolean': 27 | return item; 28 | case 'number': 29 | return item < 0 || item > 0; 30 | case 'object': 31 | return !!item; 32 | case 'string': 33 | return ['true', '1'].indexOf(item.toLowerCase()) >= 0; 34 | case 'undefined': 35 | return false; 36 | default: 37 | return true; 38 | } 39 | } 40 | 41 | export function filterEvent(event) { 42 | return (event.type === 'disengage' || event.type === 'disengage_steer'); 43 | } 44 | 45 | export function formatDriveDuration(duration) { 46 | const hours = Math.floor((duration / (1000 * 60 * 60))) % 24; 47 | const minutes = Math.floor((duration / (1000 * 60))) % 60; 48 | return `${hours > 0 ? `${hours} hr ` : ''}${minutes} min`; 49 | } 50 | 51 | export function timeFromNow(ts) { 52 | const dt = (Date.now() - ts) / 1000; 53 | if (dt > 3600 * 24 * 30) { 54 | return fecha.format(ts, 'MMM Do YYYY'); 55 | } 56 | if (dt > 3600 * 24) { 57 | const days = Math.floor(dt / (3600 * 24)); 58 | const plural = days === 1 ? 'day' : 'days'; 59 | return `${days} ${plural} ago`; 60 | } 61 | if (dt > 3600) { 62 | const hours = Math.floor(dt / 3600); 63 | const plural = hours === 1 ? 'hour' : 'hours'; 64 | return `${hours} ${plural} ago`; 65 | } 66 | if (dt > 60) { 67 | const mins = Math.floor(dt / 60); 68 | const plural = mins === 1 ? 'minute' : 'minutes'; 69 | return `${mins} ${plural} ago`; 70 | } 71 | return 'just now'; 72 | } 73 | 74 | export function deviceTypePretty(deviceType) { 75 | switch (deviceType) { 76 | case 'neo': 77 | return 'EON'; 78 | case 'freon': 79 | return 'freon'; 80 | case 'unknown': 81 | return 'unknown'; 82 | default: 83 | return `comma ${deviceType}`; 84 | } 85 | } 86 | 87 | export function deviceIsOnline(device) { 88 | if (!device || !device.last_athena_ping) { 89 | return false; 90 | } 91 | return device.last_athena_ping >= (device.fetched_at - 120); 92 | } 93 | 94 | export function deviceOnCellular(device) { 95 | if (!device) { 96 | return null; 97 | } 98 | return device.network_metered; 99 | } 100 | 101 | export function isTouchDevice() { 102 | return (('ontouchstart' in window) 103 | || (navigator.maxTouchPoints > 0) 104 | || (navigator.msMaxTouchPoints > 0)); 105 | } 106 | 107 | export function pairErrorToMessage(err, sentryFingerprint) { 108 | let msg; 109 | if (err.message.indexOf('400') === 0) { 110 | msg = 'invalid request'; 111 | } else if (err.message.indexOf('401') === 0) { 112 | msg = 'could not decode token'; 113 | } else if (err.message.indexOf('403') === 0) { 114 | msg = 'device paired with different owner'; 115 | } else if (err.message.indexOf('404') === 0) { 116 | msg = 'tried to pair invalid device'; 117 | } else if (err.message.indexOf('417') === 0) { 118 | msg = 'pair token not true'; 119 | } else { 120 | msg = 'unable to pair'; 121 | console.error(err); 122 | if (sentryFingerprint) { 123 | Sentry.captureException(err, { fingerprint: sentryFingerprint }); 124 | } 125 | } 126 | return msg; 127 | } 128 | 129 | export function verifyPairToken(pairToken, fromUrl, sentryFingerprint) { 130 | let decoded; 131 | try { 132 | decoded = decodeJwt(pairToken); 133 | } catch (err) { 134 | // https://github.com/auth0/jwt-decode#getting-started 135 | if (err instanceof InvalidTokenError) { 136 | throw new Error('invalid QR code, could not decode pair token'); 137 | } else { 138 | // unkown error, let server verify token 139 | Sentry.captureException(err, { fingerprint: sentryFingerprint }); 140 | return; 141 | } 142 | } 143 | 144 | if (!decoded) { 145 | throw new Error('could not decode pair token'); 146 | } 147 | 148 | if (!decoded.identity) { 149 | let msg = 'could not get identity from payload'; 150 | if (!fromUrl) { 151 | msg += ', make sure you are using openpilot 0.8.3 or newer'; 152 | } 153 | throw new Error(msg); 154 | } 155 | } 156 | 157 | export function filterRegularClick(func) { 158 | return (ev) => { 159 | if (ev.button === 0 && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.shiftKey) { 160 | ev.preventDefault(); 161 | func(); 162 | } 163 | }; 164 | } 165 | 166 | export function deviceVersionAtLeast(device, version) { 167 | if (!device || !device.openpilot_version) { 168 | return false; 169 | } 170 | 171 | const deviceParts = device.openpilot_version.split('.'); 172 | const versionParts = version.split('.'); 173 | try { 174 | for (let i = 0; i < versionParts.length; i++) { 175 | const devicePart = deviceParts[i] ? parseInt(deviceParts[i], 10) : 0; 176 | const versionPart = parseInt(versionParts[i], 10); 177 | if (!Number.isInteger(devicePart) || devicePart < versionPart) { 178 | return false; 179 | } 180 | if (devicePart > versionPart) { 181 | return true; 182 | } 183 | } 184 | return true; 185 | } catch (err) { 186 | Sentry.captureException(err); 187 | return false; 188 | } 189 | } 190 | 191 | export function getDeviceFromState(state, dongleId) { 192 | if (state.device.dongle_id === dongleId) { 193 | return state.device; 194 | } 195 | return state.devices.find((d) => d.dongle_id === dongleId) || null; 196 | } 197 | 198 | export function getSegmentNumber(route, offset) { 199 | if (!route) { 200 | return null; 201 | } 202 | if (offset === undefined) { 203 | offset = currentOffset(); 204 | } 205 | for (let i = 0; i < route.segment_offsets.length; i++) { 206 | if (offset >= route.segment_offsets[i] 207 | && (i === route.segment_offsets.length - 1 || offset < route.segment_offsets[i + 1])) { 208 | return route.segment_numbers[i]; 209 | } 210 | } 211 | return null; 212 | } 213 | -------------------------------------------------------------------------------- /src/serviceWorkerRegistration.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://cra.link/PWA 12 | 13 | // eslint-disable-next-line no-var 14 | var refreshing; 15 | 16 | const isLocalhost = Boolean( 17 | window.location.hostname === 'localhost' 18 | // [::1] is the IPv6 localhost address. 19 | || window.location.hostname === '[::1]' 20 | // 127.0.0.0/8 are considered localhost for IPv4. 21 | || window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/), 22 | ); 23 | 24 | export function register(config) { 25 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 26 | // The URL constructor is available in all browsers that support SW. 27 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 28 | if (publicUrl.origin !== window.location.origin) { 29 | // Our service worker won't work if PUBLIC_URL is on a different origin 30 | // from what our page is served on. This might happen if a CDN is used to 31 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 32 | return; 33 | } 34 | 35 | window.addEventListener('load', () => { 36 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 37 | 38 | if (isLocalhost) { 39 | // This is running on localhost. Let's check if a service worker still exists or not. 40 | checkValidServiceWorker(swUrl, config); 41 | 42 | // Add some additional logging to localhost, pointing developers to the 43 | // service worker/PWA documentation. 44 | navigator.serviceWorker.ready.then(() => { 45 | console.log( 46 | 'This web app is being served cache-first by a service ' 47 | + 'worker. To learn more, visit https://cra.link/PWA', 48 | ); 49 | }); 50 | } else { 51 | // Is not localhost. Just register service worker 52 | registerValidSW(swUrl, config); 53 | } 54 | 55 | navigator.serviceWorker.addEventListener('controllerchange', () => { 56 | console.log('[ServiceWorkerRegistration] Controller changed'); 57 | if (refreshing) return; 58 | refreshing = true; 59 | window.location.reload(); 60 | }); 61 | }); 62 | } else { 63 | console.log('[ServiceWorkerRegistration] Service worker is not supported'); 64 | } 65 | } 66 | 67 | function registerValidSW(swUrl, config) { 68 | navigator.serviceWorker 69 | .register(swUrl) 70 | .then((registration) => { 71 | const interval = setInterval(() => { 72 | if (!registration) { 73 | clearInterval(interval); 74 | return; 75 | } 76 | 77 | console.log('[ServiceWorkerRegistration] Checking for updates...'); 78 | registration.update(); 79 | }, 10 * 60 * 1000); 80 | 81 | if (registration.waiting) { 82 | console.log('[ServiceWorkerRegistration] Update waiting'); 83 | 84 | // Execute callback 85 | if (config && config.onUpdate) { 86 | config.onUpdate(registration); 87 | } 88 | } 89 | 90 | registration.onupdatefound = () => { 91 | const installingWorker = registration.installing; 92 | if (installingWorker == null) { 93 | return; 94 | } 95 | 96 | console.debug('[ServiceWorkerRegistration] Update found...'); 97 | installingWorker.onstatechange = () => { 98 | if (installingWorker.state === 'installed') { 99 | if (navigator.serviceWorker.controller) { 100 | // At this point, the updated precached content has been fetched, 101 | // but the previous service worker will still serve the older 102 | // content until all client tabs are closed. 103 | console.debug('[ServiceWorkerRegistration] Update ready'); 104 | 105 | // Execute callback 106 | if (config && config.onUpdate) { 107 | config.onUpdate(registration); 108 | } 109 | } else { 110 | // At this point, everything has been precached. 111 | // It's the perfect time to display a 112 | // "Content is cached for offline use." message. 113 | console.log('[ServiceWorkerRegistration] Content is cached for offline use'); 114 | 115 | // Execute callback 116 | if (config && config.onSuccess) { 117 | config.onSuccess(registration); 118 | } 119 | } 120 | } 121 | }; 122 | }; 123 | }) 124 | .catch((error) => { 125 | console.error('Error during service worker registration:', error); 126 | }); 127 | } 128 | 129 | function checkValidServiceWorker(swUrl, config) { 130 | // Check if the service worker can be found. If it can't reload the page. 131 | fetch(swUrl, { 132 | headers: { 'Service-Worker': 'script' }, 133 | }) 134 | .then((response) => { 135 | // Ensure service worker exists, and that we really are getting a JS file. 136 | const contentType = response.headers.get('content-type'); 137 | if ( 138 | response.status === 404 139 | || (contentType != null && contentType.indexOf('javascript') === -1) 140 | ) { 141 | // No service worker found. Probably a different app. Reload the page. 142 | navigator.serviceWorker.ready.then((registration) => { 143 | registration.unregister().then(() => { 144 | window.location.reload(); 145 | }); 146 | }); 147 | } else { 148 | // Service worker found. Proceed as normal. 149 | registerValidSW(swUrl, config); 150 | } 151 | }) 152 | .catch(() => { 153 | console.log('[ServiceWorkerRegistration] No internet connection found. App is running in offline mode.'); 154 | }); 155 | } 156 | 157 | export function unregister() { 158 | if ('serviceWorker' in navigator) { 159 | navigator.serviceWorker.ready 160 | .then((registration) => { 161 | registration.unregister(); 162 | }) 163 | .catch((error) => { 164 | console.error(error.message); 165 | }); 166 | } 167 | } 168 | --------------------------------------------------------------------------------