chore: Migrate from Create React App to Vite (#231)

* chore(vite): use vite

* fix(vite): alias lib directory

* chore(vite): set type: module

* chore: update vite and MUI

* fix(vite): make MUI components load

* fix: use node path resolution

* chore(vite): add svg support

* fix(vite): polyfill global

* fix(vite): use import.meta

* fix(vite): use correct svg module resolution

* chore(vite): migrate to vitest

* fix(vite): remove PUBLIC_URL

* fix(tests): mock audio service

* chore(deps): upgrade to react test library 14

* refactor(tests): simplify room test setup

* refactor(tests): make Date.now() mockable

* refactor(vite): remove bootstrap shim

* chore(deps): drop react-scripts

* chore(deps): remove source-map-explorer

Source maps do not currently work with MUI and Vite:
https://github.com/vitejs/vite/issues/15012

Because of this, source map utilities are currently removed.

* refactor(vite): use TypeScript for Vite config

* chore(actions): update actions config for new paths

* fix(service-worker): use VITE_HOMEPAGE for service worker resolution

* fix(vercel): use quotes for build command

* fix(vite): use import.meta.env.MODE

* fix(service-worker): use correct definition for publicUrl

* feat(vite): use vite-plugin-pwa

* fix(pwa): make update prompt work

* fix(types): use vite/client types

* docs(readme): update building instructions

* refactor(vite): simplify theme loading workaround

* refactor(vite): use manifest object

* docs(readme): update tool references

* chore(deps): run `npm audit fix`

* fix(vite): make syntax highlighter work consistently

See: https://github.com/react-syntax-highlighter/react-syntax-highlighter/issues/513

* fix(pwa): remove manifest.json references

* refactor(deps): remove jest references

* refactor(types): remove react-scripts reference

* chore(deps): use TypeScript 5

* refactor(tests): improve persisted storage mocking
This commit is contained in:
Jeremy Kahn 2024-03-13 03:44:43 +01:00 committed by GitHub
parent 72bc66a340
commit ea34058fa7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 24909 additions and 16945 deletions

3
.env
View File

@ -1,2 +1 @@
REACT_APP_NAME=$npm_package_name
REACT_APP_GITHUB_REPO="https://github.com/jeremyckahn/chitchatter"
VITE_GITHUB_REPO="https://github.com/jeremyckahn/chitchatter"

View File

@ -4,7 +4,6 @@ on:
push:
branches:
- '**'
# - '!main'
jobs:
test_and_build:
@ -23,4 +22,4 @@ jobs:
- uses: actions/upload-artifact@v3
with:
name: build-output
path: build
path: dist

View File

@ -24,11 +24,10 @@ jobs:
run: |
npm ci
npm run build
npm run analyze -- --html build/bundle-info.html
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.DEPLOY_KEY }}
publish_dir: ./build
publish_dir: ./dist
force_orphan: true

3
.gitignore vendored
View File

@ -9,7 +9,8 @@
/coverage
# production
/build
/dist
/dev-dist
# misc
.DS_Store

View File

