diff --git a/.env.example b/.env.example index 2ef5254..5b5a695 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,14 @@ NODE_ENV=prod PORT=8080 SOCKETIO_PORT=9000 +# Preserve room membership while clients reconnect after a socket deploy. +ROOM_RECONNECT_GRACE_MS=60000 +# Grand Prix is intentionally disabled until the mode is redesigned. +GRAND_PRIX_ENABLED=false +# Friend System routes remain disabled through the #188 launch gate. +SOCIAL_FEATURES_ENABLED=false +# Manual Compose commands use this tag. scripts/deploy.sh overrides it with +# the full deployed commit SHA. APP_IMAGE_TAG=local # Public URLs used by the client build diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..301fce0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d76d96..0d77f9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,16 @@ on: - master jobs: + configuration: + name: Production configuration checks + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Validate production metrics configuration + run: scripts/test-compose-metrics-config.sh + server: name: Server lint and unit tests runs-on: ubuntu-latest @@ -111,10 +121,10 @@ jobs: run: docker compose -f compose.yml -f compose.dev.yml up -d mongo redis - name: Start server - run: LETSCUBE_TEST_AUTH=true yarn workspace letscube-server node index.js > server.log 2>&1 & + run: LETSCUBE_TEST_AUTH=true SOCIAL_FEATURES_ENABLED=true POSTGRES_ENABLED=false yarn workspace letscube-server node index.js > server.log 2>&1 & - name: Start socket server - run: yarn workspace letscube-server node socket/ > socket.log 2>&1 & + run: SOCIAL_FEATURES_ENABLED=true POSTGRES_ENABLED=false yarn workspace letscube-server node socket/ > socket.log 2>&1 & - name: Wait for local stack run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..700b698 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + pull_request: + push: + branches: + - master + schedule: + - cron: '23 10 * * 1' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze JavaScript + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: javascript-typescript + + - name: Analyze + uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore index c4b0b9f..0b56507 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ client/config/.env.development # Local build/worktree scratch .turbo .worktrees +.privacy-email-cutover diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..4f3315a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint && yarn test diff --git a/AGENTS.md b/AGENTS.md index 54c4edf..deddb60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,73 +1,81 @@ -# Let's Cube Agent Gotchas +# Let's Cube Agent Guidelines -This file is intentionally short. It captures repo-specific traps that are easy -to miss when making changes here. +This file is only for durable, repository-specific instructions that change how +coding agents should work. Feature behavior, architecture, deployment details, +known bugs, and operational procedures belong in normal documentation and tests. +Do not duplicate them here. -## Project Layout +## Find The Source Of Truth -- This is a Yarn classic workspace monorepo. `client/` and `server/` are - workspaces, and the root `yarn.lock` is the only dependency lockfile. -- Root scripts are workspace-aware and mostly run through Turbo. Prefer root - commands like `yarn lint`, `yarn test`, and `yarn build` unless you need a - narrow workspace command. +- Read the relevant README, `docs/` file, tests, configuration, and surrounding + code before changing behavior. Prefer those sources over conversation history + or assumptions from similarly named branches. +- When a change introduces a durable feature rule or operational procedure, + update the owning documentation or tests instead of adding it to this file. +- Keep this file short. Do not use it for task status, backlog notes, temporary + workarounds, or descriptions of individual features. -## Runtime Setup +## Keep Changes Focused -- Local development needs MongoDB and Redis. -- The repo targets Node 22. Install from the repo root with `yarn install`. -- The backend is two separate processes: - - `yarn start:server` starts Express/static/auth/API on port `8080`. - - `yarn start:socket` starts Socket.IO on port `9000`. -- The client runs separately with `yarn start:client`. -- Client env values live in `client/.env.development` and expect the API at - `http://localhost:8080` and Socket.IO at `http://localhost:9000`. +- Match existing project patterns and make the smallest coherent change. The + repository contains legacy and modernized areas side by side, so avoid broad + rewrites or dependency upgrades unless they are part of the task. +- Do not mix cleanup, refactoring, generated-file churn, or unrelated fixes into + a feature branch. Preserve pre-existing user changes and untracked files. +- If the requested direction changes, remove the abandoned implementation before + building the replacement. -## Dependency Age +## Code Structure And Comments -- The stack is old: React 16, Webpack 4, Material UI v4, Socket.IO v3, - Mongoose 6, and a customized CRA/Webpack toolchain. -- Be cautious with modern Node/npm changes. The old CRA/Webpack toolchain is - likely to be the first thing to break. +- Prefer clear names and structure over comments. Add a comment only to explain a + non-obvious invariant, external constraint, compatibility requirement, safety + concern, or intentional tradeoff. Explain why, not what the next line does. +- Do not leave development narration, conversation history, commented-out code, + or vague TODO/FIXME notes. Link actionable follow-up work to an issue, and + update or remove comments when the surrounding behavior changes. +- Keep functions cohesive and easy to understand, but do not mechanically + extract every expression. A function should own a meaningful unit of behavior, + not merely wrap a one-liner unless an interface or callback requires it. +- Treat a convoluted function name as a design smell: simplify the responsibility + or keep the operation inline. Split functions that mix responsibilities or + accumulate difficult branching, without maximizing the number of functions. +- Do not add speculative abstractions, configuration, or fallback paths for + hypothetical future needs. Implement the current contract and make violated + invariants visible instead of silently masking them. -## Socket.IO +## Branch And Worktree Hygiene -- Socket protocol constants live in `client/src/lib/protocol.js` and are also - imported by the server. Update protocol constants, client middleware, and - server namespace handlers together. -- `Protocol.ERROR` intentionally maps to the literal event name `errorrr`. - Do not casually rename it to `error`, which can collide with Socket.IO's own - error behavior. -- Client socket connections are owned by Redux middleware in - `client/src/store/middlewares/`, not by React components. -- Echo-style socket features often need separate incoming and outgoing events. - Reusing one event for both directions can create infinite loops. +- This repository often has several active worktrees and stale local branches. + Confirm the current worktree, branch, status, and merge base before editing. +- Start unrelated work in a fresh branch and worktree so active changes remain + isolated. Unless explicitly told otherwise, fetch first and create the new + worktree from `origin/master`, not from the currently checked-out feature + branch. +- Before publishing, inspect both the commit list and + `git diff --stat origin/master...HEAD`. Rebase or cherry-pick onto the intended + base if the diff contains unrelated work. -## Room State +## Monorepo Discipline -- Room user state uses Mongoose `Map`s keyed by WCA numeric user ids converted - to strings. Preserve that convention when reading or writing maps like - `waitingFor`, `competing`, `banned`, `inRoom`, and `registered`. -- Room data is deliberately masked differently for lobby users and joined users - in `server/socket/namespaces/rooms.js`. -- Normal rooms are marked stale with `expireAt` TTL when empty. Grand Prix rooms - are timer-driven and have different lifecycle behavior. +- This is a Yarn classic workspace monorepo. Install from the repository root, + use the root `yarn.lock`, and do not create workspace-level lockfiles. +- Prefer root Turbo commands for broad checks and workspace-filtered commands + for focused iteration. Follow existing package boundaries rather than adding + cross-workspace source imports. +- Before adding a dependency, check whether the platform or an existing + dependency already solves the problem clearly. Add it only to the workspace + that uses it and explain non-obvious choices. -## Auth And Sessions +## Local Machine Gotcha -- Express sessions are stored in MongoDB and shared with Socket.IO through - `express-socket.io-session`. -- Client API requests use `credentials: 'include'`; auth, CORS, and socket - changes need to preserve cookie behavior. -- WCA OAuth uses the `redirectUri` sent by the client. The mixed - `callbackUrl`/`callbackURL` config spelling is not currently the source of - truth for the auth redirect. +- Use the system Docker engine on this machine. The Docker Desktop context is + unreliable, so inspect the current context and run project Compose commands + with `DOCKER_CONTEXT=default`. -## Checks +## Verification And Handoff -- Tests are sparse. The server test script currently passes when no tests exist. -- The root pre-commit hook runs `yarn lint && yarn test`. -- Useful focused checks: - - `yarn turbo run lint --filter=letscube-client` - - `yarn turbo run test:ci --filter=letscube-client` - - `yarn turbo run lint --filter=letscube-server` - - `yarn turbo run test:ci --filter=letscube-server` +- Run the narrowest meaningful lint and tests while iterating, then broaden + checks when a change crosses workspace or runtime boundaries. +- Before handoff, review the complete diff for scope, run `git diff --check`, + and state exactly which checks passed, failed, or were not run. +- When committing, include a concise body explaining what changed and why. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1766e5..d9e48c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,69 +1,111 @@ -The project is split into 2 parts: the server, and the client. +# Contributing to Let's Cube -# Sending a Pull Request +Thank you for helping improve Let's Cube. Keep changes focused, preserve the +existing behavior outside the task, and include tests when behavior changes. -1. Fork the repo -2. Clone it to your machine +## Before You Start -```bash -git clone git@github.com:/letscube.git -cd letscube -git remote add upstream git@github.com:coder13/letscube.git -``` +Read the [development guide](docs/development.md) and the documentation for the +area being changed. The repository targets Node `22.17.0`, Yarn classic, and +Docker Compose. -3. Sync your local `master` branch with upstream: -```bash -get checkout master -git pull upstream master -``` +Install dependencies once from the repository root: -4. Create a new feature branch: -```bash -git checkout -b feature-branch +```sh +nvm use +corepack enable +yarn install --frozen-lockfile ``` -5. Commit and push the changes: -```bash -git push -u +Do not run separate installs or create lockfiles inside workspaces. + +## Branches + +Create branches from the latest `master`: + +```sh +git fetch origin +git switch -c your-branch origin/master ``` -7. Go to the [repository](https://github.com/coder13/letscube) and make a Pull Request to the `dev` branch. +If you use a fork, replace `origin` with the remote that tracks the canonical +repository. Pull requests should target `master`. + +Keep each pull request limited to one coherent change. Before publishing, +inspect both the commit list and the complete diff against `master` to make sure +the branch does not include work inherited from another feature branch. + +## Code Style -# Installing and Running: +- Follow the surrounding JavaScript and React patterns. +- The client and server use ESLint configurations based on Airbnb's style. +- Prefer small modules organized by responsibility over broad rewrites. +- Add comments only when they explain a non-obvious constraint or decision. +- Avoid unrelated formatting, dependency, or generated-file churn. -`npm install` in the root directory installs the pre-commit hook. +The repository contains modernized and legacy areas side by side. A change does +not need to modernize adjacent code unless that work is part of its purpose. -## to start the bash server: +## Tests And Checks -```bash -cd server/ -npm install -npm start +Run focused checks while iterating: + +```sh +yarn turbo run lint --filter=letscube-client +yarn turbo run test:ci --filter=letscube-client + +yarn turbo run lint --filter=letscube-server +yarn turbo run test:ci --filter=letscube-server + +yarn turbo run lint --filter=letscube-scrambles +yarn turbo run test:ci --filter=letscube-scrambles ``` -## to start the client dev server: +Run the broad checks before requesting review when the change crosses workspace +boundaries: -```bash -cd client/ -npm install -npm start +```sh +yarn lint +yarn test +yarn build ``` -# Pre-commit hook -When installed properly, the pre-commit hook won't let you commit without the client and server being properly linted and tests being ran. +Use `yarn cypress:run` for behavior that spans the browser, API, and Socket.IO +server. See [Development](docs/development.md#full-stack-cypress) for the local +stack expected by Cypress. -# Linting +Every behavior change should have a test at the narrowest useful layer. UI +changes should include screenshots or a short recording in the pull request. -Both the client and the server use eslint and a slight variation of [airbnb's style guide](https://github.com/airbnb/javascript). +## Database Changes + +PostgreSQL schema changes must be represented in Prisma and committed as a +migration. Validate them with: + +```sh +yarn workspace letscube-server postgres:schema:validate +yarn workspace letscube-server postgres:migrate +yarn workspace letscube-server postgres:schema:check +``` -# Tests +Migrations must remain compatible with the previously deployed application +image because application rollback does not reverse database migrations. Read +[Data and migrations](docs/data.md) before changing persistence. -LetsCube is currently using Jest and Enzyme for testing. Right now there is very minimal tests but I would like to add much more coverage in time. For any complicated computational code, it should be tested to make sure there are no errors. +## Pull Requests -# Nit picky stuff: +A pull request should include: -This project uses React: A component should be made whenever something gets too complicated (like with the timesTable) or if it's going to be used repeatedly. +- the problem and intended behavior; +- a concise summary of the implementation; +- the checks that passed, failed, or were not run; +- migration, deployment, or compatibility considerations; +- screenshots for visible UI changes; and +- links to relevant issues. -Redux: I'd like to keep the number of reducers relatively low. Please get in contact with me before adding another reducer. +Write commit subjects in the imperative mood. Include a short commit body that +explains what changed and why. -Socket.IO: A common code pattern I found myself using is socket events to send information from a client and a separate event to echo the data to clients. If not done properly and one event was used for both, an infinite loop would happen. Take this into consideration when creating events that echo to other users. +Do not include credentials, production data, access tokens, or private logs. If +you discover a vulnerability, follow [SECURITY.md](SECURITY.md) instead of +opening a public issue. diff --git a/Dockerfile b/Dockerfile index 0383245..e7bdecb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,8 @@ RUN corepack enable COPY package.json yarn.lock ./ COPY client/package.json ./client/package.json COPY server/package.json ./server/package.json -RUN yarn install --frozen-lockfile +COPY packages/scrambles/package.json ./packages/scrambles/package.json +RUN yarn install --frozen-lockfile --network-concurrency 1 --network-timeout 600000 ARG REACT_APP_API_ORIGIN= ARG REACT_APP_SOCKETIO_ORIGIN= @@ -20,6 +21,7 @@ ENV REACT_APP_SOCKETIO_ORIGIN=${REACT_APP_SOCKETIO_ORIGIN} ENV REACT_APP_WCA_ORIGIN=${REACT_APP_WCA_ORIGIN} ENV REACT_APP_WCA_CLIENT_ID=${REACT_APP_WCA_CLIENT_ID} +COPY packages/scrambles ./packages/scrambles COPY client ./client RUN yarn workspace letscube-client build @@ -34,7 +36,8 @@ RUN corepack enable COPY package.json yarn.lock ./ COPY client/package.json ./client/package.json COPY server/package.json ./server/package.json -RUN yarn install --frozen-lockfile --production=true \ +COPY packages/scrambles/package.json ./packages/scrambles/package.json +RUN yarn install --frozen-lockfile --production=true --network-concurrency 1 --network-timeout 600000 \ && yarn cache clean \ && mkdir -p server/node_modules @@ -55,6 +58,7 @@ COPY --from=server-deps --chown=letscube:letscube /app/node_modules ./node_modul COPY --from=server-deps --chown=letscube:letscube /app/server/node_modules ./server/node_modules COPY --from=client-build --chown=letscube:letscube /app/client/build ./client/build COPY --from=client-build --chown=letscube:letscube /app/client/src/lib ./client/src/lib +COPY --from=client-build --chown=letscube:letscube /app/packages/scrambles ./packages/scrambles USER letscube EXPOSE 8080 9000 diff --git a/README.md b/README.md index 363d1e4..5407740 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,86 @@ # Let's Cube -This is a Progressive Web app written in Node.JS, Express, MongoDB, -PostgreSQL, socket.io, React, and Material UI. +Let's Cube is a progressive web app for racing other cubers in real time. It +combines a React client, an Express API, and a Socket.IO server with shared room +state and scramble generation. -This project consists of a client and a server. +The production site is [letscube.net](https://letscube.net). -## Development: +## Repository Layout -Make sure Node.JS and Docker are installed. Docker Compose starts the local -MongoDB, PostgreSQL, and Redis services used by the app. +- `client/` — React, Redux, Material UI, Vite, and the service worker +- `server/` — Express, authentication, Socket.IO, persistence, and metrics +- `packages/scrambles/` — shared event catalog and scramble provider +- `cypress/` — full-stack browser smoke tests +- `scripts/` — deployment, backup, restore, and deployment tests +- `docs/` — architecture, development, data, realtime, and operations guides -``` -git clone https://github.com/coder13/letscube.git -cd letscube -yarn install +This is a Yarn classic workspace monorepo. Install dependencies from the root +and use the root `yarn.lock`. + +## Quick Start + +Prerequisites are Node `22.17.0`, Yarn `1.22.22`, Docker, and Docker Compose. + +```sh +nvm use +corepack enable +yarn install --frozen-lockfile docker compose -f docker-compose.yml up -d +yarn workspace letscube-server postgres:migrate ``` -The local PostgreSQL service is exposed on port `55433` to avoid collisions -with other projects. MongoDB remains the source of truth while PostgreSQL is a -non-blocking dual-write target. Prisma owns the PostgreSQL schema and migration -history. Apply committed migrations locally with: +Run the three application processes in separate terminals: -```bash -yarn workspace letscube-server postgres:migrate +```sh +yarn start:client # http://localhost:3000 +yarn start:server # http://localhost:8080 +yarn start:socket # http://localhost:9000 ``` -Create schema changes during development with -`yarn workspace letscube-server postgres:migrate:dev`. Production Compose runs -`prisma migrate deploy` as a separate one-shot service, keeping schema changes -out of the API and Socket.IO startup paths. CI applies every committed migration -to PostgreSQL 17 and checks the resulting database for drift from -`server/prisma/schema.prisma`. +The default development configuration uses the WCA staging site. A real WCA +OAuth application is required to exercise the complete login flow. -**Server** +See [Development](docs/development.md) for environment configuration, Docker +alternatives, tests, database migrations, and troubleshooting. -The server is split across 2 processes: +## Documentation -```bash -yarn start:server # Starts the file server, auth, and API requests on port 8080 -yarn start:socket # Run this in a separate terminal for Socket.IO on port 9000 -``` +- [Documentation index](docs/README.md) +- [Architecture](docs/architecture.md) +- [Development](docs/development.md) +- [Realtime behavior](docs/realtime.md) +- [Data and migrations](docs/data.md) +- [Production operations](docs/operations.md) +- [Contributing](CONTRIBUTING.md) +- [Security](SECURITY.md) -**Client** +## Checks -```bash -yarn start:client +```sh +yarn lint +yarn test +yarn build ``` -For more on the internals and contributing, check out the [wiki](https://github.com/coder13/LetsCube/wiki) +The pre-commit hook runs lint and unit tests. Pull requests also run focused +client and server jobs plus a Cypress full-stack smoke test. + +## Identity privacy + +Let's Cube requests only the WCA OAuth `public` scope. WCA profile data is +allowlisted at login, and the application does not request, retain, mirror, +return, log, analyze, or search WCA email addresses or dates of birth. Product +identity uses the WCA numeric user ID internally and exposes a WCA ID only when +the user has explicitly enabled that existing profile preference. + +Any user-discovery feature must accept only its documented username and visible +WCA ID formats. An email-like input must not be treated as an identifier or +produce an existence signal. This is a required invariant for the Friend System, +not a future discovery mode. + +The server's owner/admin handoff and reconnect guarantees are documented in +[Room ownership and administration](docs/room-ownership.md). ## Metrics @@ -71,9 +103,10 @@ access codes, OAuth credentials, chat content, scramble text, or solve times. ## PostgreSQL dual writes New MongoDB writes are mirrored into PostgreSQL without changing application -reads. PostgreSQL receives users and preferences, rooms and participant state, -attempts, durable solve results, and sanitized analytics events. OAuth access -tokens are deliberately not copied. Writes use deterministic UUIDs and upserts, +reads. PostgreSQL receives public identity and preferences, rooms and +participant state, attempts, durable solve results, and sanitized analytics +events. OAuth access tokens are deliberately not copied. Writes use +deterministic UUIDs and upserts, so retries and future backfills are idempotent. Live room saves mirror only the attempts and results changed by that save; complete room snapshots are reserved for explicit backfills. Changing a room event explicitly replaces that room's @@ -91,3 +124,18 @@ TLS connections can set `PGSSL=true` and provide a CA with `PGSSL_CA`; certificate verification is enabled by default. PostgreSQL failures are logged but do not fail the corresponding MongoDB-backed application operation during this migration phase. + +Username lookup uses a separately normalized, uniquely indexed key while +preserving display casing. See [the normalized username migration](docs/username-migration.md) +for the required collision audit, production order, and rollback procedure. + +Friend relationships and directional blocks use revisioned tombstone rows; +strictly older mirrors are ignored so reordered background writes cannot +resurrect a removed relationship or a deactivated block. +Social notifications mirror only numeric recipient and actor references plus +their typed resource metadata; the notification mirror does not read or write +email addresses. + +## License + +Let's Cube is available under the terms in [LICENSE](LICENSE). diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index eba0432..2e38878 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -1,6 +1,6 @@ # LetsCube Deployment -This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `redis`, and `nginx`. +This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `postgres`, `redis`, and `nginx`. ## Current Startup Audit @@ -42,10 +42,16 @@ cp .env.example .env.prod Start or update production: ```bash -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build +APP_IMAGE_TAG="$(git rev-parse HEAD)" \ + docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build ``` -The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB and Redis are internal only. +Using the full commit SHA keeps manually deployed application images +immutable. `APP_IMAGE_TAG=local` remains available for direct development and +one-off Compose use. The deployment script always overrides `APP_IMAGE_TAG` +with the commit it checked out. + +The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB, PostgreSQL, and Redis are internal only. TLS expects Let’s Encrypt files at: @@ -70,6 +76,7 @@ View logs: docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f api docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f socket docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f mongo +docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f postgres docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f redis docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f nginx ``` @@ -87,6 +94,174 @@ Deploy latest code from the server checkout: APP_DIR=/opt/letscube scripts/deploy.sh ``` +The default `DEPLOY_TARGET=auto` chooses the smallest safe application target: + +| Changed paths | Resolved target | +| --- | --- | +| Documentation, CI, or agent metadata only | `none` | +| Browser client only | `api` (which also serves the static client) | +| Shared socket protocol, server, dependency, container, deployment, or unknown files | `all` | + +Override the classifier when the intended runtime impact is known: + +```bash +DEPLOY_TARGET=api APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=socket APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=all APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=none APP_DIR=/opt/letscube scripts/deploy.sh +``` + +Accepted values are `auto`, `api`, `socket`, `all`, and `none`. Explicit +targets are honored even when the checkout is already current. With `auto`, an +unchanged checkout exits without replacing healthy running containers. If API, +socket, or nginx is missing, `auto` bootstraps both application services; +`DEPLOY_FORCE=true` also rebuilds and replaces both at the current commit. +Readiness waits are capped at 60 seconds before automatic rollback; override +that bound with `DEPLOY_WAIT_TIMEOUT_SECONDS` when diagnosing unusually slow +startup. + +You can verify the conservative path classifier without fetching code or +touching Docker: + +```bash +scripts/test-deploy-classifier.sh +scripts/test-deploy.sh +scripts/classify-deploy-target.sh client/src/App.jsx +``` + +For any selected application target, the deploy script builds one shared image +tagged with the full commit SHA, waits for backing services without recreating +them, and applies PostgreSQL migrations before replacing application +containers. It snapshots the exact image ID of every selected running service, +then replaces and health-checks services one at a time. For `all`, socket rolls +out before API/static so the backward-compatible server is ready before the new +browser client is served. nginx is gracefully reloaded only after a selected +service changes, so it resolves the replacement container without dropping +unrelated connections. If nginx is not running during a bootstrap deploy, its +startup is deferred until the selected application services are ready. + +If replacement readiness or the nginx reload fails, recent diagnostic logs are +printed and every application service attempted in that deploy is restored in +reverse order using its exact pre-deploy image and Compose configuration. nginx +is then reloaded again. +Rollback image tags are retained locally for manual recovery until normal image +cleanup. Database migrations are not automatically reversed, so migrations +must remain backward-compatible with the previous application image. + +The checkout remains at the fetched commit after a failed deploy. Correct the +failure and rerun with the explicit target printed by the script; a later +`auto` run with no new commit otherwise treats the checkout as up to date. + +Socket clients briefly reconnect only when `socket` is selected. Room +membership, the current scramble, waiting/competing state, and room admin are +preserved during `ROOM_RECONNECT_GRACE_MS` (60 seconds by default). A client +that reconnects within that window resumes the existing room without being +logically removed first. Only users with no socket on any Socket.IO instance +after the grace window are removed from the room. + +An in-progress solve continues timing in the browser during that reconnect. If +the solve finishes before the socket is ready, the result is retained locally, +the next solve is blocked, and the saved result is submitted only after the +client rejoins the room. The local copy is cleared only after the server +acknowledges persistence, so a refresh or repeated reconnect does not discard +the time. + +Set the grace window in `.env.prod` if production deploys or client reconnects +need more time: + +```bash +ROOM_RECONNECT_GRACE_MS=90000 +``` + +Keep the grace shorter than the time in which an abandoned room should appear +active. Explicit leaves, kicks, and bans remain immediate and do not use the +grace window. + +Grand Prix is intentionally disabled in production: + +```bash +GRAND_PRIX_ENABLED=false +``` + +Keep this setting disabled until the legacy scheduler is redesigned. Setting +it to `true` explicitly restores the old mode for development or controlled +testing. + +## WCA Email Privacy Cutover + +The privacy cutover is deliberately separate from ordinary database migrations. +PostgreSQL migrations run before a new application image is healthy, so using a +migration to purge data could let the automatic rollback restore an image that +writes the data again. + +Perform the cutover in this order: + +1. Deploy this release to both application services with `DEPLOY_TARGET=all`. +2. Confirm the API and socket health checks pass, the browser's WCA authorization + request has exactly `scope=public`, and a new login succeeds. +3. From `/opt/letscube`, record the running safe commit as the rollback floor: + + ```bash + git rev-parse HEAD | tee .privacy-email-cutover + ``` + + Future `scripts/deploy.sh` runs refuse any commit that does not descend from + this floor. Do not use direct Compose commands or a saved image tag to restore + an older release after creating the marker. +4. Run the purge from the newly deployed image: + + ```bash + docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod run --rm --no-deps api node server/privacy/purgeUserEmails.js + ``` + + The command reports record counts only. It never prints field values and + exits unsuccessfully if either database still has a value. +5. Run the same command a second time. Every matched, modified, cleared, and + remaining count should be zero; this verifies that the operation is + idempotent. +6. Create and verify a fresh post-cutover backup. Remove every pre-cutover local + and remote backup according to the storage provider's secure deletion + procedure; normal retention is not sufficient for historical private data. + +If the initial deployment rolls back before step 3, do not create the marker or +run the purge. Correct the release and redeploy it first. After step 3, the +privacy-safe commit is the minimum supported rollback image. + +The PostgreSQL `app.users.email` column remains temporarily as an always-empty +compatibility column. Current dual writes never send it a value and clear a +legacy value whenever they update an existing row. Removing the column is phase two: +first deploy an application release that no longer references the compatibility +column, advance every supported rollback image to that release, and only then +apply a migration that drops it. Do not combine that drop with this cutover. + +The Friend System server surface is disabled by default: + +```bash +SOCIAL_FEATURES_ENABLED=false +``` + +Only set this to `true` during controlled development or after the #188 +launch-hardening gate is complete. The same switch controls REST routes and +their realtime relay; normal room behavior is unaffected. + +### Health Checks + +The API and socket processes expose dependency-aware health endpoints. +Unavailable MongoDB or Redis returns HTTP `503` with the failing check marked +`error`. PostgreSQL dual writes are optional, so a PostgreSQL outage returns +HTTP `200` with overall status `degraded` while the primary MongoDB/Redis path +remains ready. + +```bash +curl -sS https://letscube.net/health/api +curl -sS https://letscube.net/health/socket +``` + +The default Socket.IO namespace also accepts a `health_check` event. It returns +the socket health report through the acknowledgment callback, or emits +`health_status` when no callback is provided. This tests a real Socket.IO +connection in addition to the HTTP readiness endpoint. + ## Backups And Restore Run a MongoDB backup: @@ -122,10 +297,10 @@ Do not upgrade the old VPS in place for this migration. 5. Clone this repo to `/opt/letscube`. 6. Create `.env.prod` with production secrets and domains. 7. Copy or issue Let’s Encrypt certs for `letscube.net`. -8. Start MongoDB and Redis containers. +8. Start MongoDB, PostgreSQL, and Redis containers. 9. Restore MongoDB from the production backup. 10. Start the full production stack. 11. Test frontend loading, login, room create/join, race/session flows, Redis-backed socket behavior, backups, and restore on non-production data. -12. Confirm MongoDB and Redis are not publicly reachable. +12. Confirm MongoDB, PostgreSQL, and Redis are not publicly reachable. 13. Point DNS at the new VPS. 14. Keep the old VPS available for rollback until the new stack has been stable. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fd3f5ad --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +## Supported Version + +Let's Cube is deployed continuously from `master`. Security fixes are made on +the current codebase; older commits and abandoned branches are not supported. + +## Reporting A Vulnerability + +Do not open a public issue for a suspected vulnerability. + +Use GitHub's private vulnerability reporting flow for +[`coder13/LetsCube`](https://github.com/coder13/LetsCube/security/advisories/new). +If that flow is unavailable, contact the repository owner privately and share +only enough information to establish a secure follow-up channel. + +Please include: + +- the affected endpoint or component; +- reproduction steps or a minimal proof of concept; +- the likely impact; +- any preconditions, such as authentication or room membership; and +- suggested mitigations, if known. + +Avoid accessing data that is not yours, disrupting production traffic, or +publishing exploit details before a fix is available. + +## Secrets And Sensitive Data + +- Never commit `.env` files, OAuth credentials, session secrets, database + passwords, TLS private keys, access tokens, or production backups. +- Use placeholders in examples and redact secrets from logs and screenshots. +- Use WCA staging credentials and non-production data for local testing. +- Treat room passwords, access codes, session cookies, OAuth data, email + addresses, and production identifiers as sensitive. +- Production access should use individual authenticated accounts and the + least privilege needed for the task. + +See [Data and migrations](docs/data.md) for stored-data boundaries and +[Production operations](docs/operations.md) for backup and deployment safety. diff --git a/client/.eslintignore b/client/.eslintignore deleted file mode 100644 index 12a8fb3..0000000 --- a/client/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -build/** -config/** -scripts/** \ No newline at end of file diff --git a/client/.eslintrc b/client/.eslintrc deleted file mode 100644 index 2ae4b20..0000000 --- a/client/.eslintrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": [ - "react-app", - "airbnb", - "plugin:jsx-a11y/recommended" - ], - "plugins": [ - "jsx-a11y" - ], - "rules": { - "semi": 1, - "no-underscore-dangle": 0, - "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], - "no-console": "warn", - "react/jsx-props-no-spreading": [1, { - "custom": "ignore" - }] - } -} \ No newline at end of file diff --git a/client/babel.config.cjs b/client/babel.config.cjs new file mode 100644 index 0000000..9349078 --- /dev/null +++ b/client/babel.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + ['@babel/preset-react', { runtime: 'automatic' }], + ], +}; diff --git a/client/config/env.js b/client/config/env.js deleted file mode 100644 index 856fc7d..0000000 --- a/client/config/env.js +++ /dev/null @@ -1,93 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const paths = require('./paths'); - -// Make sure that including paths.js after env.js will read .env variables. -delete require.cache[require.resolve('./paths')]; - -const NODE_ENV = process.env.NODE_ENV; -if (!NODE_ENV) { - throw new Error( - 'The NODE_ENV environment variable is required but was not specified.' - ); -} - -// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use -const dotenvFiles = [ - `${paths.dotenv}.${NODE_ENV}.local`, - `${paths.dotenv}.${NODE_ENV}`, - // Don't include `.env.local` for `test` environment - // since normally you expect tests to produce the same - // results for everyone - NODE_ENV !== 'test' && `${paths.dotenv}.local`, - paths.dotenv, -].filter(Boolean); - -// Load environment variables from .env* files. Suppress warnings using silent -// if this file is missing. dotenv will never modify any environment variables -// that have already been set. Variable expansion is supported in .env files. -// https://github.com/motdotla/dotenv -// https://github.com/motdotla/dotenv-expand -dotenvFiles.forEach(dotenvFile => { - if (fs.existsSync(dotenvFile)) { - require('dotenv-expand')( - require('dotenv').config({ - path: dotenvFile, - }) - ); - } -}); - -// We support resolving modules according to `NODE_PATH`. -// This lets you use absolute paths in imports inside large monorepos: -// https://github.com/facebook/create-react-app/issues/253. -// It works similar to `NODE_PATH` in Node itself: -// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders -// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. -// Otherwise, we risk importing Node.js core modules into the browser bundle. -// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 -// We also resolve them to make sure all tools using them work consistently. -const appDirectory = fs.realpathSync(process.cwd()); -process.env.NODE_PATH = (process.env.NODE_PATH || '') - .split(path.delimiter) - .filter(folder => folder && !path.isAbsolute(folder)) - .map(folder => path.resolve(appDirectory, folder)) - .join(path.delimiter); - -// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be -// injected into the application by the frontend build configuration. -const REACT_APP = /^REACT_APP_/i; - -function getClientEnvironment(publicUrl) { - const raw = Object.keys(process.env) - .filter(key => REACT_APP.test(key)) - .reduce( - (env, key) => { - env[key] = process.env[key]; - return env; - }, - { - // Useful for determining whether we’re running in production mode. - // Most importantly, it switches React into the correct mode. - NODE_ENV: process.env.NODE_ENV || 'development', - // Useful for resolving the correct path to static assets in `public`. - // For example, . - // This should only be used as an escape hatch. Normally you would put - // images into the `src` and `import` them in code to get their paths. - PUBLIC_URL: publicUrl, - } - ); - // Stringify all values for Jest's Babel-based test environment. - const stringified = { - 'process.env': Object.keys(raw).reduce((env, key) => { - env[key] = JSON.stringify(raw[key]); - return env; - }, {}), - }; - - return { raw, stringified }; -} - -module.exports = getClientEnvironment; diff --git a/client/config/jest/cssTransform.js b/client/config/jest/cssTransform.js deleted file mode 100644 index 8f65114..0000000 --- a/client/config/jest/cssTransform.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -// This is a custom Jest transformer turning style imports into empty objects. -// http://facebook.github.io/jest/docs/en/webpack.html - -module.exports = { - process() { - return 'module.exports = {};'; - }, - getCacheKey() { - // The output is always the same. - return 'cssTransform'; - }, -}; diff --git a/client/config/jest/fileTransform.js b/client/config/jest/fileTransform.js deleted file mode 100644 index aab6761..0000000 --- a/client/config/jest/fileTransform.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -const path = require('path'); -const camelcase = require('camelcase'); - -// This is a custom Jest transformer turning file imports into filenames. -// http://facebook.github.io/jest/docs/en/webpack.html - -module.exports = { - process(src, filename) { - const assetFilename = JSON.stringify(path.basename(filename)); - - if (filename.match(/\.svg$/)) { - // Based on how SVGR generates a component name: - // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 - const pascalCaseFilename = camelcase(path.parse(filename).name, { - pascalCase: true, - }); - const componentName = `Svg${pascalCaseFilename}`; - return `const React = require('react'); - module.exports = { - __esModule: true, - default: ${assetFilename}, - ReactComponent: React.forwardRef(function ${componentName}(props, ref) { - return { - $$typeof: Symbol.for('react.element'), - type: 'svg', - ref: ref, - key: null, - props: Object.assign({}, props, { - children: ${assetFilename} - }) - }; - }), - };`; - } - - return `module.exports = ${assetFilename};`; - }, -}; diff --git a/client/config/paths.js b/client/config/paths.js deleted file mode 100644 index a53d246..0000000 --- a/client/config/paths.js +++ /dev/null @@ -1,90 +0,0 @@ -'use strict'; - -const path = require('path'); -const fs = require('fs'); -const url = require('url'); - -// Make sure any symlinks in the project folder are resolved: -// https://github.com/facebook/create-react-app/issues/637 -const appDirectory = fs.realpathSync(process.cwd()); -const resolveApp = relativePath => path.resolve(appDirectory, relativePath); - -const envPublicUrl = process.env.PUBLIC_URL; - -function ensureSlash(inputPath, needsSlash) { - const hasSlash = inputPath.endsWith('/'); - if (hasSlash && !needsSlash) { - return inputPath.substr(0, inputPath.length - 1); - } else if (!hasSlash && needsSlash) { - return `${inputPath}/`; - } else { - return inputPath; - } -} - -const getPublicUrl = appPackageJson => - envPublicUrl || require(appPackageJson).homepage; - -// We use `PUBLIC_URL` environment variable or "homepage" field to infer -// "public path" at which the app is served. -// The build needs to know it to put the right asset hrefs into HTML even in -// single-page apps that may serve index.html for nested URLs like /todos/42. -// We can't use a relative path in HTML because we don't want to load something -// like /todos/42/static/js/bundle.7289d.js. We have to know the root. -function getServedPath(appPackageJson) { - const publicUrl = getPublicUrl(appPackageJson); - const servedUrl = - envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/'); - return ensureSlash(servedUrl, true); -} - -const moduleFileExtensions = [ - 'web.mjs', - 'mjs', - 'web.js', - 'js', - 'web.ts', - 'ts', - 'web.tsx', - 'tsx', - 'json', - 'web.jsx', - 'jsx', -]; - -// Resolve file paths in the order expected by the test tooling. -const resolveModule = (resolveFn, filePath) => { - const extension = moduleFileExtensions.find(extension => - fs.existsSync(resolveFn(`${filePath}.${extension}`)) - ); - - if (extension) { - return resolveFn(`${filePath}.${extension}`); - } - - return resolveFn(`${filePath}.js`); -}; - -// config after eject: we're in ./config/ -module.exports = { - dotenv: resolveApp('.env'), - appPath: resolveApp('.'), - appBuild: resolveApp('build'), - appPublic: resolveApp('public'), - appHtml: resolveApp('index.html'), - appIndexJs: resolveModule(resolveApp, 'src/index'), - appPackageJson: resolveApp('package.json'), - appSrc: resolveApp('src'), - appTsConfig: resolveApp('tsconfig.json'), - appJsConfig: resolveApp('jsconfig.json'), - yarnLockFile: resolveApp('yarn.lock'), - testsSetup: resolveModule(resolveApp, 'src/setupTests'), - proxySetup: resolveApp('src/setupProxy.js'), - appNodeModules: resolveApp('node_modules'), - publicUrl: getPublicUrl(resolveApp('package.json')), - servedPath: getServedPath(resolveApp('package.json')), -}; - - - -module.exports.moduleFileExtensions = moduleFileExtensions; diff --git a/client/eslint.config.cjs b/client/eslint.config.cjs new file mode 100644 index 0000000..cdf94b2 --- /dev/null +++ b/client/eslint.config.cjs @@ -0,0 +1,62 @@ +const js = require('@eslint/js'); +const globals = require('globals'); +const importPlugin = require('eslint-plugin-import'); +const jsxA11y = require('eslint-plugin-jsx-a11y'); +const react = require('eslint-plugin-react'); +const reactHooks = require('eslint-plugin-react-hooks'); + +module.exports = [ + { + ignores: ['build/**'], + }, + js.configs.recommended, + { + files: ['src/**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 'latest', + globals: { + ...globals.browser, + ...globals.jest, + module: 'readonly', + process: 'readonly', + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + sourceType: 'module', + }, + plugins: { + import: importPlugin, + 'jsx-a11y': jsxA11y, + react, + 'react-hooks': reactHooks, + }, + rules: { + ...importPlugin.configs.recommended.rules, + ...jsxA11y.configs.recommended.rules, + ...react.configs.recommended.rules, + 'import/no-unresolved': ['error', { ignore: ['^react-ga4$'] }], + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/rules-of-hooks': 'error', + 'no-console': 'warn', + 'no-underscore-dangle': 'off', + 'jsx-a11y/no-autofocus': 'off', + 'react/jsx-filename-extension': ['warn', { extensions: ['.js', '.jsx'] }], + 'react/jsx-props-no-spreading': ['warn', { custom: 'ignore' }], + 'react/react-in-jsx-scope': 'off', + semi: ['warn', 'always'], + }, + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.mjs'], + }, + }, + react: { + version: 'detect', + }, + }, + }, +]; diff --git a/client/jest.config.cjs b/client/jest.config.cjs new file mode 100644 index 0000000..9797e12 --- /dev/null +++ b/client/jest.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + clearMocks: true, + collectCoverageFrom: ['src/**/*.{js,jsx}', '!src/custom-service-worker.js'], + moduleNameMapper: { + '\\.(css|less|sass|scss)$': 'identity-obj-proxy', + '\\.(mp3|ogg)$': '/test/fileMock.js', + }, + roots: ['/src'], + setupFilesAfterEnv: ['/src/setupTests.js'], + testEnvironment: 'jsdom', + transform: { + '^.+\\.[jt]sx?$': 'babel-jest', + }, +}; diff --git a/client/package.json b/client/package.json index d4f12c8..9cdc5f6 100644 --- a/client/package.json +++ b/client/package.json @@ -3,28 +3,28 @@ "version": "2.0.0", "private": true, "dependencies": { - "@material-ui/core": "^4.11.2", - "@material-ui/icons": "^4.11.2", - "@material-ui/lab": "4.0.0-alpha.46", - "@material-ui/styles": "^4.11.5", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^6.5.0", + "@mui/material": "^6.5.0", + "@mui/styles": "^6.5.0", "clsx": "1.1.0", - "connected-react-router": "6.8.0", "date-fns": "^2.16.1", "history": "4.10.1", - "material-ui-confirm": "2.0.4", - "prop-types": "15.7.2", + "material-ui-confirm": "^4.0.0", + "prop-types": "15.8.1", "qs": "^6.9.6", - "react": "^16.14.0", - "react-cube-svg": "^0.1.3", - "react-dom": "^16.14.0", - "react-ga": "2.7.0", + "react": "18.3.1", + "react-cube-svg": "^0.2.3", + "react-dom": "18.3.1", + "react-ga4": "^3.0.1", "react-markdown": "6.0.3", - "react-redux": "^7.2.2", - "react-router-dom": "^5.2.0", - "react-twitch-embed": "^2.1.0", - "react-virtualized": "^9.22.3", - "redux": "^4.0.5", - "redux-thunk": "^2.3.0", + "react-redux": "^8.1.3", + "react-router-dom": "^5.3.4", + "react-twitch-embed": "^3.0.2", + "react-virtualized": "^9.22.6", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", "reselect": "^4.0.0", "socket.io-client": "^4.8.3", "stackmat-signal-processor": "^0.1.0", @@ -35,14 +35,11 @@ "start": "vite", "build": "vite build", "preview": "vite preview", - "test": "node scripts/test.js", - "test:ci": "CI=true node scripts/test.js --watchAll=false --passWithNoTests", + "test": "jest", + "test:ci": "jest --ci --passWithNoTests", "lint": "eslint ./src/", "lint:fix": "eslint --fix ./src/" }, - "eslintConfig": { - "extends": "react-app" - }, "browserslist": { "production": [ ">0.2%", @@ -55,96 +52,28 @@ "last 1 safari version" ] }, - "jest": { - "roots": [ - "/src" - ], - "collectCoverageFrom": [ - "src/**/*.{js,jsx,ts,tsx}", - "!src/**/*.d.ts" - ], - "setupFiles": [ - "react-app-polyfill/jsdom" - ], - "setupFilesAfterEnv": [ - "/src/setupTests.js" - ], - "testMatch": [ - "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", - "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" - ], - "testEnvironment": "jest-environment-jsdom-fourteen", - "transform": { - "^.+\\.(js|jsx|ts|tsx)$": "/node_modules/babel-jest", - "^.+\\.css$": "/config/jest/cssTransform.js", - "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "/config/jest/fileTransform.js" - }, - "transformIgnorePatterns": [ - "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$", - "^.+\\.module\\.(css|sass|scss)$" - ], - "modulePaths": [ - "/usr/lib/nodejs", - "/usr/lib/node_modules", - "/usr/share/javascript" - ], - "moduleNameMapper": { - "^react-native$": "react-native-web", - "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy" - }, - "moduleFileExtensions": [ - "web.js", - "js", - "web.ts", - "ts", - "web.tsx", - "tsx", - "json", - "web.jsx", - "jsx", - "node" - ], - "watchPlugins": [ - "jest-watch-typeahead/filename", - "jest-watch-typeahead/testname" - ] - }, - "babel": { - "presets": [ - "react-app" - ] - }, "devDependencies": { - "@babel/core": "7.7.4", - "@testing-library/jest-dom": "4.2.4", - "@testing-library/react": "9.4.0", - "@testing-library/user-event": "7.2.1", - "@typescript-eslint/eslint-plugin": "^2.34.0", - "@typescript-eslint/parser": "^2.34.0", + "@babel/core": "^7.28.0", + "@babel/preset-env": "^7.28.0", + "@babel/preset-react": "^7.27.1", + "@eslint/js": "^9.39.4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "6.0.3", - "babel-eslint": "10.0.3", - "babel-jest": "^24.9.0", - "babel-preset-react-app": "^9.1.2", - "camelcase": "^5.3.1", - "dotenv": "8.2.0", - "dotenv-expand": "5.1.0", - "enzyme": "3.11.0", - "enzyme-adapter-react-16": "1.15.2", - "eslint": "^6.6.0", - "eslint-config-airbnb": "18.1.0", - "eslint-config-react-app": "^5.2.1", - "eslint-plugin-flowtype": "3.13.0", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jsx-a11y": "6.2.3", - "eslint-plugin-prettier": "3.1.2", + "babel-jest": "^30.4.1", + "eslint": "^9.39.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^1.6.1", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.7.0", "identity-obj-proxy": "3.0.0", - "jest": "^26.6.3", - "jest-environment-jsdom-fourteen": "^1.0.1", - "jest-resolve": "^26.6.2", - "jest-watch-typeahead": "^0.6.1", - "react-app-polyfill": "^1.0.6", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", "vite": "8.1.4", "vite-plugin-pwa": "1.3.0", "workbox-build": "^7.4.1", diff --git a/client/scripts/test.js b/client/scripts/test.js deleted file mode 100644 index b57cb38..0000000 --- a/client/scripts/test.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -// Do this as the first thing so that any code reading it knows the right env. -process.env.BABEL_ENV = 'test'; -process.env.NODE_ENV = 'test'; -process.env.PUBLIC_URL = ''; - -// Makes the script crash on unhandled rejections instead of silently -// ignoring them. In the future, promise rejections that are not handled will -// terminate the Node.js process with a non-zero exit code. -process.on('unhandledRejection', err => { - throw err; -}); - -// Ensure environment variables are read. -require('../config/env'); - - -const jest = require('jest'); -const execSync = require('child_process').execSync; -let argv = process.argv.slice(2); - -function isInGitRepository() { - try { - execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); - return true; - } catch (e) { - return false; - } -} - -function isInMercurialRepository() { - try { - execSync('hg --cwd . root', { stdio: 'ignore' }); - return true; - } catch (e) { - return false; - } -} - -// Watch unless on CI or explicitly running all tests -if ( - !process.env.CI && - argv.indexOf('--watchAll') === -1 && - argv.indexOf('--watchAll=false') === -1 -) { - // https://github.com/facebook/create-react-app/issues/5210 - const hasSourceControl = isInGitRepository() || isInMercurialRepository(); - argv.push(hasSourceControl ? '--watch' : '--watchAll'); -} - - -jest.run(argv); diff --git a/client/src/components/AccountMenu.jsx b/client/src/components/AccountMenu.jsx new file mode 100644 index 0000000..0b84652 --- /dev/null +++ b/client/src/components/AccountMenu.jsx @@ -0,0 +1,186 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { makeStyles } from '@mui/styles'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import Avatar from '@mui/material/Avatar'; +import Divider from '@mui/material/Divider'; +import Drawer from '@mui/material/Drawer'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import ExitToAppIcon from '@mui/icons-material/ExitToApp'; +import PeopleIcon from '@mui/icons-material/People'; +import PersonIcon from '@mui/icons-material/Person'; +import { isFeatureEnabled } from '../lib/features'; + +const useStyles = makeStyles((theme) => ({ + desktopPaper: { + minWidth: '17rem', + }, + accountHeader: { + alignItems: 'center', + display: 'flex', + padding: theme.spacing(2), + }, + avatar: { + height: theme.spacing(5), + marginRight: theme.spacing(1.5), + width: theme.spacing(5), + }, + desktopItem: { + minHeight: theme.spacing(6), + }, + menuIcon: { + minWidth: theme.spacing(5), + }, + drawerPaper: { + borderRadius: `${theme.spacing(2)}px ${theme.spacing(2)}px 0 0`, + }, + drawerHandle: { + backgroundColor: theme.palette.divider, + borderRadius: theme.spacing(1), + height: theme.spacing(0.5), + margin: theme.spacing(1.25, 'auto', 0), + width: theme.spacing(5), + }, + drawerItem: { + minHeight: theme.spacing(7), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + }, +})); + +const accountName = (user) => user.displayName || user.name || user.username || 'Your account'; + +function AccountHeader({ user }) { + const classes = useStyles(); + const name = accountName(user); + + return ( +
+ + {name.charAt(0).toUpperCase()} + +
+ {name} + Account +
+
+ ); +} + +AccountHeader.propTypes = { + user: PropTypes.shape({ + avatar: PropTypes.shape({ thumb_url: PropTypes.string }), + displayName: PropTypes.string, + name: PropTypes.string, + username: PropTypes.string, + }).isRequired, +}; + +function AccountItems({ itemComponent: Item, itemClassName, onClose, onLogout }) { + const classes = useStyles(); + + return ( + <> + {isFeatureEnabled('friends') && ( + + + + + )} + + + + + + + + + + + ); +} + +AccountItems.propTypes = { + itemClassName: PropTypes.string.isRequired, + itemComponent: PropTypes.elementType.isRequired, + onClose: PropTypes.func.isRequired, + onLogout: PropTypes.func.isRequired, +}; + +function AccountMenu({ anchorEl, onClose, onLogout, open, user }) { + const classes = useStyles(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + if (isMobile) { + return ( + +
+ + + + + + + ); + } + + return ( + + + + + + ); +} + +AccountMenu.propTypes = { + anchorEl: PropTypes.shape(), + onClose: PropTypes.func.isRequired, + onLogout: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, + user: PropTypes.shape({ + avatar: PropTypes.shape({ thumb_url: PropTypes.string }), + displayName: PropTypes.string, + name: PropTypes.string, + username: PropTypes.string, + }).isRequired, +}; + +AccountMenu.defaultProps = { + anchorEl: null, +}; + +export default AccountMenu; diff --git a/client/src/components/Admin/RoomCard.jsx b/client/src/components/Admin/RoomCard.jsx index 6dc0fd2..2e5218c 100644 --- a/client/src/components/Admin/RoomCard.jsx +++ b/client/src/components/Admin/RoomCard.jsx @@ -1,15 +1,15 @@ import React, { useState, useRef } from 'react'; import { useDispatch } from 'react-redux'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import PropTypes from 'prop-types'; -import Card from '@material-ui/core/Card'; -import CardContent from '@material-ui/core/CardContent'; -import CardHeader from '@material-ui/core/CardHeader'; -import Typography from '@material-ui/core/Typography'; -import IconButton from '@material-ui/core/IconButton'; -import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardHeader from '@mui/material/CardHeader'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useConfirm } from 'material-ui-confirm'; import { deleteRoom } from '../../store/room/actions'; import { createMessage } from '../../store/messages/actions'; diff --git a/client/src/components/Admin/index.jsx b/client/src/components/Admin/index.jsx index 6817656..abd0832 100644 --- a/client/src/components/Admin/index.jsx +++ b/client/src/components/Admin/index.jsx @@ -1,10 +1,10 @@ import React, { useEffect } from 'react'; import { useDispatch, connect } from 'react-redux'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import PropTypes from 'prop-types'; -import Container from '@material-ui/core/Container'; -import Paper from '@material-ui/core/Paper'; -import Button from '@material-ui/core/Button'; +import Container from '@mui/material/Container'; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; import { fetchAdminData } from '../../store/admin/actions'; import RoomCard from './RoomCard'; diff --git a/client/src/components/App.jsx b/client/src/components/App.jsx index f9b3e18..300edae 100644 --- a/client/src/components/App.jsx +++ b/client/src/components/App.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Provider } from 'react-redux'; -import { ConnectedRouter } from 'connected-react-router'; -import CssBaseline from '@material-ui/core/CssBaseline'; +import { Router } from 'react-router-dom'; +import CssBaseline from '@mui/material/CssBaseline'; import { ConfirmProvider } from 'material-ui-confirm'; import { ThemeProvider } from '../theme'; import store from '../store'; @@ -11,14 +11,14 @@ import Navigation from './Navigation'; function App() { return ( - + - + ); } diff --git a/client/src/components/App.test.jsx b/client/src/components/App.test.jsx index b08ffa2..ef6d65a 100644 --- a/client/src/components/App.test.jsx +++ b/client/src/components/App.test.jsx @@ -1,7 +1,21 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import App from './App'; +jest.mock('react-ga4', () => ({ + __esModule: true, + default: { + initialize: jest.fn(), + send: jest.fn(), + }, +})); + +jest.mock('./Navigation', () => function MockNavigation() { + return
; +}); + it('renders without crashing', () => { - shallow(); + render(); + + expect(screen.getByTestId('navigation')).toBeInTheDocument(); }); diff --git a/client/src/components/Footer.jsx b/client/src/components/Footer.jsx index 83980d0..045ef52 100644 --- a/client/src/components/Footer.jsx +++ b/client/src/components/Footer.jsx @@ -2,18 +2,19 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; import { Link as ReactRouterLink } from 'react-router-dom'; import PropTypes from 'prop-types'; -import { makeStyles, useTheme } from '@material-ui/core/styles'; -import Grid from '@material-ui/core/Grid'; -import Paper from '@material-ui/core/Paper'; -import Typography from '@material-ui/core/Typography'; -import Link from '@material-ui/core/Link'; -import Popover from '@material-ui/core/Popover'; -import Tooltip from '@material-ui/core/Tooltip'; -import IconButton from '@material-ui/core/IconButton'; -import EmojiObjectsIcon from '@material-ui/icons/EmojiObjects'; -import EmojiObjectsOutlinedIcon from '@material-ui/icons/EmojiObjectsOutlined'; -import CloudIcon from '@material-ui/icons/Cloud'; -import CloudOffIcon from '@material-ui/icons/CloudOff'; +import { useTheme } from '@mui/material/styles'; +import { makeStyles } from '@mui/styles'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; +import Popover from '@mui/material/Popover'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; +import EmojiObjectsIcon from '@mui/icons-material/EmojiObjects'; +import EmojiObjectsOutlinedIcon from '@mui/icons-material/EmojiObjectsOutlined'; +import CloudIcon from '@mui/icons-material/Cloud'; +import CloudOffIcon from '@mui/icons-material/CloudOff'; import { version } from '../../package.json'; import { useToggleTheme } from '../theme'; @@ -86,7 +87,7 @@ function Footer({ onClick={toggleTheme} aria-label="Toggle theme" > - { theme.palette.type === 'dark' + { theme.palette.mode === 'dark' ? : } diff --git a/client/src/components/Friends.jsx b/client/src/components/Friends.jsx new file mode 100644 index 0000000..ad26c3e --- /dev/null +++ b/client/src/components/Friends.jsx @@ -0,0 +1,331 @@ +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Divider from '@mui/material/Divider'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemText from '@mui/material/ListItemText'; +import Paper from '@mui/material/Paper'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { makeStyles } from '@mui/styles'; +import { lcFetch } from '../lib/fetch'; +import createRequestSequence from '../lib/requestSequence'; +import { createRoom } from '../store/rooms/actions'; + +const useStyles = makeStyles((theme) => ({ + root: { + paddingTop: theme.spacing(4), + }, + heading: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacing(2), + }, + section: { + padding: theme.spacing(2, 0), + }, + sectionTitle: { + padding: theme.spacing(0, 2), + }, + empty: { + padding: theme.spacing(1, 2), + }, + action: { + marginLeft: theme.spacing(1), + }, + profileLink: { + color: 'inherit', + textDecoration: 'none', + }, + search: { + display: 'flex', + gap: theme.spacing(1), + marginBottom: theme.spacing(2), + }, + searchField: { + flexGrow: 1, + }, + dialogError: { + marginBottom: theme.spacing(1), + }, +})); + +const userName = (user) => user.displayName || user.username || 'Unknown cuber'; + +const errorMessage = async (response, fallback) => { + try { + const body = await response.json(); + return body.message || fallback; + } catch { + return fallback; + } +}; + +function FriendEntry({ action, entry, secondary }) { + const classes = useStyles(); + const user = entry.user; + const name = user.profileKey + ? {userName(user)} + : userName(user); + + return ( + + + + + + {action} + + ); +} + +FriendEntry.propTypes = { + action: PropTypes.node, + entry: PropTypes.shape({ + user: PropTypes.shape({ + avatar: PropTypes.shape({ thumb_url: PropTypes.string }), + displayName: PropTypes.string, + profileKey: PropTypes.string, + username: PropTypes.string, + }).isRequired, + }).isRequired, + secondary: PropTypes.string.isRequired, +}; + +FriendEntry.defaultProps = { + action: null, +}; + +function FriendsSection({ children, emptyMessage, title }) { + const classes = useStyles(); + const entries = React.Children.toArray(children).filter(Boolean); + + return ( +
+ {title} + {entries.length ? {entries} : {emptyMessage}} +
+ ); +} + +FriendsSection.propTypes = { + children: PropTypes.node, + emptyMessage: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, +}; + +FriendsSection.defaultProps = { + children: null, +}; + +function AddFriendDialog({ onClose, onRequest, open }) { + const classes = useStyles(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + const [searching, setSearching] = useState(false); + const searchRequests = useRef(createRequestSequence()); + + useEffect(() => () => searchRequests.current.invalidate(), []); + + const search = (event) => { + event.preventDefault(); + setError(null); + setSearching(true); + const request = searchRequests.current.start(); + lcFetch(`/api/users/search?q=${encodeURIComponent(query)}`, { signal: request.signal }) + .then(async (response) => { + if (!response.ok) throw new Error(await errorMessage(response, 'Unable to search for cubers')); + return response.json(); + }) + .then((data) => { + if (request.isCurrent()) setResults(data.results || []); + }) + .catch((searchError) => { + if (request.isCurrent() && searchError.name !== 'AbortError') setError(searchError.message); + }) + .finally(() => { + if (request.isCurrent()) setSearching(false); + }); + }; + + const requestFriend = (user) => { + setError(null); + onRequest(user) + .then(() => onClose()) + .catch((requestError) => setError(requestError.message)); + }; + + return ( + + Add friend + + + Search by username or a visible WCA ID. + +
+ setQuery(event.target.value)} + value={query} + /> + + + {error && {error}} + {searching && } + {!searching && results.length > 0 && ( + + {results.map((user) => ( + + + + + + + + ))} + + )} +
+ + + +
+ ); +} + +AddFriendDialog.propTypes = { + onClose: PropTypes.func.isRequired, + onRequest: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, +}; + +function Friends() { + const classes = useStyles(); + const dispatch = useDispatch(); + const [data, setData] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const loadFriends = useCallback(() => { + setLoading(true); + setError(null); + return lcFetch('/api/friends') + .then(async (response) => { + if (!response.ok) throw new Error(await errorMessage(response, 'Unable to load friends')); + return response.json(); + }) + .then((response) => setData(response)) + .catch((loadError) => setError(loadError.message)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { loadFriends(); }, [loadFriends]); + + const performAction = (url, method, body) => lcFetch(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }).then(async (response) => { + if (!response.ok) throw new Error(await errorMessage(response, 'Unable to update friendship')); + return loadFriends(); + }); + + const requestFriend = (user) => performAction('/api/friends/requests', 'POST', { userId: user.id }); + const raceWith = (user) => { + dispatch(createRoom({ + name: `Race with ${userName(user)}`, + raceWithUserId: user.id, + type: 'normal', + })); + }; + const handleAction = (url, method, body) => { + performAction(url, method, body).catch((actionError) => setError(actionError.message)); + }; + + return ( + +
+
+ Friends + Manage your cubing connections. +
+ +
+ + {loading &&
} + {error && {error}} + {!loading && data && ( + <> + + {data.incoming.map((entry) => ( + + + + + )} + entry={entry} + key={entry.user.id} + secondary="Wants to be friends" + /> + ))} + + + + {data.outgoing.map((entry) => ( + handleAction(`/api/friends/requests/${entry.user.id}`, 'DELETE')}>Cancel} + entry={entry} + key={entry.user.id} + secondary="Waiting for a response" + /> + ))} + + + + {data.friends.map((entry) => ( + + + + + )} + entry={entry} + key={entry.user.id} + secondary="Friends" + /> + ))} + + + )} +
+ setDialogOpen(false)} + onRequest={requestFriend} + open={dialogOpen} + /> +
+ ); +} + +export default Friends; diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index a30e337..f3e0568 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -1,20 +1,22 @@ import React from 'react'; -import qs from 'qs'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -import { makeStyles } from '@material-ui/core/styles'; -import Grid from '@material-ui/core/Grid'; -import AppBar from '@material-ui/core/AppBar'; -import Typography from '@material-ui/core/Typography'; -import Toolbar from '@material-ui/core/Toolbar'; -import IconButton from '@material-ui/core/IconButton'; -import Avatar from '@material-ui/core/Avatar'; -import Menu from '@material-ui/core/Menu'; -import Button from '@material-ui/core/Button'; -import MenuItem from '@material-ui/core/MenuItem'; -import { apiOrigin } from '../lib/fetch'; +import { makeStyles } from '@mui/styles'; +import Grid from '@mui/material/Grid'; +import AppBar from '@mui/material/AppBar'; +import Typography from '@mui/material/Typography'; +import Toolbar from '@mui/material/Toolbar'; +import IconButton from '@mui/material/IconButton'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Badge from '@mui/material/Badge'; +import { lcFetch } from '../lib/fetch'; import { getNameFromId } from '../lib/events'; +import { getWcaAuthorizationUrl } from '../lib/wcaAuth'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import AccountMenu from './AccountMenu'; +import NotificationPopover from './Notifications/NotificationPopover'; const useStyles = makeStyles(() => ({ root: { @@ -42,10 +44,11 @@ const useStyles = makeStyles(() => ({ }, })); -function Header({ user, room }) { +function Header({ user, room, notifications }) { const loggedIn = !!user.id; const classes = useStyles(); const [anchorEl, setAnchorEl] = React.useState(null); + const [notificationAnchorEl, setNotificationAnchorEl] = React.useState(null); const open = Boolean(anchorEl); const handleMenu = (event) => { @@ -56,19 +59,22 @@ function Header({ user, room }) { setAnchorEl(null); }; + const closeNotifications = () => setNotificationAnchorEl(null); + const login = () => { - localStorage.setItem('letscube.redirect_uri', `${document.location.origin}/wca-redirect`); - const url = `${process.env.REACT_APP_WCA_ORIGIN}/oauth/authorize?${qs.stringify({ - response_type: 'code', - scope: 'public dob email', - redirect_uri: `${document.location.origin}/wca-redirect`, - client_id: process.env.REACT_APP_WCA_CLIENT_ID, - })}`; + const redirectUri = `${document.location.origin}/wca-redirect`; + localStorage.setItem('letscube.redirect_uri', redirectUri); + const url = getWcaAuthorizationUrl({ + origin: process.env.REACT_APP_WCA_ORIGIN, + clientId: process.env.REACT_APP_WCA_CLIENT_ID, + redirectUri, + }); window.location = url; }; const logout = () => { - window.location = `${apiOrigin || ''}/auth/logout?redirect=${document.location.origin}/`; + lcFetch('/auth/logout', { method: 'POST' }) + .finally(() => { window.location = `${document.location.origin}/`; }); }; return ( @@ -93,27 +99,44 @@ function Header({ user, room }) { { loggedIn ? (
- + {notifications.enabled && ( + <> + setNotificationAnchorEl(event.currentTarget)} + > + + + + + + + )} + - - Profile - Log out - + onLogout={logout} + open={open} + user={user} + />
) : } @@ -126,6 +149,8 @@ Header.propTypes = { user: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, + displayName: PropTypes.string, + username: PropTypes.string, wcaId: PropTypes.string, avatar: PropTypes.shape({ thumb_url: PropTypes.string, @@ -136,12 +161,19 @@ Header.propTypes = { name: PropTypes.string, event: PropTypes.string, }), + notifications: PropTypes.shape({ + enabled: PropTypes.bool, + notifications: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, type: PropTypes.string })), + unreadCount: PropTypes.number, + }), }; Header.defaultProps = { user: { id: undefined, name: undefined, + displayName: undefined, + username: undefined, wcaId: undefined, avatar: { thumb_url: undefined, @@ -152,12 +184,18 @@ Header.defaultProps = { name: undefined, event: undefined, }, + notifications: { + enabled: false, + notifications: [], + unreadCount: 0, + }, }; const mapStateToProps = (state) => ({ room: state.room, user: state.user, roomName: state.room.name, + notifications: state.notifications, }); export default connect(mapStateToProps)(Header); diff --git a/client/src/components/Lobby/Announcements.jsx b/client/src/components/Lobby/Announcements.jsx index 1d656f6..c8ee080 100644 --- a/client/src/components/Lobby/Announcements.jsx +++ b/client/src/components/Lobby/Announcements.jsx @@ -1,7 +1,7 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; import { lcFetch } from '../../lib/fetch'; const useStyles = makeStyles({ diff --git a/client/src/components/Lobby/Announcements.test.jsx b/client/src/components/Lobby/Announcements.test.jsx index 1161ea0..38d5b7b 100644 --- a/client/src/components/Lobby/Announcements.test.jsx +++ b/client/src/components/Lobby/Announcements.test.jsx @@ -1,6 +1,5 @@ import React from 'react'; -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; +import { render, screen } from '@testing-library/react'; import Announcements from './Announcements'; import { lcFetch } from '../../lib/fetch'; @@ -8,8 +7,6 @@ jest.mock('../../lib/fetch', () => ({ lcFetch: jest.fn(), })); -const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); - describe('Announcements', () => { beforeEach(() => { lcFetch.mockReset(); @@ -21,19 +18,10 @@ describe('Announcements', () => { text: jest.fn().mockResolvedValue('[Docs](https://example.com)'), }); - let wrapper; - - await act(async () => { - wrapper = mount(); - await flushPromises(); - }); - - wrapper.update(); - - const link = wrapper.find('a').first(); + render(); - expect(link.text()).toEqual('Docs'); - expect(link.prop('href')).toEqual('https://example.com'); - expect(link.prop('target')).toEqual('_blank'); + const link = await screen.findByRole('link', { name: 'Docs' }); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('target', '_blank'); }); }); diff --git a/client/src/components/Lobby/UserList.jsx b/client/src/components/Lobby/UserList.jsx index 02d0e0d..8adde81 100644 --- a/client/src/components/Lobby/UserList.jsx +++ b/client/src/components/Lobby/UserList.jsx @@ -1,14 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Toolbar from '@material-ui/core/Toolbar'; -// import Divider from '@material-ui/core/Divider'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import Avatar from '@material-ui/core/Avatar'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +// import Divider from '@mui/material/Divider'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import Avatar from '@mui/material/Avatar'; +import { Link } from 'react-router-dom'; const useStyles = makeStyles(() => ({ root: { @@ -34,7 +35,12 @@ function LobbyUserList({ users }) { }} > {users.map((user) => ( - + @@ -51,6 +57,7 @@ LobbyUserList.propTypes = { id: PropTypes.number, displayName: PropTypes.string, inARoom: PropTypes.boolean, + profileKey: PropTypes.string, })), }; diff --git a/client/src/components/Lobby/index.jsx b/client/src/components/Lobby/index.jsx index c49589c..ef8f480 100644 --- a/client/src/components/Lobby/index.jsx +++ b/client/src/components/Lobby/index.jsx @@ -3,22 +3,20 @@ import { connect, useDispatch } from 'react-redux'; import { Link } from 'react-router-dom'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Container from '@material-ui/core/Container'; -import Grid from '@material-ui/core/Grid'; -import Divider from '@material-ui/core/Divider'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import Button from '@material-ui/core/Button'; -import Alert from '@material-ui/lab/Alert'; -import ListSubheader from '@material-ui/core/ListSubheader'; -import BottomNavigation from '@material-ui/core/BottomNavigation'; -import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; -import AddIcon from '@material-ui/icons/Add'; -import DynamicFeedIcon from '@material-ui/icons/DynamicFeed'; -import PeopleIcon from '@material-ui/icons/People'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import Divider from '@mui/material/Divider'; +import List from '@mui/material/List'; +import Button from '@mui/material/Button'; +import Alert from '@mui/material/Alert'; +import ListSubheader from '@mui/material/ListSubheader'; +import BottomNavigation from '@mui/material/BottomNavigation'; +import BottomNavigationAction from '@mui/material/BottomNavigationAction'; +import AddIcon from '@mui/icons-material/Add'; +import DynamicFeedIcon from '@mui/icons-material/DynamicFeed'; +import PeopleIcon from '@mui/icons-material/People'; import RoomConfigureDialog from '../RoomConfigureDialog'; import RoomListItem from '../RoomListItem'; import EventListItem from '../common/EventListItem'; @@ -56,7 +54,7 @@ const useStyles = makeStyles((theme) => ({ display: 'flex', flexDirection: 'column', flexGrow: 1, - height: 0, + minHeight: 0, overflowY: 'auto', }, eventList: { @@ -126,6 +124,7 @@ function Lobby({ displayName: u.displayName, inARoom: !!rooms.find((room) => room.users && room.users.find((i) => i.id === u.id)), avatar: u.avatar, + profileKey: u.profileKey, })); const onCreateRoom = (options) => { @@ -171,19 +170,16 @@ function Lobby({ )} {user.id && ( - } onClick={() => setCreateRoomDialogOpen(true)} > - - - Create Room - + )}
diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index d9b849f..048a2ed 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -1,23 +1,122 @@ import React, { Suspense, lazy } from 'react'; import PropTypes from 'prop-types'; -import { Switch, Route, Redirect } from 'react-router-dom'; +import { + Switch, Route, Redirect, useLocation, +} from 'react-router-dom'; import { connect, useDispatch } from 'react-redux'; -// import Backdrop from '@material-ui/core/Backdrop'; -import CircularProgress from '@material-ui/core/CircularProgress'; -import Snackbar from '@material-ui/core/Snackbar'; -import Alert from '@material-ui/lab/Alert'; -import { makeStyles } from '@material-ui/core/styles'; +import history from '../lib/history'; +// import Backdrop from '@mui/material/Backdrop'; +import CircularProgress from '@mui/material/CircularProgress'; +import Button from '@mui/material/Button'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import { makeStyles } from '@mui/styles'; import PrivateRoute from './common/PrivateRoute'; import Header from './Header'; import Footer from './Footer'; import WCARedirect from './WCARedirect'; import Admin from './Admin'; import { closeMessage } from '../store/messages/actions'; +import { discardPendingResult } from '../store/room/actions'; +import { + canDiscardPendingResult, + isPendingResult, + pendingResultBelongsToUser, +} from '../store/room/resultOutbox'; import Text from './Text'; +import { isFeatureEnabled } from '../lib/features'; const Lobby = lazy(() => import('./Lobby/index')); const Room = lazy(() => import('./Room/index')); const Profile = lazy(() => import('./common/Profile')); +const Notifications = lazy(() => import('./Notifications')); +const Friends = lazy(() => import('./Friends')); +const PublicProfile = lazy(() => import('./PublicProfile')); + +export const shouldShowGlobalPendingResult = (room, pendingResult) => ( + isPendingResult(pendingResult) + && !(room.accessCode && room.type === 'normal' && !room.fetching) +); + +export function GlobalPendingResultAlert({ + atPendingRoom, + error, + onDiscard, + onReturn, + pendingResult, + privateRoom, + status, + userId, +}) { + const belongsToUser = pendingResultBelongsToUser(pendingResult, userId); + const canDiscardResult = canDiscardPendingResult(pendingResult, status); + let message; + + if (!belongsToUser) { + message = canDiscardResult + ? 'A saved time for another account is stored on this device. Switch back to that account or discard it.' + : 'A saved time for another account may already be submitting. Switch back to that account to finish it.'; + } else if (status === 'failed') { + message = `Your saved time could not be submitted: ${error.message}`; + } else if (atPendingRoom) { + const rejoinMessage = privateRoom + ? 'Enter the room password below to rejoin and submit it.' + : 'Rejoin the room to submit it.'; + message = canDiscardResult + ? `Your time is still saved on this device. ${rejoinMessage}` + : `Your time may already be submitting. ${rejoinMessage}`; + } else { + message = canDiscardResult + ? `Your saved time is waiting in room ${pendingResult.roomId}. Return there to submit it, or discard it.` + : `Your saved time is waiting in room ${pendingResult.roomId}. Return there to finish submitting it.`; + } + + return ( + + {belongsToUser && !atPendingRoom && ( + + )} + {canDiscardResult && ( + + )} + + )} + > + {message} + + ); +} + +GlobalPendingResultAlert.propTypes = { + atPendingRoom: PropTypes.bool.isRequired, + error: PropTypes.shape({ + message: PropTypes.string, + }), + onDiscard: PropTypes.func.isRequired, + onReturn: PropTypes.func.isRequired, + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + roomId: PropTypes.string, + submissionId: PropTypes.string, + userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }).isRequired, + privateRoom: PropTypes.bool, + status: PropTypes.oneOf(['pending', 'sending', 'failed']).isRequired, + userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +GlobalPendingResultAlert.defaultProps = { + error: null, + privateRoom: false, + userId: undefined, +}; const useStyles = makeStyles((theme) => ({ root: { @@ -46,10 +145,20 @@ const useStyles = makeStyles((theme) => ({ })); function Navigation({ - room, connected, user, messages, server, + room, roomList, connected, user, messages, server, }) { const classes = useStyles(); const dispatch = useDispatch(); + const location = useLocation(); + const roomConnectionInterrupted = !!room._id && !roomList.connected; + const connectionInterrupted = room._id + ? roomConnectionInterrupted + : (!connected || server.reconnecting); + const resultSubmission = room.resultSubmission || {}; + const pendingResult = resultSubmission.pendingResult; + const showGlobalPendingResult = shouldShowGlobalPendingResult(room, pendingResult); + const pendingRoomPath = pendingResult ? `/rooms/${pendingResult.roomId}` : null; + const atPendingRoom = location.pathname === pendingRoomPath; const handleClose = (index, event, reason) => { if (reason === 'clickaway') { @@ -82,9 +191,11 @@ function Navigation({
- {(!connected || server.reconnecting) && ( - - Disconnected from server. + {connectionInterrupted && ( + + {roomConnectionInterrupted + ? 'Room connection interrupted. You can finish your solve; your completed time will be saved on this device.' + : 'Disconnected from server.'} { (server.reconnecting || server.reconnectAttempts > 0) && ( ` Reconnecting...${server.reconnectAttempts} attempts` )} @@ -99,6 +210,18 @@ function Navigation({ )} )} + {showGlobalPendingResult && ( + dispatch(discardPendingResult(pendingResult.submissionId))} + onReturn={() => history.push(pendingRoomPath)} + pendingResult={pendingResult} + privateRoom={!!room.private} + status={resultSubmission.status} + userId={user.id} + /> + )}
{!user.fetching && ( @@ -106,6 +229,10 @@ function Navigation({ { (!user.id || user.canJoinRoom) && } + + + {isFeatureEnabled('friends') && } + @@ -145,6 +272,24 @@ Navigation.propTypes = { })), room: PropTypes.shape({ _id: PropTypes.string, + accessCode: PropTypes.string, + fetching: PropTypes.bool, + private: PropTypes.bool, + type: PropTypes.string, + resultSubmission: PropTypes.shape({ + status: PropTypes.oneOf(['idle', 'pending', 'sending', 'failed']), + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + roomId: PropTypes.string, + submissionId: PropTypes.string, + }), + error: PropTypes.shape({ + message: PropTypes.string, + }), + }), + }), + roomList: PropTypes.shape({ + connected: PropTypes.bool, }), server: PropTypes.shape({ reconnecting: PropTypes.bool, @@ -161,6 +306,9 @@ Navigation.defaultProps = { room: { id: undefined, }, + roomList: { + connected: false, + }, server: { reconnecting: false, reconnectAttempts: 0, @@ -174,6 +322,7 @@ const mapStateToProps = (state) => ({ user: state.user, messages: state.messages.messages, room: state.room, + roomList: state.roomList, }); export default connect(mapStateToProps)(Navigation); diff --git a/client/src/components/Navigation.test.jsx b/client/src/components/Navigation.test.jsx new file mode 100644 index 0000000..78348b1 --- /dev/null +++ b/client/src/components/Navigation.test.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { + GlobalPendingResultAlert, + shouldShowGlobalPendingResult, +} from './Navigation'; +import { + createPendingResult, + markPendingResultAttempted, +} from '../store/room/resultOutbox'; + +const pendingResult = createPendingResult({ + userId: 42, + roomId: 'original-room', + attemptId: 7, + attemptKey: 'attempt-seven', + result: { time: 1234, penalties: {} }, +}, { + createId: () => 'submission-one', + now: () => 1000, +}); + +describe('global pending result alert', () => { + it('is hidden when a joined normal room can render the room-level controls', () => { + expect(shouldShowGlobalPendingResult({ + accessCode: 'ABC123', + fetching: false, + type: 'normal', + }, pendingResult)).toBe(false); + + expect(shouldShowGlobalPendingResult({ + accessCode: 'ABC123', + fetching: true, + type: 'normal', + }, pendingResult)).toBe(true); + }); + + it('keeps discard accessible when the pending room could not be joined', () => { + const onDiscard = jest.fn(); + render( + , + ); + + expect(screen.getByText(/Enter the room password below/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Discard saved result' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Discard saved result' })); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + + it('offers a return action when the user is somewhere else', () => { + const onReturn = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Return to room' })); + expect(onReturn).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Discard saved result' })).toBeInTheDocument(); + }); + + it('does not offer discard after delivery has started', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Return to room' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Discard saved result' })).not.toBeInTheDocument(); + expect(screen.getByText(/finish submitting/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Notifications/NotificationList.jsx b/client/src/components/Notifications/NotificationList.jsx new file mode 100644 index 0000000..aa78988 --- /dev/null +++ b/client/src/components/Notifications/NotificationList.jsx @@ -0,0 +1,106 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from '@mui/material/Button'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import { makeStyles } from '@mui/styles'; +import notificationPresentation from './registry'; + +const useStyles = makeStyles((theme) => ({ + list: { + marginTop: theme.spacing(1), + }, + notification: { + alignItems: 'center', + borderLeft: '3px solid transparent', + paddingBottom: theme.spacing(1.5), + paddingTop: theme.spacing(1.5), + }, + unread: { + backgroundColor: theme.palette.action.hover, + borderLeftColor: theme.palette.primary.main, + }, + actions: { + display: 'flex', + flexDirection: 'column', + marginLeft: theme.spacing(1), + minWidth: '5rem', + }, + status: { + color: theme.palette.text.secondary, + fontSize: '0.75rem', + marginTop: theme.spacing(0.5), + }, +})); + +export default function NotificationList({ + actionErrors, actionPending, actionStale, notifications, onAction, +}) { + const classes = useStyles(); + if (notifications.length === 0) { + return You have no notifications.; + } + return ( + + {notifications.map((notification) => { + const presentation = notificationPresentation(notification); + const pending = !!actionPending[notification.id]; + const stale = !!actionStale[notification.id]; + return ( + + {notification.readAt ? 'Viewed' : 'New'}} + /> +
+ {!stale && presentation.actions.map((action) => ( + + ))} + {actionErrors[notification.id] && ( + + {actionErrors[notification.id].message} + + )} + {stale && This request is no longer active.} +
+
+ ); + })} +
+ ); +} + +NotificationList.propTypes = { + actionErrors: PropTypes.shape(), + actionPending: PropTypes.shape(), + actionStale: PropTypes.shape(), + notifications: PropTypes.arrayOf(PropTypes.shape({ + actor: PropTypes.shape({ id: PropTypes.number }), + id: PropTypes.string.isRequired, + readAt: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + type: PropTypes.string.isRequired, + })).isRequired, + onAction: PropTypes.func.isRequired, +}; + +NotificationList.defaultProps = { + actionErrors: {}, + actionPending: {}, + actionStale: {}, +}; diff --git a/client/src/components/Notifications/NotificationPopover.jsx b/client/src/components/Notifications/NotificationPopover.jsx new file mode 100644 index 0000000..10af7cb --- /dev/null +++ b/client/src/components/Notifications/NotificationPopover.jsx @@ -0,0 +1,174 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { makeStyles } from '@mui/styles'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemText from '@mui/material/ListItemText'; +import Popover from '@mui/material/Popover'; +import Typography from '@mui/material/Typography'; +import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'; +import notificationPresentation from './registry'; + +const useStyles = makeStyles((theme) => ({ + panel: { + width: '23rem', + maxWidth: 'calc(100vw - 2rem)', + }, + header: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + padding: theme.spacing(1.5, 2), + }, + previewList: { + maxHeight: '22rem', + overflowY: 'auto', + padding: 0, + }, + preview: { + alignItems: 'center', + borderLeft: '3px solid transparent', + minHeight: '4.5rem', + paddingLeft: theme.spacing(1.5), + }, + unread: { + backgroundColor: theme.palette.action.hover, + borderLeftColor: theme.palette.primary.main, + }, + avatar: { + backgroundColor: theme.palette.primary.light, + }, + unreadIndicator: { + backgroundColor: theme.palette.primary.main, + borderRadius: '50%', + height: theme.spacing(1), + marginLeft: theme.spacing(1), + width: theme.spacing(1), + }, + emptyState: { + alignItems: 'center', + color: theme.palette.text.secondary, + display: 'flex', + flexDirection: 'column', + padding: theme.spacing(4, 2), + textAlign: 'center', + }, + emptyIcon: { + fontSize: '2rem', + marginBottom: theme.spacing(1), + }, + footer: { + display: 'flex', + justifyContent: 'flex-end', + padding: theme.spacing(0.5, 1), + }, +})); + +const actorName = (notification) => { + const actor = notification.actor || {}; + return actor.displayName || actor.username || 'A'; +}; + +export function NotificationPopover({ anchorEl, notifications, onClose, unreadCount }) { + const classes = useStyles(); + const open = Boolean(anchorEl); + + return ( + +
+
+
+ Notifications + + {unreadCount ? `${unreadCount} unread` : 'You’re all caught up'} + +
+ {unreadCount > 0 && New activity} +
+ + {notifications.length === 0 ? ( +
+ + You’re all caught up. + New activity will appear here. +
+ ) : ( + + {notifications.slice(0, 5).map((notification) => { + const name = actorName(notification); + const unread = !notification.readAt; + return ( + + + + {name.charAt(0).toUpperCase()} + + + + {unread && } + + ); + })} + + )} + +
+ +
+
+
+ ); +} + +NotificationPopover.propTypes = { + anchorEl: PropTypes.shape(), + notifications: PropTypes.arrayOf(PropTypes.shape({ + actor: PropTypes.shape({ + avatar: PropTypes.shape({ thumb_url: PropTypes.string }), + displayName: PropTypes.string, + username: PropTypes.string, + }), + id: PropTypes.string.isRequired, + readAt: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + type: PropTypes.string.isRequired, + })), + onClose: PropTypes.func.isRequired, + unreadCount: PropTypes.number, +}; + +NotificationPopover.defaultProps = { + anchorEl: null, + notifications: [], + unreadCount: 0, +}; + +export default NotificationPopover; diff --git a/client/src/components/Notifications/index.jsx b/client/src/components/Notifications/index.jsx new file mode 100644 index 0000000..ede6833 --- /dev/null +++ b/client/src/components/Notifications/index.jsx @@ -0,0 +1,87 @@ +import React, { useEffect } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import Typography from '@mui/material/Typography'; +import { makeStyles } from '@mui/styles'; +import { + fetchNotifications, + markAllNotificationsRead, + runFriendNotificationAction, +} from '../../store/notifications/actions'; +import NotificationList from './NotificationList'; + +const useStyles = makeStyles((theme) => ({ + root: { + paddingTop: theme.spacing(3), + }, + heading: { + alignItems: 'flex-start', + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacing(1), + }, + count: { + marginTop: theme.spacing(1), + }, +})); + +export function NotificationsPage({ + notifications, fetchPage, markAllRead, runAction, +}) { + const classes = useStyles(); + useEffect(() => { fetchPage(); }, [fetchPage]); + if (notifications.enabled === false) { + return Notifications are unavailable.; + } + return ( + +
+
+ Notifications + + {notifications.unreadCount ? `${notifications.unreadCount} unread` : 'You’re all caught up'} + +
+ +
+ {notifications.fetching && notifications.notifications.length === 0 && } + {notifications.error && {notifications.error.message}} + + {notifications.nextCursor && ( + + )} +
+ ); +} + +NotificationsPage.propTypes = { + fetchPage: PropTypes.func.isRequired, + markAllRead: PropTypes.func.isRequired, + notifications: PropTypes.shape().isRequired, + runAction: PropTypes.func.isRequired, +}; + +const mapStateToProps = (state) => ({ notifications: state.notifications }); +const mapDispatchToProps = (dispatch) => ({ + fetchPage: (options) => dispatch(fetchNotifications({ limit: 20, ...options })), + markAllRead: () => dispatch(markAllNotificationsRead()), + runAction: (notification, action) => dispatch(runFriendNotificationAction(notification, action)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationsPage); diff --git a/client/src/components/Notifications/registry.js b/client/src/components/Notifications/registry.js new file mode 100644 index 0000000..f64780e --- /dev/null +++ b/client/src/components/Notifications/registry.js @@ -0,0 +1,20 @@ +const notificationPresentation = (notification) => { + const actorName = notification.actor + && (notification.actor.displayName || notification.actor.username) + ? notification.actor.displayName || notification.actor.username : 'A user'; + switch (notification.type) { + case 'friend_request': + return { + actions: ['accept', 'decline'], + text: `${actorName} sent you a friend request.`, + }; + case 'friend_request_accepted': + return { actions: [], text: `${actorName} accepted your friend request.` }; + case 'room_invitation': + return { actions: ['join race'], text: `${actorName} invited you to race.` }; + default: + return { actions: [], text: 'You have a notification from Let\'s Cube.' }; + } +}; + +export default notificationPresentation; diff --git a/client/src/components/Notifications/registry.test.js b/client/src/components/Notifications/registry.test.js new file mode 100644 index 0000000..06ff843 --- /dev/null +++ b/client/src/components/Notifications/registry.test.js @@ -0,0 +1,29 @@ +import notificationPresentation from './registry'; + +describe('notification registry', () => { + it('maps only registered friend resources to typed actions', () => { + expect(notificationPresentation({ + actor: { username: 'solver' }, type: 'friend_request', + })).toEqual({ + actions: ['accept', 'decline'], + text: 'solver sent you a friend request.', + }); + }); + + it('renders unknown notification types without accepting server callbacks', () => { + const presentation = notificationPresentation({ type: 'unknown_future_type' }); + + expect(presentation.actions).toEqual([]); + expect(presentation).not.toHaveProperty('url'); + expect(presentation).not.toHaveProperty('callback'); + }); + + it('maps a typed room invitation to an explicit join action', () => { + expect(notificationPresentation({ + actor: { username: 'solver' }, type: 'room_invitation', + })).toEqual({ + actions: ['join race'], + text: 'solver invited you to race.', + }); + }); +}); diff --git a/client/src/components/PublicProfile.jsx b/client/src/components/PublicProfile.jsx new file mode 100644 index 0000000..1d6fece --- /dev/null +++ b/client/src/components/PublicProfile.jsx @@ -0,0 +1,187 @@ +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; +import { Link, useParams } from 'react-router-dom'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { makeStyles } from '@mui/styles'; +import { lcFetch } from '../lib/fetch'; +import { isFeatureEnabled } from '../lib/features'; + +const actionRequest = (action, id) => { + const encodedId = encodeURIComponent(id); + if (action === 'request') return { method: 'POST', url: '/api/friends/requests', body: { userId: id } }; + if (action === 'cancel') return { method: 'DELETE', url: `/api/friends/requests/${encodedId}` }; + if (action === 'accept' || action === 'decline') return { method: 'POST', url: `/api/friends/requests/${encodedId}/${action}` }; + if (action === 'unfriend') return { method: 'DELETE', url: `/api/friends/${encodedId}` }; + return { method: 'PUT', url: `/api/friends/blocks/${encodedId}` }; +}; + +const actionLabel = { + accept: 'Accept request', + block: 'Block', + cancel: 'Cancel request', + decline: 'Decline', + request: 'Send friend request', + unfriend: 'Remove friend', +}; + +const relationshipLabel = { + accepted: 'Friends', + incoming: 'Friend request received', + none: 'Not connected', + outgoing: 'Friend request sent', + self: 'This is you', +}; + +const useStyles = makeStyles((theme) => ({ + root: { + paddingTop: theme.spacing(5), + }, + card: { + overflow: 'hidden', + }, + identity: { + alignItems: 'center', + backgroundColor: theme.palette.action.hover, + display: 'flex', + padding: theme.spacing(3), + }, + avatar: { + height: theme.spacing(10), + marginRight: theme.spacing(2), + width: theme.spacing(10), + }, + profileType: { + letterSpacing: '0.08em', + textTransform: 'uppercase', + }, + profileContent: { + padding: theme.spacing(3), + }, + relationship: { + marginBottom: theme.spacing(2), + }, + actions: { + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + }, + loading: { + display: 'flex', + justifyContent: 'center', + padding: theme.spacing(5), + }, + notFound: { + padding: theme.spacing(4), + textAlign: 'center', + }, +})); + +function PublicProfile() { + const { id } = useParams(); + const classes = useStyles(); + const [profile, setProfile] = useState(null); + const [status, setStatus] = useState('loading'); + const [acting, setActing] = useState(false); + const requestSequence = useRef(0); + + const load = useCallback((profileKey) => { + const requestId = requestSequence.current + 1; + requestSequence.current = requestId; + setStatus('loading'); + lcFetch(`/api/users/${encodeURIComponent(profileKey)}`) + .then((response) => (response.ok ? response.json() : Promise.reject(response))) + .then((data) => { + if (requestSequence.current !== requestId) return; + setProfile(data); + setStatus('ready'); + }) + .catch((response) => { + if (requestSequence.current === requestId) { + setStatus(response && response.status === 404 ? 'not-found' : 'unavailable'); + } + }); + }, []); + + useEffect(() => load(id), [id, load]); + + const act = (action) => { + if (!profile || acting) return; + setActing(true); + const request = actionRequest(action, profile.id); + lcFetch(request.url, { + method: request.method, + headers: request.body ? { 'Content-Type': 'application/json' } : undefined, + body: request.body ? JSON.stringify(request.body) : undefined, + }).then(() => load(profile.profileKey)).finally(() => setActing(false)); + }; + + if (status === 'not-found') { + return ( + + + 404 + User not found + + This profile is unavailable or no longer public. + + + + + ); + } + + return ( + + + {status === 'loading' &&
} + {status === 'unavailable' &&
Unable to load this profile right now.
} + {status === 'ready' && profile && ( + <> +
+ +
+ Cuber profile + {profile.displayName || profile.username} + {profile.username && {`@${profile.username}`}} +
+
+
+ {profile.wcaId && {`WCA ID: ${profile.wcaId}`}} + + {relationshipLabel[profile.relationship] || 'Not connected'} + +
+ {isFeatureEnabled('friends') && profile.actions.map((action) => ( + + ))} + {profile.relationship === 'self' && ( + + )} +
+
+ + )} +
+
+ ); +} + +export default PublicProfile; diff --git a/client/src/components/Room/Common/AdminToolbar.jsx b/client/src/components/Room/Common/AdminToolbar.jsx index 200d3a5..e0eeda3 100644 --- a/client/src/components/Room/Common/AdminToolbar.jsx +++ b/client/src/components/Room/Common/AdminToolbar.jsx @@ -1,15 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect, useDispatch } from 'react-redux'; -import Toolbar from '@material-ui/core/Toolbar'; -import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import Button from '@material-ui/core/Button'; -import FormGroup from '@material-ui/core/FormGroup'; -import Select from '@material-ui/core/Select'; -import ListSubheader from '@material-ui/core/ListSubheader'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; +import Toolbar from '@mui/material/Toolbar'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Button from '@mui/material/Button'; +import FormGroup from '@mui/material/FormGroup'; +import Select from '@mui/material/Select'; +import ListSubheader from '@mui/material/ListSubheader'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useConfirm } from 'material-ui-confirm'; import { deleteRoom, diff --git a/client/src/components/Room/Common/EditDialog.jsx b/client/src/components/Room/Common/EditDialog.jsx index 86c4bf8..fcc4756 100644 --- a/client/src/components/Room/Common/EditDialog.jsx +++ b/client/src/components/Room/Common/EditDialog.jsx @@ -1,13 +1,13 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import DialogContent from '@material-ui/core/DialogContent'; -import TextField from '@material-ui/core/TextField'; -import Button from '@material-ui/core/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { formatRawTime, parseTime } from '../../../lib/utils'; function EditDialog({ diff --git a/client/src/components/Room/Common/Login.jsx b/client/src/components/Room/Common/Login.jsx index 8650f05..a460aa2 100644 --- a/client/src/components/Room/Common/Login.jsx +++ b/client/src/components/Room/Common/Login.jsx @@ -1,12 +1,12 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Paper from '@material-ui/core/Paper'; -import TextField from '@material-ui/core/TextField'; -import Button from '@material-ui/core/Button'; -import IconButton from '@material-ui/core/IconButton'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import Paper from '@mui/material/Paper'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Link } from 'react-router-dom'; import { joinRoom, @@ -22,21 +22,12 @@ const useStyles = makeStyles((theme) => ({ }, })); -function Login({ dispatch, roomId, loginFailed }) { +function Login({ dispatch, roomId, joinError }) { const classes = useStyles(); const [password, setPassword] = useState(''); - const [resetPassword, setResetPassword] = useState(false); - - // Forgive me lord - if (loginFailed && password && !resetPassword) { - setPassword(''); - - setResetPassword(true); - } const login = (event) => { event.preventDefault(); - setResetPassword(false); dispatch(joinRoom({ id: roomId, password, @@ -47,8 +38,8 @@ function Login({ dispatch, roomId, loginFailed }) { setPassword(event.target.value); }; - const helperText = (loginFailed && loginFailed.error) - ? loginFailed.error.message : 'Enter password to login'; + const helperText = joinError + ? joinError.message : 'Enter password to login'; return ( @@ -77,7 +68,7 @@ function Login({ dispatch, roomId, loginFailed }) { type="password" autoFocus helperText={helperText} - error={!!loginFailed} + error={!!joinError} value={password} onChange={updatePassword} /> @@ -96,18 +87,20 @@ function Login({ dispatch, roomId, loginFailed }) { Login.propTypes = { roomId: PropTypes.string, - loginFailed: PropTypes.shape(), + joinError: PropTypes.shape({ + message: PropTypes.string, + }), dispatch: PropTypes.func.isRequired, }; Login.defaultProps = { roomId: undefined, - loginFailed: null, + joinError: null, }; const mapStateToProps = (state) => ({ roomId: state.room._id, - loginFailed: state.socket.loginFailed, + joinError: state.room.joinError, }); export default connect(mapStateToProps)(Login); diff --git a/client/src/components/Room/Common/Main.jsx b/client/src/components/Room/Common/Main.jsx index 4a09c00..50885a7 100644 --- a/client/src/components/Room/Common/Main.jsx +++ b/client/src/components/Room/Common/Main.jsx @@ -1,30 +1,40 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core/styles'; +import { withStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Grid from '@material-ui/core/Grid'; -import Paper from '@material-ui/core/Paper'; -import Divider from '@material-ui/core/Divider'; -import Typography from '@material-ui/core/Typography'; -import ClickAwayListener from '@material-ui/core/ClickAwayListener'; -import { Cube } from 'react-cube-svg'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Alert from '@mui/material/Alert'; import UIfx from 'uifx'; +import history from '../../../lib/history'; import notificationAsset from '../../../assets/notification.mp3'; import calcStats from '../../../lib/stats'; import { submitResult, + discardPendingResult, sendStatus, timerFocused, } from '../../../store/room/actions'; +import { + canDiscardPendingResult, + isPendingResult, + pendingResultBelongsToUser, + pendingResultMatches, +} from '../../../store/room/resultOutbox'; import { StatsDialogProvider } from './StatsDialogProvider'; import { EditDialogProvider } from './EditDialogProvider'; import TimesTable from './TimesTable'; import HelpPopover from '../../common/HelpPopover'; import Timer from '../../Timer/index'; import Scramble from '../../common/Scramble'; +import ScramblePreview from '../../common/ScramblePreview'; import UserStats from './UserStats'; -const useStyles = withStyles((theme) => ({ +const withComponentStyles = withStyles((theme) => ({ root: { display: 'flex', flexGrow: 1, @@ -36,6 +46,9 @@ const useStyles = withStyles((theme) => ({ waitingForBox: { padding: '.5em', }, + submissionAlert: { + borderRadius: 0, + }, scrambleBox: { padding: '.5em', textAlign: 'center', @@ -45,12 +58,12 @@ const useStyles = withStyles((theme) => ({ }, })); -class Main extends React.Component { +export class Main extends React.Component { constructor(props) { super(props); this.state = { - currentAttemptId: undefined, + currentAttempt: undefined, }; } @@ -68,26 +81,27 @@ class Main extends React.Component { onSubmitTime(event) { const { dispatch, room, user } = this.props; - const { currentAttemptId } = this.state; + const { currentAttempt } = this.state; if (!room.attempts.length) { return; } - // Don't even bother sending the result. if (!user.id) { return; } const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; + const submittedAttempt = currentAttempt || latestAttempt; dispatch(submitResult({ - id: currentAttemptId || latestAttempt.id, + id: submittedAttempt.id, + attemptKey: submittedAttempt._id, result: { time: event.time, penalties: event.penalties, }, })); - this.setState({ currentAttemptId: null }); + this.setState({ currentAttempt: null }); } onTimerFocused = () => { @@ -108,28 +122,89 @@ class Main extends React.Component { handlePriming() { const { room } = this.props; const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; - this.setState({ currentAttemptId: latestAttempt.id }); + this.setState({ currentAttempt: latestAttempt }); } render() { const { - classes, dispatch, room, user, onlyShowSelf, + classes, dispatch, room, user, onlyShowSelf, roomConnected, } = this.props; const { users, attempts, waitingFor, } = room; + const resultSubmission = room.resultSubmission || {}; + const pendingResult = resultSubmission.pendingResult; + const hasPendingResult = isPendingResult(pendingResult); + const canDiscardResult = canDiscardPendingResult( + pendingResult, + resultSubmission.status, + ); + const pendingBelongsToUser = pendingResultBelongsToUser(pendingResult, user.id); + const pendingMatchesRoom = pendingResultMatches(pendingResult, { + userId: user.id, + roomId: room._id, + }); const latestAttempt = (attempts && attempts.length) ? attempts[attempts.length - 1] : {}; const timerDisabled = !room.timerFocused || !room.competing[user.id] - || !room.waitingFor[user.id]; + || !room.waitingFor[user.id] || hasPendingResult; const hidden = room.competing[user.id] && !waitingFor[user.id]; + let submissionMessage = ''; + if (!pendingBelongsToUser) { + submissionMessage = canDiscardResult + ? 'A saved time for another account is stored on this device. Switch back to that account or discard it before timing another solve.' + : 'A saved time for another account may already be submitting. Switch back to that account to finish it.'; + } else if (!pendingMatchesRoom) { + submissionMessage = canDiscardResult + ? `Your saved time belongs to room ${pendingResult.roomId}. Return there to submit it, or discard it before timing another solve.` + : `Your saved time belongs to room ${pendingResult.roomId}. Return there to finish submitting it.`; + } else if (resultSubmission.status === 'failed') { + submissionMessage = `Your saved time could not be submitted: ${resultSubmission.error.message}`; + } else if (resultSubmission.status === 'sending') { + submissionMessage = 'Submitting your saved time...'; + } else if (roomConnected) { + submissionMessage = 'Your time is saved on this device and waiting to submit.'; + } else { + submissionMessage = 'Your time is saved on this device. It will submit after the room reconnects.'; + } + const stats = calcStats(attempts, users); - const showScramble = latestAttempt.scrambles && room.event === '333'; + const showScramble = latestAttempt.scrambles && latestAttempt.scrambles.length; return ( { this.onTimerDefocused(); }}> { this.onTimerFocused(); }}> + {hasPendingResult && ( + + {pendingBelongsToUser && !pendingMatchesRoom && ( + + )} + {canDiscardResult && ( + + )} + + )} + > + {submissionMessage} + + )}
@@ -202,7 +277,8 @@ class Main extends React.Component { }} variant="outlined" > - @@ -231,12 +307,23 @@ Main.propTypes = { waitingFor: PropTypes.shape(), statuses: PropTypes.shape(), attempts: PropTypes.arrayOf(PropTypes.shape({ + _id: PropTypes.string, id: PropTypes.number, })), admin: PropTypes.shape({ id: PropTypes.number, }), timerFocused: PropTypes.bool, + resultSubmission: PropTypes.shape({ + status: PropTypes.oneOf(['idle', 'pending', 'sending', 'failed']), + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + submissionId: PropTypes.string, + }), + error: PropTypes.shape({ + message: PropTypes.string, + }), + }), }), user: PropTypes.shape({ id: PropTypes.number, @@ -247,6 +334,7 @@ Main.propTypes = { dispatch: PropTypes.func.isRequired, classes: PropTypes.shape().isRequired, onlyShowSelf: PropTypes.bool, + roomConnected: PropTypes.bool, }; Main.defaultProps = { @@ -264,6 +352,11 @@ Main.defaultProps = { id: undefined, }, timerFocused: true, + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, }, user: { id: undefined, @@ -272,11 +365,13 @@ Main.defaultProps = { timerType: 'spacebar', }, onlyShowSelf: false, + roomConnected: false, }; const mapStateToProps = (state) => ({ room: state.room, + roomConnected: state.roomList.connected, user: state.user, }); -export default connect(mapStateToProps)(useStyles(Main)); +export default connect(mapStateToProps)(withComponentStyles(Main)); diff --git a/client/src/components/Room/Common/Main.test.jsx b/client/src/components/Room/Common/Main.test.jsx new file mode 100644 index 0000000..cb650fe --- /dev/null +++ b/client/src/components/Room/Common/Main.test.jsx @@ -0,0 +1,203 @@ +import React from 'react'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Timer from '../../Timer/index'; +import ScramblePreview from '../../common/ScramblePreview'; +import { Main } from './Main'; +import { + createPendingResult, + markPendingResultAttempted, +} from '../../../store/room/resultOutbox'; +import { + DISCARD_PENDING_RESULT, + SUBMIT_RESULT, +} from '../../../store/room/actions'; + +jest.mock('../../Timer/StackmatTimer', () => () => null); + +const pendingResult = createPendingResult({ + userId: 42, + roomId: 'original-room', + attemptId: 7, + attemptKey: 'attempt-seven', + result: { time: 1234, penalties: {} }, +}, { + createId: () => 'submission-one', + now: () => 1000, +}); + +const findElements = (node, type) => { + if (!React.isValidElement(node)) return []; + const children = React.Children.toArray(node.props.children) + .flatMap((child) => findElements(child, type)); + return node.type === type ? [node, ...children] : children; +}; + +const makeProps = (overrides = {}) => ({ + classes: { + root: 'root', + submissionAlert: 'submissionAlert', + scrambleBox: 'scrambleBox', + waitingForBox: 'waitingForBox', + }, + dispatch: jest.fn(), + onlyShowSelf: false, + roomConnected: true, + user: { + id: 42, + useInspection: false, + muteTimer: true, + timerType: 'spacebar', + }, + room: { + _id: 'current-room', + event: '333', + users: [{ id: 42 }], + competing: { 42: true }, + waitingFor: { 42: true }, + attempts: [{ _id: 'attempt-eight', id: 8, scrambles: ['R U'], results: {} }], + timerFocused: true, + resultSubmission: { + status: 'pending', + pendingResult, + error: null, + }, + }, + ...overrides, +}); + +const createMain = (props) => { + const main = new Main(props); + main.setState = (update) => { + main.state = { ...main.state, ...update }; + }; + return main; +}; + +describe('room pending result UX', () => { + it('shows scramble previews for non-3x3 events', () => { + const props = makeProps({ + room: { ...makeProps().room, event: 'pyram' }, + }); + const previews = findElements(createMain(props).render(), ScramblePreview); + + expect(previews[0].props).toMatchObject({ + event: 'pyram', + scramble: 'R U', + }); + }); + + it('submits against the immutable attempt that was primed', () => { + const props = makeProps({ + room: { + ...makeProps().room, + attempts: [{ + _id: 'primed-attempt', id: 0, scrambles: ['R U'], results: {}, + }], + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, + }, + }); + const main = createMain(props); + main.handlePriming(); + main.props = { + ...props, + room: { + ...props.room, + attempts: [{ + _id: 'replacement-attempt', id: 0, scrambles: ['F R'], results: {}, + }], + }, + }; + + main.onSubmitTime({ time: 1234, penalties: {} }); + + expect(props.dispatch).toHaveBeenCalledWith({ + type: SUBMIT_RESULT, + result: { + id: 0, + attemptKey: 'primed-attempt', + result: { time: 1234, penalties: {} }, + }, + }); + }); + + it('does not disable the timer merely because the room socket disconnected', () => { + const props = makeProps({ + roomConnected: false, + room: { + ...makeProps().room, + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, + }, + }); + const tree = createMain(props).render(); + + expect(findElements(tree, Timer)[0].props.disabled).toBe(false); + expect(findElements(tree, Alert)).toHaveLength(0); + }); + + it('blocks another solve and offers return/discard actions outside the original room', () => { + const props = makeProps(); + const alert = findElements(createMain(props).render(), Alert)[0]; + const buttons = findElements(alert.props.action, Button); + + expect(findElements(createMain(props).render(), Timer)[0].props.disabled).toBe(true); + expect(alert.props.children).toContain('Your saved time belongs to room original-room.'); + expect(buttons.map((button) => button.props.children)).toEqual([ + 'Return to room', + 'Discard saved result', + ]); + + buttons[1].props.onClick(); + expect(props.dispatch).toHaveBeenCalledWith({ + type: DISCARD_PENDING_RESULT, + submissionId: 'submission-one', + }); + }); + + it('also blocks a solve when the device outbox belongs to another account', () => { + const anotherUsersResult = { ...pendingResult, userId: 99 }; + const props = makeProps({ + room: { + ...makeProps().room, + resultSubmission: { + status: 'pending', + pendingResult: anotherUsersResult, + error: null, + }, + }, + }); + const alert = findElements(createMain(props).render(), Alert)[0]; + + expect(findElements(createMain(props).render(), Timer)[0].props.disabled).toBe(true); + expect(alert.props.children).toContain('another account'); + expect(findElements(alert.props.action, Button)).toHaveLength(1); + expect(findElements(alert.props.action, Button)[0].props.children).toBe('Discard saved result'); + }); + + it('keeps an attempted result without offering discard', () => { + const props = makeProps({ + room: { + ...makeProps().room, + resultSubmission: { + status: 'pending', + pendingResult: markPendingResultAttempted(pendingResult), + error: null, + }, + }, + }); + const alert = findElements(createMain(props).render(), Alert)[0]; + const buttons = findElements(alert.props.action, Button); + + expect(buttons).toHaveLength(1); + expect(buttons[0].props.children).toBe('Return to room'); + expect(alert.props.children).toContain('finish submitting'); + }); +}); diff --git a/client/src/components/Room/Common/ManageUsersDialog.jsx b/client/src/components/Room/Common/ManageUsersDialog.jsx index 0434a32..994ef7b 100644 --- a/client/src/components/Room/Common/ManageUsersDialog.jsx +++ b/client/src/components/Room/Common/ManageUsersDialog.jsx @@ -2,20 +2,20 @@ import React from 'react'; import clsx from 'clsx'; import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListSubheader from '@material-ui/core/ListSubheader'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import Avatar from '@material-ui/core/Avatar'; +import { makeStyles } from '@mui/styles'; +import Button from '@mui/material/Button'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListSubheader from '@mui/material/ListSubheader'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import Avatar from '@mui/material/Avatar'; import { kickUser, updateUser, updateBanned } from '../../../store/room/actions'; import { getUsersInRoom, diff --git a/client/src/components/Room/Common/Panel.jsx b/client/src/components/Room/Common/Panel.jsx index 0043d9a..4953262 100644 --- a/client/src/components/Room/Common/Panel.jsx +++ b/client/src/components/Room/Common/Panel.jsx @@ -1,10 +1,10 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import Paper from '@material-ui/core/Paper'; -import Toolbar from '@material-ui/core/Toolbar'; -import Divider from '@material-ui/core/Divider'; -import { makeStyles } from '@material-ui/core/styles'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +import Divider from '@mui/material/Divider'; +import { makeStyles } from '@mui/styles'; const useStyles = makeStyles(() => ({ root: { diff --git a/client/src/components/Room/Common/StatsDialog.jsx b/client/src/components/Room/Common/StatsDialog.jsx index 7ec72cc..204f4cb 100644 --- a/client/src/components/Room/Common/StatsDialog.jsx +++ b/client/src/components/Room/Common/StatsDialog.jsx @@ -1,13 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; +import { makeStyles } from '@mui/styles'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; import { formatTime } from '../../../lib/utils'; const useStyles = makeStyles(() => ({ diff --git a/client/src/components/Room/Common/TableStatusCell.jsx b/client/src/components/Room/Common/TableStatusCell.jsx index 23bf4f4..c26c85f 100644 --- a/client/src/components/Room/Common/TableStatusCell.jsx +++ b/client/src/components/Room/Common/TableStatusCell.jsx @@ -1,9 +1,9 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Typography from '@mui/material/Typography'; +import TableCell from '@mui/material/TableCell'; const useStyles = makeStyles(() => ({ root: { diff --git a/client/src/components/Room/Common/TableTimeCell.jsx b/client/src/components/Room/Common/TableTimeCell.jsx index 03939f3..f9cdabb 100644 --- a/client/src/components/Room/Common/TableTimeCell.jsx +++ b/client/src/components/Room/Common/TableTimeCell.jsx @@ -1,10 +1,10 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import Typography from '@material-ui/core/Typography'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import TableCell from '@mui/material/TableCell'; import { formatTime } from '../../../lib/utils'; import { useEditDialog } from './EditDialogProvider'; diff --git a/client/src/components/Room/Common/TimesTable.jsx b/client/src/components/Room/Common/TimesTable.jsx index 54d8cd2..e6e1451 100644 --- a/client/src/components/Room/Common/TimesTable.jsx +++ b/client/src/components/Room/Common/TimesTable.jsx @@ -1,14 +1,14 @@ import React, { createRef } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import TableContainer from '@material-ui/core/TableContainer'; -import Table from '@material-ui/core/Table'; -import TableHead from '@material-ui/core/TableHead'; -import TableBody from '@material-ui/core/TableBody'; -import TableRow from '@material-ui/core/TableRow'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Typography from '@mui/material/Typography'; +import TableContainer from '@mui/material/TableContainer'; +import Table from '@mui/material/Table'; +import TableHead from '@mui/material/TableHead'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; import { useStatsDialog } from './StatsDialogProvider'; import TableCellButton from '../../common/TableCellButton'; import TableStatusCell from './TableStatusCell'; diff --git a/client/src/components/Room/Common/UserSelectorDialog.jsx b/client/src/components/Room/Common/UserSelectorDialog.jsx index 4d15637..22eb97e 100644 --- a/client/src/components/Room/Common/UserSelectorDialog.jsx +++ b/client/src/components/Room/Common/UserSelectorDialog.jsx @@ -1,18 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; -import Avatar from '@material-ui/core/Avatar'; +import { makeStyles } from '@mui/styles'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; +import Avatar from '@mui/material/Avatar'; const useStyles = makeStyles(() => ({})); diff --git a/client/src/components/Room/Common/UserStats.jsx b/client/src/components/Room/Common/UserStats.jsx index 08e0c13..4d3379a 100644 --- a/client/src/components/Room/Common/UserStats.jsx +++ b/client/src/components/Room/Common/UserStats.jsx @@ -1,12 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Table from '@material-ui/core/Table'; -import TableHead from '@material-ui/core/TableHead'; -import TableBody from '@material-ui/core/TableBody'; -import TableRow from '@material-ui/core/TableRow'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Table from '@mui/material/Table'; +import TableHead from '@mui/material/TableHead'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; import { formatTime } from '../../../lib/utils'; const useStyles = makeStyles((theme) => ({ diff --git a/client/src/components/Room/Common/UserToolbar.jsx b/client/src/components/Room/Common/UserToolbar.jsx index 1c34885..ffae149 100644 --- a/client/src/components/Room/Common/UserToolbar.jsx +++ b/client/src/components/Room/Common/UserToolbar.jsx @@ -1,21 +1,21 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Toolbar from '@material-ui/core/Toolbar'; -import FormGroup from '@material-ui/core/FormGroup'; -import Button from '@material-ui/core/Button'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import SettingsIcon from '@material-ui/icons/Settings'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import FormControl from '@material-ui/core/FormControl'; -import InputLabel from '@material-ui/core/InputLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import Select from '@material-ui/core/Select'; -import MenuItem from '@material-ui/core/MenuItem'; +import Toolbar from '@mui/material/Toolbar'; +import FormGroup from '@mui/material/FormGroup'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import SettingsIcon from '@mui/icons-material/Settings'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; import { lcFetch } from '../../../lib/fetch'; import { updateCompeting } from '../../../store/room/actions'; import { updateProfile } from '../../../store/user/actions'; diff --git a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx index 7bc2331..f80298d 100644 --- a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx +++ b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx @@ -1,15 +1,15 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { withStyles, makeStyles, useTheme } from '@material-ui/core/styles'; +import { useTheme } from '@mui/material/styles'; +import { withStyles, makeStyles } from '@mui/styles'; import { connect, useDispatch } from 'react-redux'; -import Grid from '@material-ui/core/Grid'; -import Box from '@material-ui/core/Box'; -import Paper from '@material-ui/core/Paper'; -import Divider from '@material-ui/core/Divider'; -import Typography from '@material-ui/core/Typography'; -import ClickAwayListener from '@material-ui/core/ClickAwayListener'; -import Button from '@material-ui/core/Button'; -import { Cube } from 'react-cube-svg'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Button from '@mui/material/Button'; import { formatISO9075 } from 'date-fns'; import calcStats from '../../../lib/stats'; import { @@ -19,12 +19,14 @@ import { toggleFollowUser, } from '../../../store/room/actions'; import { getRegisteredUsers } from '../../../store/room/selectors'; +import { isPendingResult } from '../../../store/room/resultOutbox'; import { StatsDialogProvider } from '../Common/StatsDialogProvider'; import { EditDialogProvider } from '../Common/EditDialogProvider'; import TimesTable from '../Common/TimesTable'; import HelpPopover from '../../common/HelpPopover'; import Timer from '../../Timer/index'; import Scramble from '../../common/Scramble'; +import ScramblePreview from '../../common/ScramblePreview'; import UserStats from '../Common/UserStats'; import UserSelectorDialog from '../Common/UserSelectorDialog'; @@ -184,7 +186,7 @@ function Main({ room, user }) { const classes = useStyles(); const dispatch = useDispatch(); const theme = useTheme(); - const [currentAttemptId, setCurrentAttemptId] = useState(undefined); + const [currentAttempt, setCurrentAttempt] = useState(undefined); const [followUserDialogOpen, setFollowUserDialogOpen] = useState(false); const { @@ -204,14 +206,16 @@ function Main({ room, user }) { } const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; + const submittedAttempt = currentAttempt || latestAttempt; dispatch(submitResult({ - id: currentAttemptId || latestAttempt.id, + id: submittedAttempt.id, + attemptKey: submittedAttempt._id, result: { time: event.time, penalties: event.penalties, }, })); - setCurrentAttemptId(null); + setCurrentAttempt(null); }; const onTimerFocused = () => { @@ -228,7 +232,7 @@ function Main({ room, user }) { const handlePriming = () => { const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; - setCurrentAttemptId(latestAttempt.id); + setCurrentAttempt(latestAttempt); }; const handleToggleUserFollow = (userId) => { @@ -237,10 +241,11 @@ function Main({ room, user }) { const latestAttempt = attempts && attempts.length && attempts[attempts.length - 1]; const showScrambleBox = latestAttempt && !latestAttempt.results[user.id]; - const timerDisabled = !room.timerFocused || !room.competing[user.id] || !showScrambleBox; + const timerDisabled = !room.timerFocused || !room.competing[user.id] || !showScrambleBox + || isPendingResult(room.resultSubmission && room.resultSubmission.pendingResult); const stats = calcStats(attempts, users); - const showScramble = latestAttempt.scrambles && room.event === '333'; + const showScramble = latestAttempt.scrambles && latestAttempt.scrambles.length; return ( { onTimerDefocused(); }}> @@ -319,7 +324,8 @@ function Main({ room, user }) { }} variant="outlined" > - @@ -369,8 +375,14 @@ Main.propTypes = { statuses: PropTypes.shape(), registered: PropTypes.shape(), attempts: PropTypes.arrayOf(PropTypes.shape({ + _id: PropTypes.string, id: PropTypes.number, })), + resultSubmission: PropTypes.shape({ + pendingResult: PropTypes.shape({ + submissionId: PropTypes.string, + }), + }), admin: PropTypes.shape({ id: PropTypes.number, }), @@ -399,6 +411,9 @@ Main.defaultProps = { statues: {}, registered: {}, attempts: [], + resultSubmission: { + pendingResult: null, + }, admin: { id: undefined, }, diff --git a/client/src/components/Room/GrandPrix/Leaderboard.jsx b/client/src/components/Room/GrandPrix/Leaderboard.jsx index 68afe90..42ecf00 100644 --- a/client/src/components/Room/GrandPrix/Leaderboard.jsx +++ b/client/src/components/Room/GrandPrix/Leaderboard.jsx @@ -1,16 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect, useDispatch } from 'react-redux'; -import { makeStyles } from '@material-ui/core/styles'; -import Toolbar from '@material-ui/core/Toolbar'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import TableContainer from '@material-ui/core/TableContainer'; -import Table from '@material-ui/core/Table'; -import TableHead from '@material-ui/core/TableHead'; -import TableBody from '@material-ui/core/TableBody'; -import TableRow from '@material-ui/core/TableRow'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; +import TableContainer from '@mui/material/TableContainer'; +import Table from '@mui/material/Table'; +import TableHead from '@mui/material/TableHead'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; import TableStatusCell from '../Common/TableStatusCell'; import TableTimeCell from '../Common/TableTimeCell'; import { StatsDialogProvider } from '../Common/StatsDialogProvider'; diff --git a/client/src/components/Room/GrandPrix/RegisterPanel.jsx b/client/src/components/Room/GrandPrix/RegisterPanel.jsx index f426b26..f94ec7e 100644 --- a/client/src/components/Room/GrandPrix/RegisterPanel.jsx +++ b/client/src/components/Room/GrandPrix/RegisterPanel.jsx @@ -2,10 +2,10 @@ import React, { useState } from 'react'; import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { formatDistanceToNow, formatRFC7231 } from 'date-fns'; -import { makeStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; +import { makeStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; import { updateRegistration } from '../../../store/room/actions'; import { getRegisteredUsers } from '../../../store/room/selectors'; diff --git a/client/src/components/Room/GrandPrix/index.jsx b/client/src/components/Room/GrandPrix/index.jsx index 7a1cf3e..cbf779c 100644 --- a/client/src/components/Room/GrandPrix/index.jsx +++ b/client/src/components/Room/GrandPrix/index.jsx @@ -1,21 +1,21 @@ import React, { useState } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Grid from '@material-ui/core/Grid'; -import AppBar from '@material-ui/core/AppBar'; -import Toolbar from '@material-ui/core/Toolbar'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import BottomNavigation from '@material-ui/core/BottomNavigation'; -import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; -import Divider from '@material-ui/core/Divider'; -import IconButton from '@material-ui/core/IconButton'; -import ChatIcon from '@material-ui/icons/Chat'; -import TimerIcon from '@material-ui/icons/Timer'; -import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import Grid from '@mui/material/Grid'; +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; +import BottomNavigation from '@mui/material/BottomNavigation'; +import BottomNavigationAction from '@mui/material/BottomNavigationAction'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import ChatIcon from '@mui/icons-material/Chat'; +import TimerIcon from '@mui/icons-material/Timer'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import Main from './GrandPrixMain'; import AdminToolbar from '../Common/AdminToolbar'; import UserToolbar from '../Common/UserToolbar'; diff --git a/client/src/components/Room/Normal/index.jsx b/client/src/components/Room/Normal/index.jsx index 1ab53d4..465bc61 100644 --- a/client/src/components/Room/Normal/index.jsx +++ b/client/src/components/Room/Normal/index.jsx @@ -1,15 +1,15 @@ import React, { useState } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Grid from '@material-ui/core/Grid'; -import Paper from '@material-ui/core/Paper'; -import BottomNavigation from '@material-ui/core/BottomNavigation'; -import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; -import Divider from '@material-ui/core/Divider'; -import ChatIcon from '@material-ui/icons/Chat'; -import TimerIcon from '@material-ui/icons/Timer'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import BottomNavigation from '@mui/material/BottomNavigation'; +import BottomNavigationAction from '@mui/material/BottomNavigationAction'; +import Divider from '@mui/material/Divider'; +import ChatIcon from '@mui/icons-material/Chat'; +import TimerIcon from '@mui/icons-material/Timer'; import Main from '../Common/Main'; import Chat from '../Panels/Chat'; import AdminToolbar from '../Common/AdminToolbar'; diff --git a/client/src/components/Room/Panels/Chat.jsx b/client/src/components/Room/Panels/Chat.jsx index b02a11a..d29e5a2 100644 --- a/client/src/components/Room/Panels/Chat.jsx +++ b/client/src/components/Room/Panels/Chat.jsx @@ -2,25 +2,25 @@ import React, { useState, useRef, useEffect } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Toolbar from '@material-ui/core/Toolbar'; -import FormControl from '@material-ui/core/FormControl'; -import Input from '@material-ui/core/Input'; -import Alert from '@material-ui/lab/Alert'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import Avatar from '@material-ui/core/Avatar'; -import Typography from '@material-ui/core/Typography'; -import IconButton from '@material-ui/core/IconButton'; -import Icon from '@material-ui/core/Icon'; -import NotificationImportantIcon from '@material-ui/icons/NotificationImportant'; -import PersonIcon from '@material-ui/icons/Person'; -import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +import FormControl from '@mui/material/FormControl'; +import Input from '@mui/material/Input'; +import Alert from '@mui/material/Alert'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Icon from '@mui/material/Icon'; +import NotificationImportantIcon from '@mui/icons-material/NotificationImportant'; +import PersonIcon from '@mui/icons-material/Person'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { sendChat } from '../../../store/chat/actions'; import Panel from '../Common/Panel'; diff --git a/client/src/components/Room/index.jsx b/client/src/components/Room/index.jsx index 8b449a4..d9910ae 100644 --- a/client/src/components/Room/index.jsx +++ b/client/src/components/Room/index.jsx @@ -1,11 +1,11 @@ import React, { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect, useDispatch } from 'react-redux'; -import Paper from '@material-ui/core/Paper'; -import Backdrop from '@material-ui/core/Backdrop'; -import CircularProgress from '@material-ui/core/CircularProgress'; +import Paper from '@mui/material/Paper'; +import Backdrop from '@mui/material/Backdrop'; +import CircularProgress from '@mui/material/CircularProgress'; import qs from 'qs'; import Login from './Common/Login'; import { diff --git a/client/src/components/RoomConfigureDialog.jsx b/client/src/components/RoomConfigureDialog.jsx index 3ca0100..0535d0a 100644 --- a/client/src/components/RoomConfigureDialog.jsx +++ b/client/src/components/RoomConfigureDialog.jsx @@ -2,26 +2,26 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import { format } from 'date-fns'; -import { makeStyles } from '@material-ui/core/styles'; -import Divider from '@material-ui/core/Divider'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import TextField from '@material-ui/core/TextField'; -import Switch from '@material-ui/core/Switch'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import FormControl from '@material-ui/core/FormControl'; -import FormHelperText from '@material-ui/core/FormHelperText'; -import InputLabel from '@material-ui/core/InputLabel'; -import Select from '@material-ui/core/Select'; -import MenuItem from '@material-ui/core/MenuItem'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import Accordion from '@material-ui/core/Accordion'; -import AccordionDetails from '@material-ui/core/AccordionDetails'; -import AccordionSummary from '@material-ui/core/AccordionSummary'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { makeStyles } from '@mui/styles'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Switch from '@mui/material/Switch'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; const useStyles = makeStyles((theme) => ({ fab: { @@ -47,7 +47,7 @@ function RoomConfigureDialog({ const classes = useStyles(); const [stateName, setName] = React.useState(room.name); const [statePrivate, setPrivate] = React.useState(room.private); - const [statePassword, setPassword] = React.useState(room.private ? room.accessCode : null); + const [statePassword, setPassword] = React.useState(''); const [stateType, setType] = React.useState(room.type); const [stateRequireRI, setRequireRI] = React.useState(room.requireRevealedIdentity); const [stateStartTime, setStartTime] = React.useState(room.startTime ? ( @@ -58,7 +58,7 @@ function RoomConfigureDialog({ const handleCancel = () => { setName(room.name); setPrivate(room.private); - setPassword(null); + setPassword(''); setType(room.type); setRequireRI(room.requireRevealedIdentity); setStartTime(room.startTime ? ( @@ -73,14 +73,14 @@ function RoomConfigureDialog({ onSave({ name: stateName, private: statePrivate, - password: statePrivate ? statePassword : null, + password: statePrivate ? statePassword || undefined : null, type: stateType, requireRevealedIdentity: stateRequireRI, startTime: stateStartTime ? new Date(stateStartTime) : null, twitchChannel: stateTwitchChannel, }); - setPassword(null); + setPassword(''); onCancel(); // Close the dialog box }; @@ -117,6 +117,7 @@ function RoomConfigureDialog({ autoComplete="off" autoFocus fullWidth + margin="dense" /> @@ -203,7 +206,11 @@ function RoomConfigureDialog({ - @@ -216,7 +223,6 @@ RoomConfigureDialog.propTypes = { _id: PropTypes.string, name: PropTypes.string, private: PropTypes.bool, - accessCode: PropTypes.string, type: PropTypes.string, requireRevealedIdentity: PropTypes.bool, startTime: PropTypes.string, @@ -232,7 +238,6 @@ RoomConfigureDialog.defaultProps = { _id: undefined, name: '', private: false, - accessCode: null, type: 'normal', requireRevealedIdentity: false, startTime: '', diff --git a/client/src/components/RoomConfigureDialog.test.jsx b/client/src/components/RoomConfigureDialog.test.jsx new file mode 100644 index 0000000..9243b6a --- /dev/null +++ b/client/src/components/RoomConfigureDialog.test.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../theme'; +import RoomConfigureDialog from './RoomConfigureDialog'; + +const privateRoom = { + _id: 'room-one', + name: 'Private room', + private: true, + type: 'normal', + requireRevealedIdentity: false, + startTime: '', + twitchChannel: '', +}; + +const renderDialog = (props) => render( + + + , +); + +describe('RoomConfigureDialog passwords', () => { + it('allows an existing private room to be edited without replacing its password', () => { + const onSave = jest.fn(); + renderDialog({ + room: privateRoom, + open: true, + onSave, + onCancel: jest.fn(), + }); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + private: true, + password: undefined, + })); + }); + + it('sends an explicitly changed password', () => { + const onSave = jest.fn(); + renderDialog({ + room: privateRoom, + open: true, + onSave, + onCancel: jest.fn(), + }); + + fireEvent.change(screen.getByLabelText('New Password'), { target: { value: 'new-password' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + private: true, + password: 'new-password', + })); + }); + + it('requires a password when making an existing public room private', () => { + renderDialog({ + room: { ...privateRoom, private: false }, + open: true, + onSave: jest.fn(), + onCancel: jest.fn(), + }); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Private Room?' })); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('requires a password when creating a private room', () => { + renderDialog({ + open: true, + onSave: jest.fn(), + onCancel: jest.fn(), + }); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Private Room?' })); + + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); +}); diff --git a/client/src/components/RoomListItem.jsx b/client/src/components/RoomListItem.jsx index 0dfcbf8..eeddca6 100644 --- a/client/src/components/RoomListItem.jsx +++ b/client/src/components/RoomListItem.jsx @@ -3,18 +3,18 @@ import { useHistory } from 'react-router-dom'; import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { formatDistanceToNow } from 'date-fns'; -import Tooltip from '@material-ui/core/Tooltip'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; -import Typography from '@material-ui/core/Typography'; -import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import IconButton from '@material-ui/core/IconButton'; -import PublicIcon from '@material-ui/icons/Public'; -import PrivateIcon from '@material-ui/icons/Lock'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; +import Tooltip from '@mui/material/Tooltip'; +import ListItem from '@mui/material/ListItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; +import Typography from '@mui/material/Typography'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; +import PublicIcon from '@mui/icons-material/Public'; +import PrivateIcon from '@mui/icons-material/Lock'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useConfirm } from 'material-ui-confirm'; import { lcFetch } from '../lib/fetch'; // import ListItem from './ListItemLink'; diff --git a/client/src/components/Timer/ManualTimer.jsx b/client/src/components/Timer/ManualTimer.jsx index f9cef7a..087a650 100644 --- a/client/src/components/Timer/ManualTimer.jsx +++ b/client/src/components/Timer/ManualTimer.jsx @@ -1,19 +1,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { withStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import Input from '@material-ui/core/Input'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { withStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Input from '@mui/material/Input'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { formatTime, parseTime, setInterval, clearInterval, now, } from '../../lib/utils'; import initialStyles from './styles'; -const useStyles = withStyles((theme) => ({ +const withComponentStyles = withStyles((theme) => ({ ...initialStyles(theme), input: { maxWidth: '300px', @@ -60,7 +60,7 @@ const STATUS = { const STATUSES = [STATUS.RESTING, STATUS.INSPECTING, STATUS.TIMING, STATUS.SUBMITTING]; -class ManualTimer extends React.Component { +export class ManualTimer extends React.Component { constructor(props) { super(props); @@ -194,10 +194,15 @@ class ManualTimer extends React.Component { const { onSubmitTime } = this.props; const { penalties } = this.state; + const time = this.finalTime(); + + if (!Number.isFinite(time)) { + return; + } if (onSubmitTime) { onSubmitTime({ - time: this.finalTime(), + time, penalties: { DNF: penalties.DNF, inspection: penalties.inspection, @@ -554,4 +559,4 @@ ManualTimer.defaultProps = { onPriming: () => {}, }; -export default useStyles(ManualTimer); +export default withComponentStyles(ManualTimer); diff --git a/client/src/components/Timer/ManualTimer.test.jsx b/client/src/components/Timer/ManualTimer.test.jsx new file mode 100644 index 0000000..11a0b68 --- /dev/null +++ b/client/src/components/Timer/ManualTimer.test.jsx @@ -0,0 +1,53 @@ +import { ManualTimer } from './ManualTimer'; + +const makeProps = () => ({ + classes: { + root: 'root', + input: 'input', + inputProps: 'inputProps', + penaltyBox: 'penaltyBox', + }, + onPriming: jest.fn(), + onStatusChange: jest.fn(), + onSubmitTime: jest.fn(), + useInspection: false, +}); + +const createTimer = (props) => { + const timer = new ManualTimer(props); + timer.setState = (update) => { + timer.state = { ...timer.state, ...update }; + }; + return timer; +}; + +describe('manual timer submission', () => { + it.each(['', 'not-a-time'])('keeps invalid input for correction: %p', (timeInput) => { + const props = makeProps(); + const timer = createTimer(props); + timer.state.timeInput = timeInput; + + timer.submitTime({ preventDefault: jest.fn() }); + + expect(props.onSubmitTime).not.toHaveBeenCalled(); + expect(timer.state.timeInput).toBe(timeInput); + }); + + it('submits and resets a valid time', () => { + const props = makeProps(); + const timer = createTimer(props); + timer.state.timeInput = '5.00'; + + timer.submitTime({ preventDefault: jest.fn() }); + + expect(props.onSubmitTime).toHaveBeenCalledWith({ + time: 5000, + penalties: { + AUF: false, + DNF: false, + inspection: false, + }, + }); + expect(timer.state.timeInput).toBe(''); + }); +}); diff --git a/client/src/components/Timer/SpacebarTimer.jsx b/client/src/components/Timer/SpacebarTimer.jsx index d291dec..cf6f2ed 100644 --- a/client/src/components/Timer/SpacebarTimer.jsx +++ b/client/src/components/Timer/SpacebarTimer.jsx @@ -1,19 +1,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { withStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormGroup from '@material-ui/core/FormGroup'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { withStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { formatTime, setInterval, clearInterval, now, } from '../../lib/utils'; import styles from './styles'; -const useStyles = withStyles(styles); +const withComponentStyles = withStyles(styles); /* Complicated beast. @@ -475,4 +475,4 @@ Timer.defaultProps = { onPriming: () => {}, }; -export default useStyles(Timer); +export default withComponentStyles(Timer); diff --git a/client/src/components/Timer/StackmatTimer.jsx b/client/src/components/Timer/StackmatTimer.jsx index 039ec65..a68e5cd 100644 --- a/client/src/components/Timer/StackmatTimer.jsx +++ b/client/src/components/Timer/StackmatTimer.jsx @@ -3,19 +3,19 @@ import StackmatSignalProcessor from 'stackmat-signal-processor'; import React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { withStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormGroup from '@material-ui/core/FormGroup'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { withStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { formatTime, setInterval, clearInterval, now, } from '../../lib/utils'; import styles from './styles'; -const useStyles = withStyles(styles); +const withComponentStyles = withStyles(styles); const STATUS = { RESTING: 'RESTING', @@ -228,6 +228,10 @@ class Timer extends React.Component { setStatus (status) { this.setState({ status }); + if (status === STATUS.TIMING) { + this.props.onPriming(); + } + if (STATUSES.indexOf(status) > -1) { this.props.onStatusChange(status); } @@ -427,4 +431,4 @@ Timer.defaultProps = { onPriming: () => {}, }; -export default useStyles(Timer); +export default withComponentStyles(Timer); diff --git a/client/src/components/Timer/index.jsx b/client/src/components/Timer/index.jsx index d796505..a0be36e 100644 --- a/client/src/components/Timer/index.jsx +++ b/client/src/components/Timer/index.jsx @@ -1,4 +1,4 @@ -/* eslint-disable react/jsx-props-no-spreading */ + import React from 'react'; import PropTypes from 'prop-types'; import SpacebarTimer from './SpacebarTimer'; diff --git a/client/src/components/User.jsx b/client/src/components/User.jsx index 57183c5..5e07e90 100644 --- a/client/src/components/User.jsx +++ b/client/src/components/User.jsx @@ -1,9 +1,10 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Popover from '@material-ui/core/Popover'; -import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@mui/styles'; +import Popover from '@mui/material/Popover'; +import Typography from '@mui/material/Typography'; +import { Link } from 'react-router-dom'; const useStyles = makeStyles((theme) => ({ root: { @@ -33,6 +34,7 @@ const useStyles = makeStyles((theme) => ({ function User({ user, admin, className }) { const classes = useStyles(); const [anchorEl, setAnchorEl] = React.useState(null); + const profileKey = user.profileKey; const handlePopoverOpen = (event) => { setAnchorEl(event.currentTarget); @@ -55,7 +57,8 @@ function User({ user, admin, className }) { aria-haspopup="true" onMouseEnter={handlePopoverOpen} onMouseLeave={handlePopoverClose} - component="span" + component={profileKey ? Link : 'span'} + to={profileKey ? `/users/${profileKey}` : undefined} variant="subtitle2" > {user.displayName} @@ -106,10 +109,12 @@ function User({ user, admin, className }) { User.propTypes = { user: PropTypes.shape({ + id: PropTypes.number, displayName: PropTypes.string, username: PropTypes.string, wcaId: PropTypes.string, name: PropTypes.string, + profileKey: PropTypes.string, showWCAID: PropTypes.bool, }).isRequired, admin: PropTypes.bool, diff --git a/client/src/components/User.test.jsx b/client/src/components/User.test.jsx new file mode 100644 index 0000000..cb99e4e --- /dev/null +++ b/client/src/components/User.test.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from '../theme'; +import User from './User'; + +describe('User', () => { + const baseUser = { displayName: 'Cuber', id: 42 }; + + const renderUser = (user) => render( + + + + + , + ); + + it('does not turn an internal user ID into a public profile link', () => { + renderUser(baseUser); + const name = screen.getByText('Cuber'); + + expect(name.tagName).toBe('SPAN'); + expect(name).not.toHaveAttribute('href'); + }); + + it('links only through a server-provided public profile key', () => { + renderUser({ ...baseUser, profileKey: 'cuber' }); + + expect(screen.getByText('Cuber')).toHaveAttribute('href', '/users/cuber'); + }); +}); diff --git a/client/src/components/WCARedirect.jsx b/client/src/components/WCARedirect.jsx index 52a3f06..17cad31 100644 --- a/client/src/components/WCARedirect.jsx +++ b/client/src/components/WCARedirect.jsx @@ -3,13 +3,13 @@ import PropTypes from 'prop-types'; import { connect, useDispatch } from 'react-redux'; import qs from 'qs'; import { Redirect, useLocation } from 'react-router-dom'; -import { makeStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Paper from '@material-ui/core/Paper'; -import Typography from '@material-ui/core/Typography'; -import LinearProgress from '@material-ui/core/LinearProgress'; -import Alert from '@material-ui/lab/Alert'; -import AlertTitle from '@material-ui/lab/AlertTitle'; +import { makeStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import LinearProgress from '@mui/material/LinearProgress'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; import { lcFetch } from '../lib/fetch'; import { userChanged } from '../store/user/actions'; diff --git a/client/src/components/common/EditableTextField.jsx b/client/src/components/common/EditableTextField.jsx index 8302b26..93a83e8 100644 --- a/client/src/components/common/EditableTextField.jsx +++ b/client/src/components/common/EditableTextField.jsx @@ -1,14 +1,14 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Input from '@material-ui/core/Input'; -import InputLabel from '@material-ui/core/InputLabel'; -import Button from '@material-ui/core/Button'; -import ButtonGroup from '@material-ui/core/ButtonGroup'; -import FormControl from '@material-ui/core/FormControl'; -import FormHelperText from '@material-ui/core/FormHelperText'; -import ClickAwayListener from '@material-ui/core/ClickAwayListener'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Input from '@mui/material/Input'; +import InputLabel from '@mui/material/InputLabel'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; const useStyles = makeStyles((theme) => ({ root: { diff --git a/client/src/components/common/EventListItem.jsx b/client/src/components/common/EventListItem.jsx index b953217..c990023 100644 --- a/client/src/components/common/EventListItem.jsx +++ b/client/src/components/common/EventListItem.jsx @@ -3,23 +3,24 @@ import { useHistory } from 'react-router-dom'; import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { formatDistanceToNow } from 'date-fns'; -import Tooltip from '@material-ui/core/Tooltip'; -import { makeStyles, useTheme } from '@material-ui/core/styles'; -import Card from '@material-ui/core/Card'; -import CardHeader from '@material-ui/core/CardHeader'; -import CardContent from '@material-ui/core/CardContent'; -import CardActions from '@material-ui/core/CardActions'; -import CardMedia from '@material-ui/core/CardMedia'; -import Alert from '@material-ui/lab/Alert'; -import Typography from '@material-ui/core/Typography'; -import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import IconButton from '@material-ui/core/IconButton'; -import Button from '@material-ui/core/Button'; -import PublicIcon from '@material-ui/icons/Public'; -import PrivateIcon from '@material-ui/icons/Lock'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; -import ShareIcon from '@material-ui/icons/Share'; +import Tooltip from '@mui/material/Tooltip'; +import { useTheme } from '@mui/material/styles'; +import { makeStyles } from '@mui/styles'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import CardMedia from '@mui/material/CardMedia'; +import Alert from '@mui/material/Alert'; +import Typography from '@mui/material/Typography'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import PublicIcon from '@mui/icons-material/Public'; +import PrivateIcon from '@mui/icons-material/Lock'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import ShareIcon from '@mui/icons-material/Share'; import { useConfirm } from 'material-ui-confirm'; import { TwitchEmbed } from 'react-twitch-embed'; import { lcFetch } from '../../lib/fetch'; @@ -167,7 +168,7 @@ function RoomListItem({ id={`channel-${room.twitchChannel}-${room._id}`} muted withChat={false} - theme={theme.palette.type} + theme={theme.palette.mode} width="100%" /> diff --git a/client/src/components/common/HelpPopover.jsx b/client/src/components/common/HelpPopover.jsx index ba58ece..b0cd17f 100644 --- a/client/src/components/common/HelpPopover.jsx +++ b/client/src/components/common/HelpPopover.jsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; -import Popover from '@material-ui/core/Popover'; -import Typography from '@material-ui/core/Typography'; -import IconButton from '@material-ui/core/IconButton'; -import HelpIcon from '@material-ui/icons/Help'; +import Popover from '@mui/material/Popover'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import HelpIcon from '@mui/icons-material/Help'; -export default () => { +const HelpPopover = () => { const [helpAnchor, setHelpAnchor] = useState(null); return ( @@ -39,3 +39,5 @@ export default () => {
); }; + +export default HelpPopover; diff --git a/client/src/components/common/ListItemLink.jsx b/client/src/components/common/ListItemLink.jsx index 26318db..74e3db7 100644 --- a/client/src/components/common/ListItemLink.jsx +++ b/client/src/components/common/ListItemLink.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import ListItem from '@material-ui/core/ListItem'; +import ListItem from '@mui/material/ListItem'; function ListItemLink({ children, to, disabled }) { return ( diff --git a/client/src/components/common/PrivateRoute.jsx b/client/src/components/common/PrivateRoute.jsx index d1564e2..e91d6b8 100644 --- a/client/src/components/common/PrivateRoute.jsx +++ b/client/src/components/common/PrivateRoute.jsx @@ -4,7 +4,7 @@ import { Route, Redirect } from 'react-router-dom'; import { connect } from 'react-redux'; const PrivateRoute = ({ - user, children, isAdminRoute, ...rest + user, isAdminRoute, ...rest }) => { const { fetching, id } = user; const loggedIn = !fetching && id; @@ -24,16 +24,11 @@ const PrivateRoute = ({ }; PrivateRoute.propTypes = { - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node, - ]), isAdminRoute: PropTypes.bool, user: PropTypes.shape(), }; PrivateRoute.defaultProps = { - children: [], isAdminRoute: false, user: { }, diff --git a/client/src/components/common/Profile.jsx b/client/src/components/common/Profile.jsx index 9a9bda0..b389300 100644 --- a/client/src/components/common/Profile.jsx +++ b/client/src/components/common/Profile.jsx @@ -1,15 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Grid from '@material-ui/core/Grid'; -import Avatar from '@material-ui/core/Avatar'; -import Typography from '@material-ui/core/Typography'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import Divider from '@material-ui/core/Divider'; -import Alert from '@material-ui/lab/Alert'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Grid from '@mui/material/Grid'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import Alert from '@mui/material/Alert'; import EditableTextField from './EditableTextField'; import { lcFetch } from '../../lib/fetch'; import { updateProfile } from '../../store/user/actions'; @@ -19,12 +19,9 @@ const validate = (username) => { return ''; } - if (username.indexOf(' ') > -1) { - return 'Username cannot contains spaces'; - } - - if (username.length >= 16) { - return 'Username cannot be longer than 16 characters'; + const normalizedUsername = username.normalize('NFKC').trim(); + if (normalizedUsername.length > 15 || !/^[A-Za-z0-9_-]+$/.test(normalizedUsername)) { + return 'Use 1–15 letters, numbers, underscores, or hyphens'; } return ''; diff --git a/client/src/components/common/Scramble.jsx b/client/src/components/common/Scramble.jsx index 5629e7e..6556cba 100644 --- a/client/src/components/common/Scramble.jsx +++ b/client/src/components/common/Scramble.jsx @@ -1,8 +1,8 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@mui/styles'; +import Typography from '@mui/material/Typography'; const useStyles = makeStyles((theme) => ({ root: { @@ -18,7 +18,7 @@ const useStyles = makeStyles((theme) => ({ disabled: { color: theme.palette.text.disabled, }, - // eslint-disable-next-line quote-props + minx: { lineHeight: '.90rem', fontSize: '.75rem', diff --git a/client/src/components/common/Scramble.test.jsx b/client/src/components/common/Scramble.test.jsx index 1fbad03..76e2ba2 100644 --- a/client/src/components/common/Scramble.test.jsx +++ b/client/src/components/common/Scramble.test.jsx @@ -1,15 +1,25 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, screen } from '@testing-library/react'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; import Scramble from './Scramble'; const scramble = 'test'; +const theme = createTheme(); + +const renderScramble = (props) => render( + + + , +); it('renders without crashing', () => { - shallow(); + renderScramble({ scrambles: [scramble] }); + + expect(screen.getByText(scramble)).toBeInTheDocument(); }); it('renders 3x3 text correctly', () => { - const component = shallow(); + renderScramble({ event: '333', scrambles: [scramble] }); - expect(component.text()).toEqual(scramble); + expect(screen.getByText(scramble)).toHaveTextContent(scramble); }); diff --git a/client/src/components/common/ScramblePreview.jsx b/client/src/components/common/ScramblePreview.jsx new file mode 100644 index 0000000..9359b2f --- /dev/null +++ b/client/src/components/common/ScramblePreview.jsx @@ -0,0 +1,170 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import loadTwistyPlayer from '../../lib/cubingTwisty'; +import Scramble from './Scramble'; + +export const puzzleByEvent = { + 222: '2x2x2', + 333: '3x3x3', + '333bf': '3x3x3', + '333oh': '3x3x3', + '333ft': '3x3x3', + 444: '4x4x4', + '444bf': '4x4x4', + 555: '5x5x5', + '555bf': '5x5x5', + 666: '6x6x6', + 777: '7x7x7', + minx: 'megaminx', + pyram: 'pyraminx', + clock: 'clock', + 'clock-optimal': 'clock', + skewb: 'skewb', + sq1: 'square1', + fto: 'fto', + pll: '3x3x3', + zbll: '3x3x3', + lse: '3x3x3', + ru: '3x3x3', +}; + +function TwistyVisualization({ event, puzzle, scramble, size, expanded }) { + const containerRef = useRef(null); + + useEffect(() => { + let disposed = false; + let player; + + if (!puzzle || !scramble) { + return undefined; + } + + loadTwistyPlayer().then((TwistyPlayer) => { + if (disposed) { + return; + } + + player = new TwistyPlayer({ + puzzle, + alg: scramble, + visualization: '2D', + controlPanel: 'none', + background: 'none', + }); + // cubing.js 0.63.3 exposes this setter but rejects timestamp in constructor config. + player.timestamp = 'end'; + player.style.width = '100%'; + player.style.height = '100%'; + containerRef.current.appendChild(player); + }).catch(() => { + // The scramble text remains available if the preview cannot be rendered. + }); + + return () => { + disposed = true; + if (player && player.parentNode) { + player.parentNode.removeChild(player); + } + }; + }, [puzzle, scramble]); + + if (!puzzle || !scramble) { + return null; + } + + return ( +
+ ); +} + +TwistyVisualization.propTypes = { + event: PropTypes.string.isRequired, + puzzle: PropTypes.string.isRequired, + scramble: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + expanded: PropTypes.bool, +}; + +TwistyVisualization.defaultProps = { + expanded: false, +}; + +function ScramblePreview({ event, scramble, size }) { + const [expanded, setExpanded] = useState(false); + const puzzle = puzzleByEvent[event]; + + if (!puzzle || !scramble) { + return null; + } + + const handleKeyDown = (keyEvent) => { + if (keyEvent.key === 'Enter' || keyEvent.key === ' ') { + keyEvent.preventDefault(); + setExpanded(true); + } + }; + + return ( + <> +
setExpanded(true)} + onKeyDown={handleKeyDown} + style={{ cursor: 'zoom-in', width: '100%', maxWidth: size }} + > + +
+ setExpanded(false)} + aria-label={`${event} enlarged scramble preview`} + > + + +
+ +
+
+
+ + ); +} + +ScramblePreview.propTypes = { + event: PropTypes.string.isRequired, + scramble: PropTypes.string, + size: PropTypes.number, +}; + +ScramblePreview.defaultProps = { + scramble: '', + size: 240, +}; + +export default ScramblePreview; diff --git a/client/src/components/common/ScramblePreview.test.jsx b/client/src/components/common/ScramblePreview.test.jsx new file mode 100644 index 0000000..d397e49 --- /dev/null +++ b/client/src/components/common/ScramblePreview.test.jsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; +import Events from '../../lib/events.json'; +import loadTwistyPlayer from '../../lib/cubingTwisty'; +import { ThemeProvider } from '../../theme'; +import ScramblePreview, { puzzleByEvent } from './ScramblePreview'; + +jest.mock('../../lib/cubingTwisty', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const players = []; + +const renderPreview = (element) => render({element}); + +const flushPlayerLoad = () => act(async () => { + await Promise.resolve(); +}); + +class TwistyPlayer { + constructor(config) { + const player = document.createElement('div'); + player.config = config; + players.push(player); + return player; + } +} + +beforeEach(() => { + jest.clearAllMocks(); + players.length = 0; + loadTwistyPlayer.mockResolvedValue(TwistyPlayer); +}); + +it('maps every configured event to a cubing.js puzzle', () => { + expect(Object.keys(puzzleByEvent).sort()).toEqual( + Events.map(({ id }) => id).sort(), + ); +}); + +it('renders the static 2D end state of a scramble', async () => { + const { getByRole } = renderPreview( + , + ); + + await flushPlayerLoad(); + expect(players).toHaveLength(1); + + expect(players[0].config).toEqual({ + puzzle: 'pyraminx', + alg: 'R U', + visualization: '2D', + controlPanel: 'none', + background: 'none', + }); + expect(players[0].timestamp).toBe('end'); + expect(getByRole('img')).toContainElement(players[0]); +}); + +it('enlarges the preview with its scramble text', async () => { + const { getByLabelText, getAllByRole, getByText } = renderPreview( + , + ); + await flushPlayerLoad(); + + fireEvent.click(getByLabelText('Enlarge 777 scramble preview')); + await flushPlayerLoad(); + + expect(getByLabelText('777 enlarged scramble preview')).toBeInTheDocument(); + expect(getByText('R U')).toBeInTheDocument(); + expect(getAllByRole('img')).toHaveLength(1); + expect(getAllByRole('img')[0]).toHaveStyle('width: 100%'); + expect(players).toHaveLength(2); +}); + +it('replaces and removes the player when the scramble changes', async () => { + const { rerender, unmount, getByRole } = render( + , + ); + await flushPlayerLoad(); + expect(players).toHaveLength(1); + + rerender(); + await flushPlayerLoad(); + expect(players).toHaveLength(2); + + expect(getByRole('img')).not.toContainElement(players[0]); + expect(getByRole('img')).toContainElement(players[1]); + + unmount(); + expect(players[1].parentNode).toBeNull(); +}); + +it('renders nothing without a supported event and scramble', () => { + const { container, rerender } = render( + , + ); + expect(container.innerHTML).toBe(''); + + rerender(); + expect(container.innerHTML).toBe(''); + expect(loadTwistyPlayer).not.toHaveBeenCalled(); +}); + +it('keeps the room usable if cubing.js cannot load', async () => { + loadTwistyPlayer.mockRejectedValue(new Error('preview failed')); + const { getByRole } = render( + , + ); + + await act(async () => {}); + + expect(getByRole('img').innerHTML).toBe(''); +}); diff --git a/client/src/components/common/TableCellButton.jsx b/client/src/components/common/TableCellButton.jsx index bccea20..c67a799 100644 --- a/client/src/components/common/TableCellButton.jsx +++ b/client/src/components/common/TableCellButton.jsx @@ -1,9 +1,9 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import TableCell from '@material-ui/core/TableCell'; -import Button from '@material-ui/core/Button'; +import { makeStyles } from '@mui/styles'; +import TableCell from '@mui/material/TableCell'; +import Button from '@mui/material/Button'; const useStyles = makeStyles(() => ({ root: { @@ -26,13 +26,13 @@ function TableCellButton({