diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d0a57d11f..0f7d2c219 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,7 +12,7 @@ on: jobs: test: runs-on: ubuntu-latest - timeout-minutes: 1 + timeout-minutes: 2 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 @@ -60,14 +60,15 @@ jobs: name: build-artifacts-${{ github.run_id }} - name: Deploy to Cloudflare Pages - if: github.ref == 'refs/heads/master' && github.repository == 'commaai/new-connect' + if: github.ref == 'refs/heads/master' && vars.CF_PAGES_PROJECT != '' uses: cloudflare/wrangler-action@v3 with: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} - command: pages deploy dist --project-name=connect --branch=new-connect --commit-dirty=true + command: pages deploy dist --project-name=${{ vars.CF_PAGES_PROJECT }} --branch=master --commit-dirty=true docker: + if: vars.DOCKER_IMAGE != '' runs-on: ubuntu-latest timeout-minutes: 1 permissions: @@ -79,7 +80,7 @@ jobs: uses: docker/setup-buildx-action@v2 - uses: docker/login-action@v3 - if: github.ref == 'refs/heads/master' && github.repository == 'commaai/new-connect' + if: github.ref == 'refs/heads/master' with: registry: ghcr.io username: ${{ github.actor }} @@ -88,7 +89,7 @@ jobs: - id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/commaai/connect2 # TODO: switch to 'connect' after launch + images: ${{ vars.DOCKER_IMAGE }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch @@ -111,6 +112,6 @@ jobs: SENTRY_RELEASE=${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} builder: ${{ steps.buildx.outputs.name }} context: . - push: ${{ github.ref == 'refs/heads/master' && github.repository == 'commaai/new-connect' }} + push: ${{ github.ref == 'refs/heads/master' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/diff.yaml b/.github/workflows/diff.yaml index 6a69e14b4..afebd06bf 100644 --- a/.github/workflows/diff.yaml +++ b/.github/workflows/diff.yaml @@ -10,40 +10,12 @@ concurrency: cancel-in-progress: true jobs: - check-branch: - name: Check PR branch status - runs-on: ubuntu-latest - outputs: - outdated: ${{ steps.status.outputs.outdated }} - steps: - - uses: actions/checkout@v4 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - name: Check whether branch is up-to-date - id: status - run: | - git remote add commaai https://github.com/commaai/new-connect.git - git fetch commaai master - echo "${{ github.event.pull_request.head.sha }}" - git rev-list --left-right --count commaai/master...${{ github.event.pull_request.head.sha }} | awk '{print "Behind "$1" - Ahead "$2""}' - count=$(git rev-list --left-right --count commaai/master...${{ github.event.pull_request.head.sha }} | awk '{print $1}') - if [ $count -gt 0 ]; then - echo "Current branch is behind commaai master branch!" - echo "outdated=true" >> "$GITHUB_OUTPUT" - else - echo "outdated=false" >> "$GITHUB_OUTPUT" - fi - lines: name: Lint count diff permissions: contents: read pull-requests: write runs-on: ubuntu-latest - needs: check-branch - if: needs.check-branch.outputs.outdated == 'false' timeout-minutes: 2 steps: - name: Checkout code from PR branch @@ -65,19 +37,3 @@ jobs: file-path: ./diff.txt comment-tag: diff github-token: ${{ secrets.GITHUB_TOKEN }} - - rebase: - name: Rebase comment - permissions: - pull-requests: write - runs-on: ubuntu-latest - needs: check-branch - if: needs.check-branch.outputs.outdated == 'true' - timeout-minutes: 1 - steps: - - name: Comment rebase - uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b - with: - message: This branch is behind commaai/master. The line count diff bot is disabled. - comment-tag: diff - github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index 12cb5f9e9..6fd894979 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest outputs: number: ${{ steps.pr.outputs.number }} - if: github.repository == 'commaai/new-connect' && github.event.workflow_run.event == 'pull_request' + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' steps: # use `gr pr view` to get the PR number # https://github.com/orgs/community/discussions/25220#discussioncomment-11285971 @@ -48,6 +48,7 @@ jobs: preview: name: Deploy preview needs: pr + if: needs.pr.outputs.number != '' && vars.CF_PAGES_PROJECT != '' && vars.PREVIEW_BASE_DOMAIN != '' outputs: check_id: ${{ steps.check.outputs.result }} runs-on: ubuntu-latest @@ -66,7 +67,7 @@ jobs: title: 'Preview deployment', summary: 'In Progress', }, - owner: 'commaai', + owner: '${{ github.repository_owner }}', repo: '${{ github.event.repository.name }}', }) return response.data.id @@ -87,7 +88,7 @@ jobs: with: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} - command: pages deploy dist --project-name=connect --branch=${{ needs.pr.outputs.number }} --commit-dirty=true + command: pages deploy dist --project-name=${{ vars.CF_PAGES_PROJECT }} --branch=pr-${{ needs.pr.outputs.number }} --commit-dirty=true - name: Comment URL on PR uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b @@ -95,69 +96,11 @@ jobs: message: | - # deployed preview: https://${{ needs.pr.outputs.number }}.connect-d5y.pages.dev + # preview ready - Welcome to connect! Make sure to: - * read the [contributing guidelines](https://github.com/commaai/connect?tab=readme-ov-file#contributing) - * mark your PR as a draft until it's ready to review - * post the preview on [Discord](https://discord.comma.ai); feedback from users will speedup the PR review - comment-tag: run_id - pr-number: ${{ needs.pr.outputs.number }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Checkout ci-artifacts - uses: actions/checkout@v4 - with: - repository: commaai/ci-artifacts - ssh-key: ${{ secrets.CI_ARTIFACTS_DEPLOY_KEY }} - path: ${{ github.workspace }}/ci-artifacts - ref: master - - - name: take screenshots - run: bun src/ci/screenshots.ts https://${{ needs.pr.outputs.number }}.connect-d5y.pages.dev ${{ github.workspace }}/ci-artifacts - - - name: Push Screenshots - working-directory: ${{ github.workspace }}/ci-artifacts - run: | - git checkout -b connect/pr-${{ needs.pr.outputs.number }} - git config user.name "GitHub Actions Bot" - git config user.email "<>" - git add . - git commit -m "screenshots for PR #${{ needs.pr.outputs.number }}" - git push origin connect/pr-${{ needs.pr.outputs.number }} --force - - - name: Add screenshots to comment on PR - uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b - with: - message: | - - - # deployed preview: https://${{ needs.pr.outputs.number }}.connect-d5y.pages.dev - - Welcome to connect! Make sure to: - * read the [contributing guidelines](https://github.com/commaai/connect?tab=readme-ov-file#contributing) - * mark your PR as a draft until it's ready to review - * post the preview on [Discord](https://discord.comma.ai); feedback from users will speedup the PR review - - ### Mobile - - - - - - - -
+ https://pr-${{ needs.pr.outputs.number }}.${{ vars.PREVIEW_BASE_DOMAIN }} - ### Desktop - - - - - - - -
+ Check the login flow, demo mode, route list, route detail, and settings before merging. comment-tag: run_id pr-number: ${{ needs.pr.outputs.number }} github-token: ${{ secrets.GITHUB_TOKEN }} @@ -165,7 +108,7 @@ jobs: update_pr_check: name: Update PR check needs: preview - if: always() && github.repository == 'commaai/connect' && github.event.workflow_run.event == 'pull_request' + if: always() && needs.preview.result != 'skipped' && github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-latest steps: - name: Update PR check @@ -182,6 +125,6 @@ jobs: title: 'Preview deployment', summary: 'Result: ${{ needs.preview.result }}', }, - owner: 'commaai', + owner: '${{ github.repository_owner }}', repo: '${{ github.event.repository.name }}', }) diff --git a/README.md b/README.md index 4a90cbb3c..a773a4e3f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ -# connect +# new connect -connect is the web and mobile experience for [openpilot](https://github.com/commaai/openpilot). +new connect is a fork-owned web app for checking devices, trips, uploads, and remote actions from phone or desktop. -Try it out at https://new-connect.connect-d5y.pages.dev. - -This is a rewrite of [comma connect](https://github.com/commaai/connect-pwa-archive). +This fork currently keeps compatibility with the existing backend APIs while taking its own product and workflow direction. ## Development @@ -16,40 +14,35 @@ curl -fsSL https://bun.sh/install | bash source ~/.bashrc # or source ~/.zshrc cd ~ -git clone https://github.com/commaai/new-connect.git +git clone https://github.com/Mahdi451/new-connect.git -cd connect +cd new-connect bun install # sets up pre-commit hook bun dev ``` -## Contributing - -Join the `#dev-connect-web` channel on our [Discord](https://discord.comma.ai). +The app has a demo mode, so you can work on the UI without pairing a live device. -connect has a demo mode, so no special comma device is needed to develop connect. - -A few constraints to keep connect light and the dev environment fun: +A few constraints keep the app light and the dev environment fast: * 5k line limit * 500KB bundle size limit * 1m timeout for all CI -References: -* [API docs](https://api.comma.ai) -* [openpilot docs](https://docs.comma.ai) -* [Discord](https://discord.comma.ai) -* [Bounties](https://comma.ai/bounties) +## Preview Workflow + +Pull requests into `master` are expected to produce a deploy preview when the repo is configured with: -## Roadmap +* `CLOUDFLARE_ACCOUNT_ID` +* `CLOUDFLARE_PAGES_TOKEN` +* `CF_PAGES_PROJECT` +* `PREVIEW_BASE_DOMAIN` -The first goal is to replace current connect and get this shipped to https://connect.comma.ai. +The preview workflow comments a URL back onto the PR after a successful build. Screenshots are intentionally disabled until the fork has its own artifact publishing setup. -[This milestone](https://github.com/commaai/connect/milestone/1) tracks that progress. Most of the issues there are [paid bounties](https://comma.ai/bounties). +## Direction -Once we've shipped v1, next up will be: -* [Sentry mode](https://www.youtube.com/watch?v=laO0RzsDzfU) -* SSH console for openpilot developers -* Replace snapshot with a live stream -* openpilot clips, like this [community tool](https://github.com/nelsonjchen/op-replay-clipper) -* Manage the settings on your comma 3X -* Car mangement: lock doors, EV charge status, etc. +The first phase for this fork is: +* own the repo and deployment workflow +* replace upstream-facing branding and copy +* improve the day-to-day experience for checking devices, trips, uploads, and settings +* selectively pull upstream fixes only when they are useful diff --git a/package.json b/package.json index b492070aa..97876063e 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "connect", + "name": "new-connect", "version": "1.0.0", - "homepage": "https://new-connect.connect-d5y.pages.dev", + "homepage": "https://new-connect.pages.dev", "license": "MIT", - "author": "comma.ai", + "author": "Mahdi451", "repository": { "type": "git", - "url": "https://github.com/commaai/new-connect.git" + "url": "https://github.com/Mahdi451/new-connect.git" }, "scripts": { "build": "bun run --bun vite build", diff --git a/src/App.browser.test.tsx b/src/App.browser.test.tsx index 73261db49..0d7dab65f 100644 --- a/src/App.browser.test.tsx +++ b/src/App.browser.test.tsx @@ -1,5 +1,5 @@ import { beforeAll, beforeEach, describe, expect, test } from 'vitest' -import { configure, render, waitFor } from '@solidjs/testing-library' +import { configure, fireEvent, render, waitFor } from '@solidjs/testing-library' import { setAccessToken, signOut } from '~/api/auth/client' import * as Demo from '~/api/auth/demo' @@ -26,10 +26,14 @@ describe('Demo mode', () => { }) test('View demo route', async () => { - const { findByText, findByTestId } = renderApp(`/${Demo.DONGLE_ID}/${DEMO_LOG_ID}`) + const { findByLabelText, findByText, findByTestId } = renderApp(`/${Demo.DONGLE_ID}/${DEMO_LOG_ID}`) expect(await findByText(DEMO_LOG_ID)).toBeTruthy() const video = (await findByTestId('route-video')) as HTMLVideoElement await waitFor(() => expect(video.src).toBeTruthy()) + expect(video.muted).toBe(true) + await fireEvent.click(await findByLabelText('Unmute')) + expect(video.muted).toBe(false) + expect(await findByLabelText('Mute')).toBeTruthy() }) }) diff --git a/src/components/RouteActions.tsx b/src/components/RouteActions.tsx index 632735914..69ef383f4 100644 --- a/src/components/RouteActions.tsx +++ b/src/components/RouteActions.tsx @@ -58,7 +58,7 @@ const RouteActions: VoidComponent = (props) => { }) const [error, setError] = createSignal(null) - const [copied, setCopied] = createSignal(false) + const [copied, setCopied] = createSignal<'path' | 'link' | null>(null) const toggleRoute = async (property: 'public' | 'preserved') => { setError(null) @@ -71,7 +71,7 @@ const RouteActions: VoidComponent = (props) => { setIsPublic(newValue) } catch (err) { console.error('Failed to update public toggle', err) - setError('Failed to update toggle') + setError('Could not update public access right now.') } } else { const currentValue = isPreserved() @@ -83,22 +83,23 @@ const RouteActions: VoidComponent = (props) => { setIsPreserved(newValue) } catch (err) { console.error('Failed to update preserved toggle', err) - setError('Failed to update toggle') + setError('Could not update saved status right now.') } } } const currentRouteId = () => props.routeName.replace('|', '/') + const currentRouteLink = () => new URL(`/${currentRouteId()}`, window.location.origin).toString() - const copyCurrentRouteId = async () => { - if (!props.routeName || !navigator.clipboard) return + const copyValue = async (value: string, mode: 'path' | 'link') => { + if (!value || !navigator.clipboard) return try { - await navigator.clipboard.writeText(currentRouteId()) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + await navigator.clipboard.writeText(value) + setCopied(mode) + setTimeout(() => setCopied(null), 2000) } catch (err) { - console.error('Failed to copy route ID: ', err) + console.error('Failed to copy route value: ', err) } } @@ -106,25 +107,42 @@ const RouteActions: VoidComponent = (props) => {
+
+ +
+ {isPublic() ? 'Anyone with access can open this route.' : 'Only signed-in viewers can open this route.'} +
+
+
- void toggleRoute('preserved')} /> + void toggleRoute('preserved')} /> void toggleRoute('public')} />
diff --git a/src/components/RouteVideoPlayer.tsx b/src/components/RouteVideoPlayer.tsx index c6e2ce8fc..a4f4d489e 100644 --- a/src/components/RouteVideoPlayer.tsx +++ b/src/components/RouteVideoPlayer.tsx @@ -25,6 +25,7 @@ const RouteVideoPlayer: VoidComponent = (props) => { let controls!: HTMLDivElement const [isPlaying, setIsPlaying] = createSignal(true) + const [isMuted, setIsMuted] = createSignal(true) const [currentTime, setCurrentTime] = createSignal(0) const [duration, setDuration] = createSignal(0) const [videoLoading, setVideoLoading] = createSignal(true) @@ -47,14 +48,28 @@ const RouteVideoPlayer: VoidComponent = (props) => { const startProgressTracking = () => { requestAnimationFrame(updateProgressContinuously) } + const requestPlay = () => { + const playResult = video.play() + if (playResult) { + void playResult.catch((error: unknown) => { + if (error instanceof DOMException && error.name === 'AbortError') return + console.debug('[RouteVideoPlayer] play interrupted', error) + }) + } + } const togglePlayback = () => { if (video.paused) { - void video.play() + requestPlay() } else { video.pause() } } + const toggleMuted = (e: Event) => { + e.preventDefault() + e.stopPropagation() + setIsMuted((muted) => !muted) + } const onClick = (e: Event) => { e.preventDefault() togglePlayback() @@ -83,7 +98,7 @@ const RouteVideoPlayer: VoidComponent = (props) => { const onEnded = () => setIsPlaying(false) const onStalled = () => { if (!isPlaying()) return - void video.play() + requestPlay() } onMount(() => { @@ -91,6 +106,8 @@ const RouteVideoPlayer: VoidComponent = (props) => { video.currentTime = props.selection.startTime } + video.defaultMuted = true + video.muted = true props.ref?.(video) controls.addEventListener('click', onClick) @@ -143,9 +160,17 @@ const RouteVideoPlayer: VoidComponent = (props) => { on(routeName, () => { setVideoLoading(true) setErrorMessage('') + setIsMuted(true) }), ) + createEffect(() => { + if (!video) return + const muted = isMuted() + video.defaultMuted = muted + video.muted = muted + }) + createEffect(() => { const url = streamUrl() const player = hls() @@ -172,7 +197,7 @@ const RouteVideoPlayer: VoidComponent = (props) => { class="size-full object-cover" data-testid="route-video" autoplay - muted + muted={isMuted()} controls={false} playsinline loop @@ -205,6 +230,9 @@ const RouteVideoPlayer: VoidComponent = (props) => {
{formatVideoTime(currentTime())} / {formatVideoTime(duration())}
+ +
+
diff --git a/src/components/material/Icon.tsx b/src/components/material/Icon.tsx index 2ab076f4b..a916ae230 100644 --- a/src/components/material/Icon.tsx +++ b/src/components/material/Icon.tsx @@ -8,7 +8,7 @@ export const Icons = [ 'add', 'arrow_back', 'camera', 'check', 'chevron_right', 'clear', 'close', 'delete', 'description', 'directions_car', 'download', 'error', 'file_copy', 'flag', 'info', 'keyboard_arrow_down', 'keyboard_arrow_up', 'local_fire_department', 'logout', 'menu', 'my_location', 'open_in_new', 'payments', 'person', 'progress_activity', 'satellite_alt', 'search', 'settings', 'upload', 'videocam', 'refresh', - 'login', 'person_off', 'autorenew', 'close_small', 'pause', 'play_arrow', 'clear_all', + 'login', 'person_off', 'autorenew', 'close_small', 'pause', 'play_arrow', 'clear_all', 'volume_off', 'volume_up', ] as const export type IconName = (typeof Icons)[number] diff --git a/src/map/geocode.live.spec.ts b/src/map/geocode.live.spec.ts new file mode 100644 index 000000000..a77499925 --- /dev/null +++ b/src/map/geocode.live.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest' + +import { getFullAddress, getPlaceName, reverseGeocode } from './geocode' + +const describeLive = process.env.RUN_LIVE_MAP_TESTS === '1' ? describe : describe.skip + +describeLive('live geocode smoke tests', () => { + test('reverseGeocode returns a feature for a known coordinate', async () => { + expect(await reverseGeocode([-0.10664, 51.514209])).not.toBeNull() + }, 15000) + + test('getFullAddress returns a non-empty string', async () => { + const fullAddress = await getFullAddress([-0.10664, 51.514209]) + expect(fullAddress).toBeTruthy() + expect(typeof fullAddress).toBe('string') + }, 15000) + + test('getPlaceName returns a non-empty string', async () => { + const placeName = await getPlaceName([-117.168638, 32.723695]) + expect(placeName).toBeTruthy() + expect(typeof placeName).toBe('string') + }, 15000) +}) diff --git a/src/map/geocode.spec.ts b/src/map/geocode.spec.ts index 2241c7c16..1d18cb267 100644 --- a/src/map/geocode.spec.ts +++ b/src/map/geocode.spec.ts @@ -1,31 +1,158 @@ -import { describe, expect, test } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import type { Position } from 'geojson' +import type { ReverseGeocodingFeature, ReverseGeocodingResponse } from './api-types' import { getFullAddress, getPlaceName, reverseGeocode } from './geocode' +import { MAPBOX_TOKEN } from './config' + +const fetchMock = vi.fn() + +type MockContext = Partial + +function createFeature(fullAddress: string, context: MockContext = {}): ReverseGeocodingFeature { + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0], + }, + properties: { + feature_type: 'address', + name: fullAddress, + name_preferred: fullAddress, + place_formatted: fullAddress, + full_address: fullAddress, + context, + }, + } as ReverseGeocodingFeature +} + +function createResponse(feature: ReverseGeocodingFeature): ReverseGeocodingResponse { + return { + type: 'FeatureCollection', + attribution: 'test fixture', + features: [feature], + } as ReverseGeocodingResponse +} + +function coordinateKey(position: Position): string { + return `${position[0].toFixed(6)},${position[1].toFixed(6)}` +} + +function mockReverseGeocode(featuresByCoordinate: Map) { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + const url = new URL(rawUrl) + const key = `${url.searchParams.get('longitude')},${url.searchParams.get('latitude')}` + const feature = featuresByCoordinate.get(key) + if (!feature) throw new Error(`Unexpected reverse geocode lookup for ${key}`) + + return new Response(JSON.stringify(createResponse(feature)), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) +} + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) describe('reverseGeocode', () => { - test('return null if coords are [0, 0]', async () => { + test('return null if coords are [0, 0] without calling fetch', async () => { expect(await reverseGeocode([0, 0])).toBeNull() + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('return first feature from reverse geocode response', async () => { + const position: Position = [-0.10664, 51.514209] + const feature = createFeature('133 Fleet Street, City of London, London, EC4A 2BB, United Kingdom') + mockReverseGeocode(new Map([[coordinateKey(position), feature]])) + + expect(await reverseGeocode(position)).toEqual(feature) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + test('request includes expected Mapbox URL, params, and cache mode', async () => { + const position: Position = [-0.10664, 51.514209] + const feature = createFeature('133 Fleet Street, City of London, London, EC4A 2BB, United Kingdom') + mockReverseGeocode(new Map([[coordinateKey(position), feature]])) + + await reverseGeocode(position) + + expect(fetchMock).toHaveBeenCalledOnce() + const [input, init] = fetchMock.mock.calls[0] as [string | URL | Request, RequestInit | undefined] + const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + const url = new URL(rawUrl) + expect(`${url.origin}${url.pathname}`).toBe('https://api.mapbox.com/search/geocode/v6/reverse') + expect(url.searchParams.get('longitude')).toBe('-0.106640') + expect(url.searchParams.get('latitude')).toBe('51.514209') + expect(url.searchParams.get('access_token')).toBe(MAPBOX_TOKEN) + expect(init).toMatchObject({ cache: 'force-cache' }) + }) + + test('return null when fetch rejects', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchMock.mockRejectedValueOnce(new Error('network down')) + + expect(await reverseGeocode([-0.10664, 51.514209])).toBeNull() + }) + + test('return null on non-ok response', async () => { + fetchMock.mockResolvedValueOnce(new Response('denied', { status: 429, statusText: 'Too Many Requests' })) + + expect(await reverseGeocode([-0.10664, 51.514209])).toBeNull() + }) + + test('return null when response json cannot be parsed', async () => { + fetchMock.mockResolvedValueOnce(new Response('{', { status: 200, headers: { 'Content-Type': 'application/json' } })) + + expect(await reverseGeocode([-0.10664, 51.514209])).toBeNull() }) }) describe('getFullAddress', () => { - test('return null if coords are [0, 0]', async () => { + test('return null if coords are [0, 0] without calling fetch', async () => { expect(await getFullAddress([0, 0])).toBeNull() + expect(fetchMock).not.toHaveBeenCalled() }) test('normal usage', async () => { - // expect(await getFullAddress([-77.036574, 38.8976765])).toBe('1600 Pennsylvania Avenue Northwest, Washington, District of Columbia 20500, United States') + mockReverseGeocode( + new Map([ + [coordinateKey([-0.10664, 51.514209]), createFeature('133 Fleet Street, City of London, London, EC4A 2BB, United Kingdom')], + [coordinateKey([-2.076843, 51.894799]), createFeature('4 Montpellier Drive, Cheltenham, GL50 1TX, United Kingdom')], + ]), + ) + expect(await getFullAddress([-0.10664, 51.514209])).toBe('133 Fleet Street, City of London, London, EC4A 2BB, United Kingdom') expect(await getFullAddress([-2.076843, 51.894799])).toBe('4 Montpellier Drive, Cheltenham, GL50 1TX, United Kingdom') }) }) describe('getPlaceName', () => { - test('return null if coords are [0, 0]', async () => { + test('return null if coords are [0, 0] without calling fetch', async () => { expect(await getPlaceName([0, 0])).toBeNull() + expect(fetchMock).not.toHaveBeenCalled() }) test('normal usage', async () => { + mockReverseGeocode( + new Map([ + [coordinateKey([-117.168638, 32.723695]), createFeature('', { neighborhood: { name: 'Little Italy' } })], + [coordinateKey([-118.192757, 33.763015]), createFeature('', { place: { name: 'Downtown Long Beach' } })], + [coordinateKey([-0.113643, 51.504546]), createFeature('', { neighborhood: { name: 'Waterloo' } })], + [coordinateKey([5.572254, 50.64428]), createFeature('', { locality: { name: 'Liège' } })], + [coordinateKey([-2.236802, 53.480931]), createFeature('', { neighborhood: { name: 'Northern Quarter' } })], + ]), + ) + expect(await getPlaceName([-117.168638, 32.723695])).toBe('Little Italy') expect(await getPlaceName([-118.192757, 33.763015])).toBe('Downtown Long Beach') expect(await getPlaceName([-0.113643, 51.504546])).toBe('Waterloo') diff --git a/src/pages/auth/auth.tsx b/src/pages/auth/auth.tsx index f2dc6adff..3d96588f6 100644 --- a/src/pages/auth/auth.tsx +++ b/src/pages/auth/auth.tsx @@ -33,8 +33,8 @@ export default function Auth() { } keyed>
- comma connect -

comma connect

+ new connect +

new connect

- comma connect + new connect
-

comma connect

-

Manage your openpilot experience.

+

new connect

+

Check devices, trips, uploads, and settings from one place.

@@ -46,7 +46,9 @@ export default function Login() {
-

Make sure to sign in with the same account if you have previously paired your comma three.

+

+ Use the same account that your device was originally paired with so your trips and settings show up correctly. +

diff --git a/src/pages/dashboard/Dashboard.tsx b/src/pages/dashboard/Dashboard.tsx index d5aff4e81..80afccebb 100644 --- a/src/pages/dashboard/Dashboard.tsx +++ b/src/pages/dashboard/Dashboard.tsx @@ -101,11 +101,11 @@ const FirstPairActivity: Component = () => { class="font-bold" leading={ }> - + new connect } > - connect + new connect

Pair your device

@@ -116,8 +116,8 @@ const FirstPairActivity: Component = () => {
  • You have installed the latest version of openpilot
  • - If you still cannot see a QR code, your device may already be paired to another account. Make sure you have signed in to connect - with the same account you may have used previously. + If you still cannot see a QR code, your device may already be paired to another account. Make sure you have signed in with the + same account you used before.

    @@ -139,16 +153,19 @@ const DeviceActivity: VoidComponent = (props) => {
    -
    -
    Loading snapshots...
    +
    + +
    Fetching latest snapshots...
    -
    +
    + + {snapshot.error} +
    - Error: {snapshot.error}
    diff --git a/src/pages/dashboard/activities/RouteActivity.tsx b/src/pages/dashboard/activities/RouteActivity.tsx index 78ad9e5b3..4608eaab3 100644 --- a/src/pages/dashboard/activities/RouteActivity.tsx +++ b/src/pages/dashboard/activities/RouteActivity.tsx @@ -73,17 +73,17 @@ const RouteActivity: VoidComponent = (props) => { - Clear current route selection + Clear clip selection
    - Route Info + Drive summary and sharing
    @@ -92,14 +92,14 @@ const RouteActivity: VoidComponent = (props) => {
    - Upload Files + Upload missing files
    - Route Map + Map overview
    }> diff --git a/src/pages/dashboard/activities/SettingsActivity.tsx b/src/pages/dashboard/activities/SettingsActivity.tsx index 3cacee800..925a7de89 100644 --- a/src/pages/dashboard/activities/SettingsActivity.tsx +++ b/src/pages/dashboard/activities/SettingsActivity.tsx @@ -150,23 +150,23 @@ const PrimeCheckout: VoidComponent<{ dongleId: string }> = (props) => { disabledDataPlanText = 'Standard plan not available, detected a third-party SIM.' } else if (!['blue', 'magenta_new', 'webbing'].includes(source.subscribeInfo.sim_type)) { disabledDataPlanText = [ - 'Standard plan not available, old SIM type detected, new SIM cards are available in the ', + 'Standard plan not available, old SIM type detected. Replacement SIM cards are available in the ', - shop + support store , ] } else if (source.subscribeInfo.sim_usable === false && source.subscribeInfo.sim_type === 'blue') { disabledDataPlanText = [ - 'Standard plan not available, SIM has been canceled and is therefore no longer usable, new SIM cards are available in the ', + 'Standard plan not available, SIM has been canceled and is therefore no longer usable. Replacement SIM cards are available in the ', - shop + support store , ] } else if (source.subscribeInfo.sim_usable === false) { disabledDataPlanText = [ - 'Standard plan not available, SIM is no longer usable, new SIM cards are available in the ', + 'Standard plan not available, SIM is no longer usable. Replacement SIM cards are available in the ', - shop + support store , ] } @@ -184,24 +184,20 @@ const PrimeCheckout: VoidComponent<{ dongleId: string }> = (props) => { return (
      -
    • 24/7 connectivity
    • -
    • Take pictures remotely
    • -
    • 1 year storage of drive videos
    • -
    • Simple SSH for developers
    • +
    • Always-on connectivity for your device
    • +
    • Remote snapshots when the device is reachable
    • +
    • Longer cloud storage for drive videos
    • +
    • Developer-focused remote access tools
    -

    - Learn more from our{' '} - - FAQ - - . +

    + Plan availability still depends on the current backend account, device state, and SIM setup.

    - Checkout cancelled + Checkout canceled
    @@ -313,7 +309,7 @@ const PrimeManage: VoidComponent<{ dongleId: string }> = (props) => {
    -

    comma prime activated

    +

    Connectivity activated

    Connectivity will be enabled as soon as activation propogates to your local cell tower. Rebooting your device may help. @@ -436,7 +432,7 @@ const SettingsActivity: VoidComponent = (props) => {
    -

    comma prime

    +

    Connectivity plan

    }> diff --git a/src/pages/dashboard/components/RouteList.tsx b/src/pages/dashboard/components/RouteList.tsx index a35d16b59..12303e5bf 100644 --- a/src/pages/dashboard/components/RouteList.tsx +++ b/src/pages/dashboard/components/RouteList.tsx @@ -1,4 +1,16 @@ -import { createEffect, createResource, createSignal, For, Index, onCleanup, onMount, Show, Suspense, type VoidComponent } from 'solid-js' +import { + createEffect, + createMemo, + createResource, + createSignal, + For, + Index, + Match, + Show, + Suspense, + Switch, + type VoidComponent, +} from 'solid-js' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc.js' import timezone from 'dayjs/plugin/timezone.js' @@ -7,6 +19,7 @@ dayjs.extend(timezone) import { fetcher } from '~/api' import { getRouteStatistics } from '~/api/derived' +import { getPreservedRoutes } from '~/api/route' import Card, { CardContent, CardHeader } from '~/components/material/Card' import Icon from '~/components/material/Icon' import RouteStatisticsBar from '~/components/RouteStatisticsBar' @@ -16,6 +29,7 @@ import { dateTimeToColorBetween } from '~/utils/format' interface RouteCardProps { route: Route + isSaved: boolean } const RouteCard: VoidComponent = (props) => { @@ -40,13 +54,18 @@ const RouteCard: VoidComponent = (props) => { headline={`${startTime().format('h:mm A')} to ${endTime().format('h:mm A')}`} subhead={}>{location()}} trailing={ - - -
    - -
    +
    + + Saved - + + +
    + +
    +
    +
    +
    } /> @@ -58,109 +77,162 @@ const RouteCard: VoidComponent = (props) => { ) } -const Sentinel = (props: { onTrigger: () => void }) => { - let sentinel!: HTMLDivElement - const observer = new IntersectionObserver( - (entries) => { - if (!entries[0].isIntersecting) return - props.onTrigger() - }, - { threshold: 0.1 }, - ) - onMount(() => observer.observe(sentinel)) - onCleanup(() => observer.disconnect()) - return
    -} +type RouteFilter = 'all' | 'recent' | 'saved' const PAGE_SIZE = 10 const RouteList: VoidComponent<{ dongleId: string }> = (props) => { - const endpoint = () => `/v1/devices/${props.dongleId}/routes?limit=${PAGE_SIZE}` - const getKey = (previousPageData?: Route[]): string | undefined => { - if (!previousPageData) return endpoint() - if (previousPageData.length === 0) return undefined - return `${endpoint()}&created_before=${previousPageData.at(-1)!.create_time}` - } - const getPage = (page: number): Promise => { - if (pages[page] === undefined) { - pages[page] = (async () => { - const previousPageData = page > 0 ? await getPage(page - 1) : undefined - const key = getKey(previousPageData) - return key ? fetcher(key).catch(() => []) : [] - })() - } - return pages[page] - } - - const pages: Promise[] = [] const [size, setSize] = createSignal(1) - const pageNumbers = () => Array.from({ length: size() }) + const [filter, setFilter] = createSignal('all') + + const [preservedRoutes] = createResource(() => props.dongleId, getPreservedRoutes) + const [routes] = createResource( + () => ({ dongleId: props.dongleId, size: size() }), + async ({ dongleId, size }) => { + const pages: Route[] = [] + let createdBefore: number | undefined + for (let i = 0; i < size; i += 1) { + const params = new URLSearchParams({ limit: PAGE_SIZE.toString() }) + if (createdBefore) params.set('created_before', createdBefore.toString()) + const page = await fetcher(`/v1/devices/${dongleId}/routes?${params.toString()}`).catch(() => []) + pages.push(...page) + if (page.length < PAGE_SIZE) break + createdBefore = page.at(-1)?.create_time + } + return pages + }, + ) + + const savedRoutes = createMemo(() => new Set((preservedRoutes() ?? []).map((route) => route.fullname))) + const filteredRoutes = createMemo(() => { + const allRoutes = routes() ?? [] + return allRoutes.filter((route) => { + if (filter() === 'saved') return savedRoutes().has(route.fullname) + if (filter() === 'recent') return dayjs.utc(route.start_time).local().isAfter(dayjs().subtract(7, 'day')) + return true + }) + }) + const hasMore = createMemo(() => (routes()?.length ?? 0) >= size() * PAGE_SIZE) createEffect(() => { if (props.dongleId) { - pages.length = 0 setSize(1) + setFilter('all') } }) - // Group and display headers for each day - let prevDayHeader: string | null = null - function getDayHeader(route: Route): string | null { - const date = dayjs.utc(route.start_time).local() - let dayHeader = null - if (date.isSame(dayjs(), 'day')) { - dayHeader = `Today – ${date.format('dddd, MMM D')}` - } else if (date.isSame(dayjs().subtract(1, 'day'), 'day')) { - dayHeader = `Yesterday – ${date.format('dddd, MMM D')}` - } else if (date.year() === dayjs().year()) { - dayHeader = date.format('dddd, MMM D') - } else { - dayHeader = date.format('dddd, MMM D, YYYY') - } - - if (dayHeader !== prevDayHeader) { - prevDayHeader = dayHeader - return dayHeader - } - return null - } - return (
    - - {(_, i) => { - const [routes] = createResource(() => i(), getPage) - return ( - -

    - {() =>
    } - - } - > - - {(route) => { - const firstHeader = prevDayHeader === null - const dayHeader = getDayHeader(route) - return ( - <> - - -
    - -

    {dayHeader}

    - - - - ) - }} - - - ) - }} - - setSize((size) => size + 1)} /> +
    +
    +
    +

    Trips

    +

    Use quick filters to focus on recent drives or routes you saved for later.

    +
    +
    + + {(value) => ( + + )} + +
    +
    +
    + + +

    + {() =>
    } + + } + > + + {(() => { + let prevDayHeader: string | null = null + const getDayHeader = (route: Route): string | null => { + const date = dayjs.utc(route.start_time).local() + let dayHeader = null + if (date.isSame(dayjs(), 'day')) { + dayHeader = `Today - ${date.format('dddd, MMM D')}` + } else if (date.isSame(dayjs().subtract(1, 'day'), 'day')) { + dayHeader = `Yesterday - ${date.format('dddd, MMM D')}` + } else if (date.year() === dayjs().year()) { + dayHeader = date.format('dddd, MMM D') + } else { + dayHeader = date.format('dddd, MMM D, YYYY') + } + if (dayHeader !== prevDayHeader) { + prevDayHeader = dayHeader + return dayHeader + } + return null + } + + return ( + + {(route) => { + const firstHeader = prevDayHeader === null + const dayHeader = getDayHeader(route) + return ( + <> + + +
    + +

    {dayHeader}

    + + + + ) + }} + + ) + })()} + + +
    + +
    +
    + + } + > + +
    + No trips were found for this device yet. +
    +
    + +
    + No trips match the current filter. Try another filter or load more trips. +
    +
    + +
    ) } diff --git a/src/pages/offline.tsx b/src/pages/offline.tsx index a429f6441..538c60318 100644 --- a/src/pages/offline.tsx +++ b/src/pages/offline.tsx @@ -4,9 +4,9 @@ export default function OfflinePage() { return (
    - comma connect + new connect
    -

    comma connect

    +

    new connect

    offline

    diff --git a/vite.config.ts b/vite.config.ts index de1eba666..db2b5edc3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv, type HtmlTagDescriptor, type PluginOption } from 'vite' import solid from 'vite-plugin-solid' import devtools from 'solid-devtools/vite' import { sentryVitePlugin } from '@sentry/vite-plugin' @@ -7,8 +7,9 @@ import { VitePWA } from 'vite-plugin-pwa' // noinspection ES6PreferShortImport import { Icons } from './src/components/material/Icon' -export default defineConfig({ - plugins: [ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const plugins: PluginOption[] = [ devtools(), solid({ ssr: false, @@ -16,9 +17,9 @@ export default defineConfig({ VitePWA({ registerType: 'autoUpdate', manifest: { - name: 'comma connect', - short_name: 'connect', - description: 'manage your openpilot experience', + name: 'new connect', + short_name: 'new connect', + description: 'Monitor your devices, trips, and uploads from one place.', background_color: '#131318', theme_color: '#131318', start_url: '/', @@ -56,40 +57,50 @@ export default defineConfig({ }), { name: 'inject-material-symbols', - transformIndexHtml(html) { + transformIndexHtml(html: string) { const icons = Icons.toSorted().join(',') + const tags: HtmlTagDescriptor[] = [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: `https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0..1,0&icon_names=${icons}&display=block`, + }, + injectTo: 'head', + }, + ] return { html, - tags: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: `https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0..1,0&icon_names=${icons}&display=block`, - }, - injectTo: 'head', - }, - ], + tags, } }, }, - // put the Sentry plugin after all other plugins - sentryVitePlugin({ - org: 'commaai', - project: 'new-connect', - telemetry: false, - }), - ], - server: { - port: 3000, - }, - build: { - target: 'esnext', - sourcemap: true, // must be turned on for Sentry - }, - resolve: { - alias: { - '~': '/src', + ] + + if (env.VITE_SENTRY_ORG && env.VITE_SENTRY_PROJECT) { + plugins.push( + sentryVitePlugin({ + org: env.VITE_SENTRY_ORG, + project: env.VITE_SENTRY_PROJECT, + authToken: env.SENTRY_AUTH_TOKEN || undefined, + telemetry: false, + }), + ) + } + + return { + plugins, + server: { + port: 3000, + }, + build: { + target: 'esnext', + sourcemap: true, + }, + resolve: { + alias: { + '~': '/src', + }, }, - }, + } })