@ -17,7 +17,7 @@ Chitchatter is a free (as in both price and freedom) communication tool. Designe
- Embeddable
- [Self-hostable](#self-hosting)
Chitchatter uses the [Create React App](https://github.com/facebook/create-react-app) toolchain. The secure networking and streaming magic would not be possible without [Trystero](https://github.com/dmotz/trystero). File transfer functionality is powered by [`secure-file-transfer`](https://github.com/jeremyckahn/secure-file-transfer).
Chitchatter uses [Vite](https://vitejs.dev/). The secure networking and streaming magic would not be possible without [Trystero](https://github.com/dmotz/trystero). File transfer functionality is powered by [`secure-file-transfer`](https://github.com/jeremyckahn/secure-file-transfer).
## Status
@ -173,11 +173,11 @@ The page will reload when you make changes. You may also see any lint errors in
### `npm test`
Launches the test runner in the interactive watch mode. See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
Launches the test runner in the interactive watch mode.
### `npm run build`
Builds the app for production to the `build` folder. It correctly bundles React in production mode and optimizes the build for the best performance.
Builds the app for production to the `dist` folder. It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.
@ -204,7 +204,7 @@ When hosted on GitHub Pages and the configuration above has been done, the Produ
##### On non-GitHub hosts
Build the app with `PUBLIC_URL="https://your-domain-here.com" npm run build`, and then serve the `build` directory. Any static file serving solution should work provided it is using a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).
Build the app with `npm pkg set homepage="https://your-domain-here.com" && npm run build` (with `https://your-domain-here.com` substituted for the root URL that Chitchatter will be served from), and then serve the `dist` directory. Any static file serving solution should work provided it is using a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).
#### Runtime configuration

View File

@ -2,25 +2,20 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="A peer-to-peer chat app that is serverless, decentralized, and ephemeral"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" href="/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
Notice the use of in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
Unlike "/favicon.ico" or "favicon.ico", "/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
@ -69,5 +64,6 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

91
manifest.ts Normal file
View File

@ -0,0 +1,91 @@
import { ManifestOptions } from 'vite-plugin-pwa'
export const manifest: Partial<ManifestOptions> = {
short_name: 'Chitchatter',
name: 'Chitchatter',
description:
'This is a communication tool that is free, open source, and designed for simplicity and security. All communication between you and your online peers is encrypted. There is no trace of your conversation once you leave.',
icons: [
{
src: 'favicon.ico',
sizes: '64x64 32x32 24x24 16x16',
type: 'image/x-icon',
},
{
src: 'logo192.png',
type: 'image/png',
sizes: '192x192',
},
{
src: 'logo512.png',
type: 'image/png',
sizes: '512x512',
},
],
start_url: './',
display: 'fullscreen',
theme_color: '#000000',
background_color: '#222222',
screenshots: [
{
src: 'screenshots/home-desktop.png',
sizes: '2160x1620',
type: 'image/png',
},
{
src: 'screenshots/public-room-desktop.png',
sizes: '2160x1620',
type: 'image/png',
},
{
src: 'screenshots/public-room-desktop-with-video.png',
sizes: '2160x1620',
type: 'image/png',
},
{
src: 'screenshots/home-mobile-dark.png',
sizes: '750x1334',
type: 'image/png',
form_factor: 'narrow',
},
{
src: 'screenshots/home-mobile-light.png',
sizes: '750x1334',
type: 'image/png',
form_factor: 'narrow',
},
{
src: 'screenshots/public-room-mobile.png',
sizes: '750x1334',
type: 'image/png',
form_factor: 'narrow',
},
],
shortcuts: [
{
name: 'About',
url: './about',
icons: [
{
src: 'logo512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
],
},
{
name: 'Disclaimer',
url: './disclaimer',
icons: [
{
src: 'logo512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
],
},
],
}

41048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,17 +4,17 @@
"homepage": "https://chitchatter.im/",
"author": "Jeremy Kahn <jeremyckahn@gmail.com>",
"license": "GPL-2.0-or-later",
"type": "module",
"dependencies": {
"@emotion/react": "^11.10.0",
"@emotion/styled": "^11.10.0",
"@mui/icons-material": "^5.8.4",
"@mui/material": "^5.14.12",
"@mui/icons-material": "^5.15.6",
"@mui/material": "^5.15.6",
"@react-hook/debounce": "^4.0.0",
"@react-hook/window-size": "^3.1.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^28.1.6",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^18.18.4",
"@types/react": "^18.2.25",
"@types/react-dom": "^18.0.6",
@ -35,42 +35,39 @@
"react-markdown": "^8.0.3",
"react-qrcode-logo": "^2.8.0",
"react-router-dom": "^6.16.0",
"react-scripts": "5.0.1",
"react-syntax-highlighter": "^15.5.0",
"react-youtube": "^10.1.0",
"readable-web-to-node-stream": "^3.0.2",
"remark-gfm": "^3.0.1",
"sass": "^1.54.3",
"sdp": "^3.2.0",
"secure-file-transfer": "^0.0.7",
"streamsaver": "^2.0.6",
"trystero": "^0.15.0",
"typeface-public-sans": "^1.1.13",
"typeface-roboto": "^1.1.13",
"typescript": "^4.9.5",
"typescript": "^5.4.2",
"uuid": "^8.3.2",
"vite-plugin-babel-macros": "^1.0.6",
"web-vitals": "^2.1.4",
"webrtc-adapter": "^8.2.2"
},
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "cross-env REACT_APP_HOMEPAGE=$(npm pkg get homepage) react-scripts start",
"start": "cross-env VITE_HOMEPAGE=$(npm pkg get homepage) vite --port 3000",
"start:tracker": "bittorrent-tracker",
"start:streamsaver": "serve -p 3015 node_modules/streamsaver",
"dev": "mprocs \"npx cross-env REACT_APP_TRACKER_URL=\"ws://localhost:8000\" REACT_APP_STREAMSAVER_URL=\"http://localhost:3015/mitm.html\" npm run start\" \"npm run start:tracker\" \"npm run start:streamsaver\"",
"dev": "mprocs \"npx cross-env VITE_TRACKER_URL=\"ws://localhost:8000\" VITE_STREAMSAVER_URL=\"http://localhost:3015/mitm.html\" npm run start\" \"npm run start:tracker\" \"npm run start:streamsaver\"",
"build": "npm run build:app && npm run build:sdk",
"build:app": "cross-env REACT_APP_HOMEPAGE=$(npm pkg get homepage) react-scripts build",
"build:sdk": "parcel build sdk/sdk.ts --dist-dir build --no-content-hash",
"build:app": "cross-env VITE_HOMEPAGE=$(npm pkg get homepage) vite build",
"build:sdk": "parcel build sdk/sdk.ts --no-content-hash",
"build:sdk:watch": "nodemon --exec \"npm run build:sdk\"",
"test": "react-scripts test",
"test": "vitest",
"prepare": "husky install",
"prettier": "prettier 'src/**/*.js' --write",
"lint": "eslint src --max-warnings=0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
"react-app"
]
},
"engines": {
@ -99,6 +96,7 @@
"@types/webtorrent": "^0.109.3",
"@typescript-eslint/eslint-plugin": "^5.33.0",
"@typescript-eslint/parser": "^5.33.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.8",
"bittorrent-tracker": "^9.19.0",
"cross-env": "^7.0.3",
@ -109,6 +107,7 @@
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.1",
"jsdom": "^24.0.0",
"mprocs": "^0.6.4",
"nodemon": "^3.0.1",
"parcel": "^2.10.0",
@ -116,11 +115,16 @@
"prettier": "^2.7.1",
"pretty-quick": "^3.1.3",
"process": "^0.11.10",
"sass": "^1.69.5",
"serve": "^14.1.2",
"source-map-explorer": "^2.5.3",
"tailwindcss": "^3.1.8",
"url": "^0.11.0",
"util": "^0.12.5"
"util": "^0.12.5",
"vite": "^5.0.12",
"vite-plugin-node-polyfills": "^0.19.0",
"vite-plugin-pwa": "^0.19.2",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^1.3.1"
},
"overrides": {
"ipfs-core": "npm:dry-uninstall",
@ -134,10 +138,5 @@
"resolve-url-loader": {
"postcss": "8.4.31"
}
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!trystero)/"
]
}
}

View File

@ -1,88 +0,0 @@
{
"short_name": "Chitchatter",
"name": "Chitchatter",
"description": "This is a communication tool that is free, open source, and designed for simplicity and security. All communication between you and your online peers is encrypted. There is no trace of your conversation once you leave.",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "./",
"display": "fullscreen",
"theme_color": "#000000",
"background_color": "#222222",
"screenshots": [
{
"src": "screenshots/home-desktop.png",
"sizes": "2160x1620",
"type": "image/png"
},
{
"src": "screenshots/public-room-desktop.png",
"sizes": "2160x1620",
"type": "image/png"
},
{
"src": "screenshots/public-room-desktop-with-video.png",
"sizes": "2160x1620",
"type": "image/png"
},
{
"src": "screenshots/home-mobile-dark.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "screenshots/home-mobile-light.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "screenshots/public-room-mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "About",
"url": "./about",
"icons": [
{
"src": "logo512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
},
{
"name": "Disclaimer",
"url": "./disclaimer",
"icons": [
{
"src": "logo512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
]
}

View File

@ -1,4 +0,0 @@
// NOTE: This file is a shim to enable the Bootstrap component to be
// lazy-loaded.
import Bootstrap from './Bootstrap.tsx'
export default Bootstrap

View File

@ -1,5 +1,6 @@
import { vi } from 'vitest'
import { act, render } from '@testing-library/react'
import localforage from 'localforage'
import persistedStorage from 'localforage'
import { PersistedStorageKeys } from 'models/storage'
import {
@ -11,28 +12,14 @@ import { userSettingsStubFactory } from 'test-utils/stubs/userSettings'
import Bootstrap, { BootstrapProps } from './Bootstrap'
const mockPersistedStorage =
jest.createMockFromModule<jest.Mock<typeof localforage>>('localforage')
const mockGetItem = jest.fn()
const mockSetItem = jest.fn()
vi.mock('localforage')
const userSettingsStub = userSettingsStubFactory()
beforeEach(() => {
mockGetItem.mockImplementation(() => Promise.resolve(null))
mockSetItem.mockImplementation((data: any) => Promise.resolve(data))
})
const renderBootstrap = async (overrides: Partial<BootstrapProps> = {}) => {
Object.assign(mockPersistedStorage, {
getItem: mockGetItem,
setItem: mockSetItem,
})
render(
<Bootstrap
persistedStorage={mockPersistedStorage as any as typeof localforage}
persistedStorage={persistedStorage}
initialUserSettings={userSettingsStub}
serializationService={mockSerialization}
{...overrides}
@ -51,7 +38,9 @@ test('renders', async () => {
test('checks persistedStorage for user settings', async () => {
await renderBootstrap()
expect(mockGetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS)
expect(persistedStorage.getItem).toHaveBeenCalledWith(
PersistedStorageKeys.USER_SETTINGS
)
})
test('updates persisted user settings', async () => {
@ -59,14 +48,17 @@ test('updates persisted user settings', async () => {
initialUserSettings: { ...userSettingsStub, userId: 'abc123' },
})
expect(mockSetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS, {
colorMode: 'dark',
userId: 'abc123',
customUsername: '',
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
showActiveTypingStatus: true,
publicKey: mockSerializedPublicKey,
privateKey: mockSerializedPrivateKey,
})
expect(persistedStorage.setItem).toHaveBeenCalledWith(
PersistedStorageKeys.USER_SETTINGS,
{
colorMode: 'dark',
userId: 'abc123',
customUsername: '',
playSoundOnNewMessage: true,
showNotificationOnNewMessage: true,
showActiveTypingStatus: true,
publicKey: mockSerializedPublicKey,
privateKey: mockSerializedPrivateKey,
}
)
})

View File

@ -6,8 +6,8 @@ import {
Navigate,
} from 'react-router-dom'
import localforage from 'localforage'
import { useRegisterSW } from 'virtual:pwa-register/react'
import * as serviceWorkerRegistration from 'serviceWorkerRegistration'
import { StorageContext } from 'contexts/StorageContext'
import { SettingsContext } from 'contexts/SettingsContext'
import { homepageUrl, routes } from 'config/routes'
@ -87,16 +87,11 @@ const Bootstrap = ({
)
const [persistedStorage] = useState(persistedStorageProp)
const [appNeedsUpdate, setAppNeedsUpdate] = useState(false)
const [hasLoadedSettings, setHasLoadedSettings] = useState(false)
const [userSettings, setUserSettings] =
useState<UserSettings>(initialUserSettings)
const { userId } = userSettings
const handleServiceWorkerUpdate = () => {
setAppNeedsUpdate(true)
}
const persistUserSettings = useCallback(
async (newUserSettings: UserSettings) => {
if (queryParams.has(QueryParamKeys.IS_EMBEDDED)) {
@ -114,9 +109,9 @@ const Bootstrap = ({
[persistedStorageProp, queryParams, serializationService, userSettings]
)
useEffect(() => {
serviceWorkerRegistration.register({ onUpdate: handleServiceWorkerUpdate })
}, [])
const {
needRefresh: [appNeedsUpdate],
} = useRegisterSW()
useEffect(() => {
;(async () => {

View File

@ -13,8 +13,7 @@ import { ColorMode, UserSettings } from 'models/settings'
import type { BootstrapProps } from './Bootstrap'
// @ts-expect-error
const Bootstrap = lazy(() => import('./Bootstrap.js'))
const Bootstrap = lazy(() => import('./Bootstrap'))
export interface InitProps extends Omit<BootstrapProps, 'initialUserSettings'> {
getUuid?: typeof uuid

View File

@ -1,12 +1,13 @@
import { vi } from 'vitest'
import { PropsWithChildren } from 'react'
import { waitFor, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter as Router, Route, Routes } from 'react-router-dom'
import { userSettingsContextStubFactory } from 'test-utils/stubs/settingsContext'
import { mockEncryptionService } from 'test-utils/mocks/mockEncryptionService'
import { SettingsContext } from 'contexts/SettingsContext'
import { Time } from 'lib/Time'
import { Room, RoomProps } from './'
@ -17,13 +18,17 @@ const userSettingsStub = userSettingsContextStubFactory({
userId: mockUserId,
})
window.AudioContext = jest.fn().mockImplementation()
const mockGetUuid = jest.fn()
const mockMessagedSender = jest
.fn()
.mockImplementation(() => Promise.resolve([]))
window.AudioContext = vi.fn().mockImplementation(() => {})
const mockGetUuid = vi.fn()
const mockMessagedSender = vi.fn().mockImplementation(() => Promise.resolve([]))
jest.mock('trystero', () => ({
const mockTimeService = new Time()
const mockNowTime = 1234
mockTimeService.now = () => mockNowTime
vi.mock('../../lib/Audio')
vi.mock('trystero', () => ({
joinRoom: () => ({
makeAction: () => [mockMessagedSender, () => {}, () => {}],
ping: () => Promise.resolve(0),
@ -53,10 +58,14 @@ const RouteStub = ({ children }: PropsWithChildren) => {
)
}
jest.useFakeTimers().setSystemTime(100)
const RoomStub = (props: RoomProps) => {
return <Room encryptionService={mockEncryptionService} {...props} />
return (
<Room
encryptionService={mockEncryptionService}
timeService={mockTimeService}
{...props}
/>
)
}
describe('Room', () => {
@ -89,9 +98,7 @@ describe('Room', () => {
const sendButton = screen.getByLabelText('Send')
const textInput = screen.getByPlaceholderText('Your message')
await waitFor(() => {
userEvent.type(textInput, 'hello')
})
await userEvent.type(textInput, 'hello')
expect(sendButton).not.toBeDisabled()
})
@ -106,13 +113,8 @@ describe('Room', () => {
const sendButton = screen.getByLabelText('Send')
const textInput = screen.getByPlaceholderText('Your message')
await waitFor(() => {
userEvent.type(textInput, 'hello')
})
await waitFor(() => {
userEvent.click(sendButton)
})
await userEvent.type(textInput, 'hello')
await userEvent.click(sendButton)
expect(textInput).toHaveValue('')
})
@ -131,18 +133,13 @@ describe('Room', () => {
const sendButton = screen.getByLabelText('Send')
const textInput = screen.getByPlaceholderText('Your message')
await waitFor(() => {
userEvent.type(textInput, 'hello')
})
await waitFor(() => {
userEvent.click(sendButton)
})
await userEvent.type(textInput, 'hello')
await userEvent.click(sendButton)
expect(mockMessagedSender).toHaveBeenCalledWith({
authorId: mockUserId,
text: 'hello',
timeSent: 100,
timeSent: mockNowTime,
id: 'abc123',
})
})

View File

@ -8,6 +8,7 @@ import { v4 as uuid } from 'uuid'
import { rtcConfig } from 'config/rtcConfig'
import { trackerUrls } from 'config/trackerUrls'
import { time } from 'lib/Time'
import { RoomContext } from 'contexts/RoomContext'
import { ShellContext } from 'contexts/ShellContext'
import { MessageForm } from 'components/MessageForm'
@ -31,12 +32,14 @@ export interface RoomProps {
roomId: string
userId: string
encryptionService?: typeof encryption
timeService?: typeof time
}
export function Room({
appId = `${encodeURI(window.location.origin)}_${process.env.REACT_APP_NAME}`,
appId = `${encodeURI(window.location.origin)}_${process.env.VITE_NAME}`,
getUuid = uuid,
encryptionService = encryption,
timeService = time,
roomId,
password,
userId,
@ -68,6 +71,7 @@ export function Room({
getUuid,
publicKey,
encryptionService,
timeService,
}
)

View File

@ -26,8 +26,9 @@ import {
} from 'models/chat'
import { getPeerName, usePeerNameDisplay } from 'components/PeerNameDisplay'
import { Audio } from 'lib/Audio'
import { notification } from 'services/Notification'
import { time } from 'lib/Time'
import { PeerRoom, PeerHookType } from 'lib/PeerRoom'
import { notification } from 'services/Notification'
import { fileTransfer } from 'lib/FileTransfer'
import { AllowedKeyType, encryption } from 'services/Encryption'
@ -41,6 +42,7 @@ interface UseRoomConfig {
publicKey: CryptoKey
getUuid?: typeof uuid
encryptionService?: typeof encryption
timeService?: typeof time
}
interface UserMetadata {
@ -57,6 +59,7 @@ export function useRoom(
publicKey,
getUuid = uuid,
encryptionService = encryption,
timeService = time,
}: UseRoomConfig
) {
const isPrivate = password !== undefined
@ -83,9 +86,7 @@ export function useRoom(
const [messageLog, _setMessageLog] = useState<Array<Message | InlineMedia>>(
[]
)
const [newMessageAudio] = useState(
() => new Audio(process.env.PUBLIC_URL + '/sounds/new-message.aac')
)
const [newMessageAudio] = useState(() => new Audio('/sounds/new-message.aac'))
const { getDisplayUsername } = usePeerNameDisplay()
@ -237,7 +238,7 @@ export function useRoom(
const unsentMessage: UnsentMessage = {
authorId: userId,
text: message,
timeSent: Date.now(),
timeSent: timeService.now(),
id: getUuid(),
}
@ -248,7 +249,7 @@ export function useRoom(
setMessageLog([
...messageLog,
{ ...unsentMessage, timeReceived: Date.now() },
{ ...unsentMessage, timeReceived: timeService.now() },
])
setIsMessageSending(false)
}
@ -324,7 +325,10 @@ export function useRoom(
}
}
setMessageLog([...messageLog, { ...message, timeReceived: Date.now() }])
setMessageLog([
...messageLog,
{ ...message, timeReceived: timeService.now() },
])
updatePeer(peerId, { isTyping: false })
})
@ -391,7 +395,7 @@ export function useRoom(
const unsentInlineMedia: UnsentInlineMedia = {
authorId: userId,
magnetURI: fileOfferId,
timeSent: Date.now(),
timeSent: timeService.now(),
id: getUuid(),
}
@ -402,7 +406,7 @@ export function useRoom(
setMessageLog([
...messageLog,
{ ...unsentInlineMedia, timeReceived: Date.now() },
{ ...unsentInlineMedia, timeReceived: timeService.now() },
])
setIsMessageSending(false)
}
@ -434,7 +438,10 @@ export function useRoom(
}
}
setMessageLog([...messageLog, { ...inlineMedia, timeReceived: Date.now() }])
setMessageLog([
...messageLog,
{ ...inlineMedia, timeReceived: timeService.now() },
])
})
receiveTypingStatusChange((typingStatus, peerId) => {

View File

@ -145,7 +145,9 @@ export const Drawer = ({ isDrawerOpen, onDrawerClose, theme }: DrawerProps) => {
<MuiLink
target="_blank"
rel="noopener"
href={`${process.env.REACT_APP_GITHUB_REPO}/commit/${commit.hash}`}
href={`${import.meta.env.VITE_GITHUB_REPO}/commit/${
commit.hash
}`}
>
{commit.shortHash}
</MuiLink>

View File

@ -12,7 +12,7 @@ const { isSecureContext, RTCDataChannel } = window
const doesSupportWebRtc = RTCDataChannel !== undefined
export const isEnvironmentSupported =
(isSecureContext && doesSupportWebRtc) || process.env.NODE_ENV === 'test'
(isSecureContext && doesSupportWebRtc) || import.meta.env.MODE === 'test'
export const EnvironmentUnsupportedDialog = () => {
const theme = useTheme()

View File

@ -35,7 +35,7 @@ describe('Shell', () => {
userEvent.click(menuButton)
})
const navigation = screen.getByRole('navigation')
const navigation = screen.getByLabelText('Navigation menu')
await waitFor(() => {
expect(navigation).toBeVisible()
@ -56,7 +56,7 @@ describe('Shell', () => {
userEvent.click(closeMenu)
})
const navigation = screen.getByRole('navigation')
const navigation = screen.getByLabelText('Navigation menu')
await waitFor(() => {
expect(navigation).not.toBeVisible()

View File

@ -6,14 +6,17 @@ import DialogContent from '@mui/material/DialogContent'
import DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle'
import WarningIcon from '@mui/icons-material/Warning'
import { useRegisterSW } from 'virtual:pwa-register/react'
interface UpgradeDialogProps {
appNeedsUpdate: boolean
}
export const UpgradeDialog = ({ appNeedsUpdate }: UpgradeDialogProps) => {
const { updateServiceWorker } = useRegisterSW()
const handleRestartClick = () => {
window.location.reload()
updateServiceWorker(true)
}
return (

View File

@ -9,5 +9,5 @@ export enum routes {
}
export const homepageUrl = new URL(
process.env.REACT_APP_HOMEPAGE ?? 'https://chitchatter.im/'
import.meta.env.VITE_HOMEPAGE ?? 'https://chitchatter.im/'
)

View File

@ -1,5 +1,5 @@
export const streamSaverUrl =
process.env.REACT_APP_STREAMSAVER_URL ??
import.meta.env.VITE_STREAMSAVER_URL ??
// If you would like to host your own Chitchatter instance with an
// alternative StreamSaver fork to facilitate file sharing, change this
// string to its respective .mitm.html URL.

View File

@ -8,12 +8,12 @@ let trackerUrls: string[] | undefined = [
// https://github.com/dmotz/trystero/blob/694f49974974cc9df8b621db09215d6df10fad09/src/torrent.js#L27-L33
]
// If a tracker URL has been provided via the REACT_APP_TRACKER_URL environment
// If a tracker URL has been provided via the VITE_TRACKER_URL environment
// variable, prioritize using it. This is mainly relevant for local development
// when using the `npm run dev` script. If you are hosting your own Chitchatter
// instance, consider populating the trackerUrls above instead.
if (process.env.REACT_APP_TRACKER_URL) {
trackerUrls.unshift(process.env.REACT_APP_TRACKER_URL)
if (import.meta.env.VITE_TRACKER_URL) {
trackerUrls.unshift(import.meta.env.VITE_TRACKER_URL)
}
// If no tracker URL overrides have been provided, set trackerUrls to undefined

View File

@ -3,9 +3,27 @@ import ReactDOM from 'react-dom/client'
import 'typeface-roboto'
import './index.sass'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter'
import Init from './Init'
import reportWebVitals from './reportWebVitals'
// NOTE: This is a workaround for MUI components attempting to access theme code
// before it has loaded.
// See: https://stackoverflow.com/a/76017295/470685
;<ThemeProvider theme={createTheme()} />
// NOTE: This is a workaround for SyntaxHighlighter not working reliably in the
// EmbedCodeDialog component. It seems to have the effect of warming some
// sort of internal cache that avoids a race condition within
// SyntaxHighlighter.
// See: https://github.com/react-syntax-highlighter/react-syntax-highlighter/issues/513
ReactDOM.createRoot(document.createElement('div')).render(
<SyntaxHighlighter language="" children={''} />
)
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(<Init />)

7
src/lib/Time/Time.ts Normal file
View File

@ -0,0 +1,7 @@
export class Time {
now = () => {
return Date.now()
}
}
export const time = new Time()

1
src/lib/Time/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './Time'

View File

@ -16,7 +16,7 @@ import { v4 as uuid } from 'uuid'
import { routes } from 'config/routes'
import { ShellContext } from 'contexts/ShellContext'
import { PeerNameDisplay } from 'components/PeerNameDisplay'
import { ReactComponent as Logo } from 'img/logo.svg'
import Logo from 'img/logo.svg?react'
import { EmbedCodeDialog } from './EmbedCodeDialog'

View File

@ -1,9 +1 @@
import 'webrtc-adapter'
import { Buffer } from 'buffer'
// @ts-ignore
import process from 'process/browser'
// Polyfill
window.Buffer = Buffer
window.process = process

View File

@ -1 +1 @@
/// <reference types="react-scripts" />
/// <reference types="vite-plugin-svgr/client" />

View File

@ -1,73 +0,0 @@
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate } from 'workbox-strategies'
clientsClaim()
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST)
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false
} // If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false
} // Return true to signal that we want to use the handler.
return true
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
)
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) =>
url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
)
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
// Any other custom service worker logic can go here.

View File

@ -1,141 +0,0 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
)
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href)
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return
}
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config)
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA'
)
})
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config)
}
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing
if (installingWorker == null) {
return
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://cra.link/PWA.'
)
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration)
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.')
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration)
}
}
}
}
}
})
.catch(error => {
console.error('Error during service worker registration:', error)
})
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type')
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload()
})
})
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config)
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
)
})
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister()
})
.catch(error => {
console.error(error.message)
})
}
}

View File

@ -3,12 +3,16 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom'
import { vi } from 'vitest'
afterEach(() => {
jest.restoreAllMocks()
vi.restoreAllMocks()
})
jest.mock('secure-file-transfer', () => ({
vi.mock('trystero')
vi.mock('trystero/torrent')
vi.mock('secure-file-transfer', () => ({
__esModule: true,
FileTransfer: class FileTransfer {
rescindAll() {}

View File

@ -1,8 +1,9 @@
import { vi } from 'vitest'
import { encryption } from 'services/Encryption'
export const mockEncryptionService = encryption
mockEncryptionService.generateKeyPair = jest.fn(async () => ({
mockEncryptionService.generateKeyPair = vi.fn(async () => ({
publicKey: encryption.cryptoKeyStub,
privateKey: encryption.cryptoKeyStub,
}))

View File

@ -10,12 +10,17 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "NodeNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "src"
"baseUrl": "src",
"allowImportingTsExtensions": true,
"paths": {
"trystero/torrent": ["../node_modules/trystero/src/torrent.d.ts"]
},
"types": ["vite-plugin-pwa/client", "vite/client"]
},
"include": ["src"]
}

6
vercel.json Normal file
View File

@ -0,0 +1,6 @@
{
"framework": "vite",
"buildCommand": "npm run build",
"installCommand": "npm ci && npm pkg set homepage=\"https://$VERCEL_BRANCH_URL\"",
"outputDirectory": "dist"
}

87
vite.config.ts Normal file
View File

@ -0,0 +1,87 @@
/// <reference types="vitest" />
import path from 'path'
import { fileURLToPath } from 'url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import svgr from 'vite-plugin-svgr'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
import macrosPlugin from 'vite-plugin-babel-macros'
import { VitePWA } from 'vite-plugin-pwa'
import { manifest } from './manifest'
const srcPaths = [
'components',
'config',
'contexts',
'lib',
'models',
'pages',
'services',
'img',
'utils',
'test-utils',
]
const srcPathAliases = srcPaths.reduce((acc, dir) => {
acc[dir] = path.resolve(__dirname, `./src/${dir}`)
return acc
}, {})
const config = () => {
return defineConfig({
build: {
// NOTE: This isn't really working. At the very least, it's still useful
// for exposing source code to users.
// See: https://github.com/vitejs/vite/issues/15012#issuecomment-1956429165
sourcemap: true,
},
plugins: [
svgr({
include: '**/*.svg?react',
}),
react(),
macrosPlugin(),
nodePolyfills({
globals: {
Buffer: true,
global: true,
process: true,
},
protocolImports: true,
}),
VitePWA({
registerType: 'prompt',
devOptions: {
enabled: false,
},
injectRegister: 'auto',
manifest,
}),
],
resolve: {
alias: {
webtorrent: fileURLToPath(
new URL(
'./node_modules/webtorrent/webtorrent.min.js',
import.meta.url
)
),
...srcPathAliases,
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
coverage: {
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/setupTests.ts'],
},
},
})
}
export default config