issue 77
This commit is contained in:
parent
7c11ee50da
commit
95cc222302
|
|
@ -23,19 +23,34 @@ jobs:
|
||||||
include:
|
include:
|
||||||
- name: api-rate-limiter
|
- name: api-rate-limiter
|
||||||
type: python
|
type: python
|
||||||
path: PROJECTS/api-rate-limiter
|
path: PROJECTS/advanced/api-rate-limiter
|
||||||
- name: dns-lookup
|
- name: dns-lookup
|
||||||
type: python
|
type: python
|
||||||
path: PROJECTS/dns-lookup
|
path: PROJECTS/beginner/dns-lookup
|
||||||
- name: keylogger
|
- name: keylogger
|
||||||
type: python
|
type: python
|
||||||
path: PROJECTS/keylogger
|
path: PROJECTS/beginner/keylogger
|
||||||
- name: api-security-scanner
|
|
||||||
type: typescript
|
|
||||||
path: PROJECTS/api-security-scanner/frontend
|
|
||||||
- name: docker-security-audit
|
- name: docker-security-audit
|
||||||
type: go
|
type: go
|
||||||
path: PROJECTS/docker-security-audit
|
path: PROJECTS/beginner/docker-security-audit
|
||||||
|
- name: bug-bounty-platform-frontend
|
||||||
|
type: biome
|
||||||
|
path: PROJECTS/advanced/bug-bounty-platform/frontend
|
||||||
|
- name: c2-beacon-frontend
|
||||||
|
type: biome
|
||||||
|
path: PROJECTS/beginner/c2-beacon/frontend
|
||||||
|
- name: api-security-scanner-frontend
|
||||||
|
type: biome
|
||||||
|
path: PROJECTS/intermediate/api-security-scanner/frontend
|
||||||
|
- name: siem-dashboard-frontend
|
||||||
|
type: biome
|
||||||
|
path: PROJECTS/intermediate/siem-dashboard/frontend
|
||||||
|
- name: encrypted-p2p-chat-frontend
|
||||||
|
type: biome
|
||||||
|
path: PROJECTS/advanced/encrypted-p2p-chat/frontend
|
||||||
|
- name: fullstack-template-frontend
|
||||||
|
type: biome
|
||||||
|
path: TEMPLATES/fullstack-template/frontend
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
|
@ -68,18 +83,32 @@ jobs:
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
# TypeScript Setup
|
# Biome Setup
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: matrix.type == 'typescript'
|
if: matrix.type == 'biome'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: ${{ matrix.path }}/package-lock.json
|
|
||||||
|
|
||||||
- name: Install TypeScript dependencies
|
- name: Setup pnpm
|
||||||
if: matrix.type == 'typescript'
|
if: matrix.type == 'biome'
|
||||||
run: npm install
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Cache pnpm store
|
||||||
|
if: matrix.type == 'biome'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/pnpm/store/v10
|
||||||
|
key: ${{ runner.os }}-pnpm-${{ matrix.name }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', matrix.path)) }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-${{ matrix.name }}-
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
if: matrix.type == 'biome'
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Go Setup
|
# Go Setup
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
|
|
@ -135,35 +164,20 @@ jobs:
|
||||||
cat mypy-output.txt
|
cat mypy-output.txt
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# TypeScript Linting
|
# Biome Linting
|
||||||
- name: Run ESLint
|
- name: Run Biome
|
||||||
if: matrix.type == 'typescript'
|
if: matrix.type == 'biome'
|
||||||
id: eslint
|
id: biome
|
||||||
run: |
|
run: |
|
||||||
echo "Running ESLint..."
|
echo "Running Biome check..."
|
||||||
if npm run lint:eslint > eslint-output.txt 2>&1; then
|
if npx @biomejs/biome check . > biome-output.txt 2>&1; then
|
||||||
echo "ESLINT_PASSED=true" >> $GITHUB_ENV
|
echo "BIOME_PASSED=true" >> $GITHUB_ENV
|
||||||
echo "No ESLint errors found!"
|
echo "No Biome errors found!"
|
||||||
else
|
else
|
||||||
echo "ESLINT_PASSED=false" >> $GITHUB_ENV
|
echo "BIOME_PASSED=false" >> $GITHUB_ENV
|
||||||
echo "ESLint found issues!"
|
echo "Biome found issues!"
|
||||||
fi
|
fi
|
||||||
cat eslint-output.txt
|
cat biome-output.txt
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Run TypeScript Check
|
|
||||||
if: matrix.type == 'typescript'
|
|
||||||
id: tsc
|
|
||||||
run: |
|
|
||||||
echo "Running TypeScript type checking..."
|
|
||||||
if npx tsc --noEmit > tsc-output.txt 2>&1; then
|
|
||||||
echo "TSC_PASSED=true" >> $GITHUB_ENV
|
|
||||||
echo "No TypeScript errors found!"
|
|
||||||
else
|
|
||||||
echo "TSC_PASSED=false" >> $GITHUB_ENV
|
|
||||||
echo "TypeScript found issues!"
|
|
||||||
fi
|
|
||||||
cat tsc-output.txt
|
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# Go Linting
|
# Go Linting
|
||||||
|
|
@ -247,46 +261,31 @@ jobs:
|
||||||
echo "<!-- lint-check-${{ matrix.name }}-marker -->"
|
echo "<!-- lint-check-${{ matrix.name }}-marker -->"
|
||||||
} > lint-report.md
|
} > lint-report.md
|
||||||
|
|
||||||
# Create Summary for TypeScript
|
# Create Summary for Biome
|
||||||
- name: Create TypeScript Lint Summary
|
- name: Create Biome Lint Summary
|
||||||
if: matrix.type == 'typescript' && github.event_name == 'pull_request'
|
if: matrix.type == 'biome' && github.event_name == 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
{
|
{
|
||||||
echo "## Lint Results: ${{ matrix.name }}"
|
echo "## Lint Results: ${{ matrix.name }}"
|
||||||
echo ''
|
echo ''
|
||||||
|
|
||||||
# ESLint Status
|
# Biome Status
|
||||||
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]]; then
|
if [[ "${{ env.BIOME_PASSED }}" == "true" ]]; then
|
||||||
echo '### ESLint: **Passed**'
|
echo '### Biome: **Passed**'
|
||||||
echo 'No ESLint issues found.'
|
echo 'No Biome issues found.'
|
||||||
else
|
else
|
||||||
echo '### ESLint: **Issues Found**'
|
echo '### Biome: **Issues Found**'
|
||||||
echo '<details><summary>View ESLint output</summary>'
|
echo '<details><summary>View Biome output</summary>'
|
||||||
echo ''
|
echo ''
|
||||||
echo '```'
|
echo '```'
|
||||||
head -100 eslint-output.txt
|
head -100 biome-output.txt
|
||||||
echo '```'
|
|
||||||
echo '</details>'
|
|
||||||
fi
|
|
||||||
echo ''
|
|
||||||
|
|
||||||
# TypeScript Status
|
|
||||||
if [[ "${{ env.TSC_PASSED }}" == "true" ]]; then
|
|
||||||
echo '### TypeScript: **Passed**'
|
|
||||||
echo 'No type errors found.'
|
|
||||||
else
|
|
||||||
echo '### TypeScript: **Issues Found**'
|
|
||||||
echo '<details><summary>View TypeScript output</summary>'
|
|
||||||
echo ''
|
|
||||||
echo '```'
|
|
||||||
head -100 tsc-output.txt
|
|
||||||
echo '```'
|
echo '```'
|
||||||
echo '</details>'
|
echo '</details>'
|
||||||
fi
|
fi
|
||||||
echo ''
|
echo ''
|
||||||
|
|
||||||
# Overall Summary
|
# Overall Summary
|
||||||
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]] && [[ "${{ env.TSC_PASSED }}" == "true" ]]; then
|
if [[ "${{ env.BIOME_PASSED }}" == "true" ]]; then
|
||||||
echo '---'
|
echo '---'
|
||||||
echo '### All checks passed!'
|
echo '### All checks passed!'
|
||||||
else
|
else
|
||||||
|
|
@ -350,9 +349,9 @@ jobs:
|
||||||
echo "Python lint checks failed"
|
echo "Python lint checks failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
elif [[ "${{ matrix.type }}" == "typescript" ]]; then
|
elif [[ "${{ matrix.type }}" == "biome" ]]; then
|
||||||
if [[ "${{ env.ESLINT_PASSED }}" == "false" ]] || [[ "${{ env.TSC_PASSED }}" == "false" ]]; then
|
if [[ "${{ env.BIOME_PASSED }}" == "false" ]]; then
|
||||||
echo "TypeScript lint checks failed"
|
echo "Biome lint checks failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
elif [[ "${{ matrix.type }}" == "go" ]]; then
|
elif [[ "${{ matrix.type }}" == "go" ]]; then
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,50 @@ repos:
|
||||||
# TODO: add golangci checks for GO projects
|
# TODO: add golangci checks for GO projects
|
||||||
|
|
||||||
|
|
||||||
# TODO: replace eslint in any full-stack (React) projects with biome then add biome checks
|
# Biome Frontend Checks
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: biome-bug-bounty-platform
|
||||||
|
name: biome check (bug-bounty-platform frontend)
|
||||||
|
entry: bash -c 'cd PROJECTS/advanced/bug-bounty-platform/frontend && npx @biomejs/biome check .'
|
||||||
|
language: system
|
||||||
|
files: ^PROJECTS/advanced/bug-bounty-platform/frontend/src/
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: biome-c2-beacon
|
||||||
|
name: biome check (c2-beacon frontend)
|
||||||
|
entry: bash -c 'cd PROJECTS/beginner/c2-beacon/frontend && npx @biomejs/biome check .'
|
||||||
|
language: system
|
||||||
|
files: ^PROJECTS/beginner/c2-beacon/frontend/src/
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: biome-api-security-scanner
|
||||||
|
name: biome check (api-security-scanner frontend)
|
||||||
|
entry: bash -c 'cd PROJECTS/intermediate/api-security-scanner/frontend && npx @biomejs/biome check .'
|
||||||
|
language: system
|
||||||
|
files: ^PROJECTS/intermediate/api-security-scanner/frontend/src/
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: biome-siem-dashboard
|
||||||
|
name: biome check (siem-dashboard frontend)
|
||||||
|
entry: bash -c 'cd PROJECTS/intermediate/siem-dashboard/frontend && npx @biomejs/biome check .'
|
||||||
|
language: system
|
||||||
|
files: ^PROJECTS/intermediate/siem-dashboard/frontend/src/
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: biome-encrypted-p2p-chat
|
||||||
|
name: biome check (encrypted-p2p-chat frontend)
|
||||||
|
entry: bash -c 'cd PROJECTS/advanced/encrypted-p2p-chat/frontend && npx @biomejs/biome check .'
|
||||||
|
language: system
|
||||||
|
files: ^PROJECTS/advanced/encrypted-p2p-chat/frontend/src/
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: biome-fullstack-template
|
||||||
|
name: biome check (fullstack-template frontend)
|
||||||
|
entry: bash -c 'cd TEMPLATES/fullstack-template/frontend && npx @biomejs/biome check .'
|
||||||
|
language: system
|
||||||
|
files: ^TEMPLATES/fullstack-template/frontend/src/
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v6.0.0
|
rev: v6.0.0
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@ classifiers = [
|
||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.128.7",
|
"fastapi[standard]>=0.129.0",
|
||||||
"pydantic>=2.12.5,<3.0.0",
|
"pydantic>=2.12.5,<3.0.0",
|
||||||
"pydantic-settings>=2.12.0,<3.0.0",
|
"pydantic-settings>=2.13.0",
|
||||||
"redis>=7.1.1",
|
"redis>=7.2.0",
|
||||||
"pyjwt>=2.11.0",
|
"pyjwt>=2.11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -36,12 +36,12 @@ dev = [
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"fakeredis>=2.33.0",
|
"fakeredis>=2.34.0",
|
||||||
"time-machine>=3.2.0",
|
"time-machine>=3.2.0",
|
||||||
"asgi-lifespan>=2.1.0",
|
"asgi-lifespan>=2.1.0",
|
||||||
"mypy>=1.19.1",
|
"mypy>=1.19.1",
|
||||||
"types-redis>=4.6.0.20241004",
|
"types-redis>=4.6.0.20241004",
|
||||||
"ruff>=0.15.0",
|
"ruff>=0.15.1",
|
||||||
"pylint>=4.0.4",
|
"pylint>=4.0.4",
|
||||||
"pylint-pydantic>=0.4.1",
|
"pylint-pydantic>=0.4.1",
|
||||||
"pylint-per-file-ignores>=3.2.0",
|
"pylint-per-file-ignores>=3.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc -b",
|
||||||
"lint:scss": "stylelint '**/*.scss'",
|
"lint:scss": "stylelint '**/*.scss'",
|
||||||
"lint:scss:fix": "stylelint '**/*.scss' --fix"
|
"lint:scss:fix": "stylelint '**/*.scss' --fix"
|
||||||
},
|
},
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.8",
|
"@biomejs/biome": "^2.4.2",
|
||||||
"@tanstack/react-query-devtools": "^5.91.1",
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
"@types/node": "^24.10.2",
|
"@types/node": "^24.10.2",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@ importers:
|
||||||
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
|
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: ^2.3.8
|
specifier: ^2.4.2
|
||||||
version: 2.3.11
|
version: 2.4.2
|
||||||
'@tanstack/react-query-devtools':
|
'@tanstack/react-query-devtools':
|
||||||
specifier: ^5.91.1
|
specifier: ^5.91.1
|
||||||
version: 5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3)
|
version: 5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3)
|
||||||
|
|
@ -170,55 +170,59 @@ packages:
|
||||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@biomejs/biome@2.3.11':
|
'@biomejs/biome@2.4.2':
|
||||||
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
|
resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.3.11':
|
'@biomejs/cli-darwin-arm64@2.4.2':
|
||||||
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==}
|
resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.3.11':
|
'@biomejs/cli-darwin-x64@2.4.2':
|
||||||
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==}
|
resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.3.11':
|
'@biomejs/cli-linux-arm64-musl@2.4.2':
|
||||||
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==}
|
resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.3.11':
|
'@biomejs/cli-linux-arm64@2.4.2':
|
||||||
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
|
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.3.11':
|
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||||
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
|
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.3.11':
|
'@biomejs/cli-linux-x64@2.4.2':
|
||||||
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
|
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.3.11':
|
'@biomejs/cli-win32-arm64@2.4.2':
|
||||||
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
|
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.3.11':
|
'@biomejs/cli-win32-x64@2.4.2':
|
||||||
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==}
|
resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
@ -344,36 +348,42 @@ packages:
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@parcel/watcher-win32-arm64@2.5.1':
|
'@parcel/watcher-win32-arm64@2.5.1':
|
||||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||||
|
|
@ -432,24 +442,28 @@ packages:
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
|
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
|
||||||
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
|
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
|
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
|
||||||
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
|
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
|
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
|
||||||
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
|
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
|
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
|
||||||
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
|
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
|
||||||
|
|
@ -954,24 +968,28 @@ packages:
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.30.2:
|
lightningcss-linux-arm64-musl@1.30.2:
|
||||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.30.2:
|
lightningcss-linux-x64-gnu@1.30.2:
|
||||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.30.2:
|
lightningcss-linux-x64-musl@1.30.2:
|
||||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.30.2:
|
lightningcss-win32-arm64-msvc@1.30.2:
|
||||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||||
|
|
@ -1528,39 +1546,39 @@ snapshots:
|
||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@biomejs/biome@2.3.11':
|
'@biomejs/biome@2.4.2':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@biomejs/cli-darwin-arm64': 2.3.11
|
'@biomejs/cli-darwin-arm64': 2.4.2
|
||||||
'@biomejs/cli-darwin-x64': 2.3.11
|
'@biomejs/cli-darwin-x64': 2.4.2
|
||||||
'@biomejs/cli-linux-arm64': 2.3.11
|
'@biomejs/cli-linux-arm64': 2.4.2
|
||||||
'@biomejs/cli-linux-arm64-musl': 2.3.11
|
'@biomejs/cli-linux-arm64-musl': 2.4.2
|
||||||
'@biomejs/cli-linux-x64': 2.3.11
|
'@biomejs/cli-linux-x64': 2.4.2
|
||||||
'@biomejs/cli-linux-x64-musl': 2.3.11
|
'@biomejs/cli-linux-x64-musl': 2.4.2
|
||||||
'@biomejs/cli-win32-arm64': 2.3.11
|
'@biomejs/cli-win32-arm64': 2.4.2
|
||||||
'@biomejs/cli-win32-x64': 2.3.11
|
'@biomejs/cli-win32-x64': 2.4.2
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.3.11':
|
'@biomejs/cli-darwin-arm64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.3.11':
|
'@biomejs/cli-darwin-x64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.3.11':
|
'@biomejs/cli-linux-arm64-musl@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.3.11':
|
'@biomejs/cli-linux-arm64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.3.11':
|
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.3.11':
|
'@biomejs/cli-linux-x64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.3.11':
|
'@biomejs/cli-win32-arm64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.3.11':
|
'@biomejs/cli-win32-x64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@cacheable/memory@2.0.7':
|
'@cacheable/memory@2.0.7':
|
||||||
|
|
|
||||||
|
|
@ -165,11 +165,13 @@ export const REPORT_STATUS_COLORS: Record<ReportStatus, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isOpenStatus = (status: ReportStatus): boolean => {
|
export const isOpenStatus = (status: ReportStatus): boolean => {
|
||||||
return ([
|
return (
|
||||||
ReportStatus.NEW,
|
[
|
||||||
ReportStatus.TRIAGING,
|
ReportStatus.NEW,
|
||||||
ReportStatus.NEEDS_MORE_INFO,
|
ReportStatus.TRIAGING,
|
||||||
] as ReportStatus[]).includes(status)
|
ReportStatus.NEEDS_MORE_INFO,
|
||||||
|
] as ReportStatus[]
|
||||||
|
).includes(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isClosedStatus = (status: ReportStatus): boolean => {
|
export const isClosedStatus = (status: ReportStatus): boolean => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 82,
|
||||||
|
"lineEnding": "lf"
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"semicolons": "asNeeded",
|
||||||
|
"trailingCommas": "es5",
|
||||||
|
"arrowParentheses": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"complexity": {
|
||||||
|
"noExcessiveCognitiveComplexity": {
|
||||||
|
"level": "error",
|
||||||
|
"options": { "maxAllowedComplexity": 25 }
|
||||||
|
},
|
||||||
|
"noForEach": "off",
|
||||||
|
"useLiteralKeys": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "error",
|
||||||
|
"noUnusedImports": "error",
|
||||||
|
"useExhaustiveDependencies": "warn",
|
||||||
|
"useHookAtTopLevel": "error",
|
||||||
|
"noUndeclaredVariables": "error"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"useImportType": "error",
|
||||||
|
"useConst": "error",
|
||||||
|
"useTemplate": "error",
|
||||||
|
"useSelfClosingElements": "error",
|
||||||
|
"useFragmentSyntax": "error",
|
||||||
|
"noNonNullAssertion": "error",
|
||||||
|
"useConsistentArrayType": {
|
||||||
|
"level": "error",
|
||||||
|
"options": { "syntax": "shorthand" }
|
||||||
|
},
|
||||||
|
"useNamingConvention": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "error",
|
||||||
|
"noDebugger": "error",
|
||||||
|
"noConsole": "warn",
|
||||||
|
"noArrayIndexKey": "warn",
|
||||||
|
"noAssignInExpressions": "error",
|
||||||
|
"noDoubleEquals": "error",
|
||||||
|
"noRedeclare": "error",
|
||||||
|
"noVar": "error"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"noDangerouslySetInnerHtml": "error"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"useAltText": "error",
|
||||||
|
"useAnchorContent": "error",
|
||||||
|
"useKeyWithClickEvents": "error",
|
||||||
|
"noStaticElementInteractions": "error",
|
||||||
|
"useButtonType": "error",
|
||||||
|
"useValidAnchor": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["src/main.tsx"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["src/components/UI/Tooltip.tsx"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"a11y": {
|
||||||
|
"noStaticElementInteractions": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -2,22 +2,16 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// eslint.config.js
|
// eslint.config.js
|
||||||
// ===================
|
// ===================
|
||||||
import js from "@eslint/js";
|
import js from '@eslint/js'
|
||||||
import tseslint from "typescript-eslint";
|
import prettierConfig from 'eslint-config-prettier'
|
||||||
import solid from "eslint-plugin-solid/configs/typescript";
|
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||||
import jsxA11y from "eslint-plugin-jsx-a11y";
|
import solid from 'eslint-plugin-solid/configs/typescript'
|
||||||
import prettierConfig from "eslint-config-prettier";
|
import globals from 'globals'
|
||||||
import globals from "globals";
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: ['dist', 'node_modules', '*.config.js', '*.config.ts', '*.min.js'],
|
||||||
"dist",
|
|
||||||
"node_modules",
|
|
||||||
"*.config.js",
|
|
||||||
"*.config.ts",
|
|
||||||
"*.min.js",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
|
|
@ -26,16 +20,16 @@ export default tseslint.config(
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
{
|
{
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ['**/*.{ts,tsx}'],
|
||||||
...solid,
|
...solid,
|
||||||
plugins: {
|
plugins: {
|
||||||
...solid.plugins,
|
...solid.plugins,
|
||||||
"jsx-a11y": jsxA11y,
|
'jsx-a11y': jsxA11y,
|
||||||
},
|
},
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tseslint.parser,
|
parser: tseslint.parser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ["./tsconfig.json"],
|
project: ['./tsconfig.json'],
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
ecmaFeatures: { jsx: true },
|
ecmaFeatures: { jsx: true },
|
||||||
},
|
},
|
||||||
|
|
@ -47,23 +41,23 @@ export default tseslint.config(
|
||||||
rules: {
|
rules: {
|
||||||
...solid.rules,
|
...solid.rules,
|
||||||
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
argsIgnorePattern: "^_",
|
argsIgnorePattern: '^_',
|
||||||
varsIgnorePattern: "^_",
|
varsIgnorePattern: '^_',
|
||||||
caughtErrorsIgnorePattern: "^_",
|
caughtErrorsIgnorePattern: '^_',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"@typescript-eslint/consistent-type-imports": [
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
prefer: "type-imports",
|
prefer: 'type-imports',
|
||||||
fixStyle: "inline-type-imports",
|
fixStyle: 'inline-type-imports',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"@typescript-eslint/explicit-function-return-type": [
|
'@typescript-eslint/explicit-function-return-type': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
allowExpressions: true,
|
allowExpressions: true,
|
||||||
allowTypedFunctionExpressions: true,
|
allowTypedFunctionExpressions: true,
|
||||||
|
|
@ -71,15 +65,15 @@ export default tseslint.config(
|
||||||
allowDirectConstAssertionInArrowFunctions: true,
|
allowDirectConstAssertionInArrowFunctions: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"@typescript-eslint/naming-convention": "off",
|
'@typescript-eslint/naming-convention': 'off',
|
||||||
"@typescript-eslint/no-non-null-assertion": "error",
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
"@typescript-eslint/array-type": ["error", { default: "array" }],
|
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||||
"@typescript-eslint/no-explicit-any": "error",
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
'@typescript-eslint/no-confusing-void-expression': 'off',
|
||||||
"@typescript-eslint/no-unnecessary-condition": "off",
|
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
"@typescript-eslint/strict-boolean-expressions": [
|
'@typescript-eslint/strict-boolean-expressions': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
allowString: false,
|
allowString: false,
|
||||||
allowNumber: false,
|
allowNumber: false,
|
||||||
|
|
@ -88,11 +82,11 @@ export default tseslint.config(
|
||||||
allowAny: true,
|
allowAny: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"@typescript-eslint/prefer-as-const": "error",
|
'@typescript-eslint/prefer-as-const': 'error',
|
||||||
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
|
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||||
"@typescript-eslint/restrict-template-expressions": "off",
|
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||||
"@typescript-eslint/no-misused-promises": [
|
'@typescript-eslint/no-misused-promises': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
checksVoidReturn: {
|
checksVoidReturn: {
|
||||||
attributes: false,
|
attributes: false,
|
||||||
|
|
@ -100,57 +94,57 @@ export default tseslint.config(
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
"solid/reactivity": "warn",
|
'solid/reactivity': 'warn',
|
||||||
"solid/no-destructure": "warn",
|
'solid/no-destructure': 'warn',
|
||||||
"solid/jsx-no-undef": "error",
|
'solid/jsx-no-undef': 'error',
|
||||||
"solid/no-react-specific-props": "error",
|
'solid/no-react-specific-props': 'error',
|
||||||
"solid/prefer-for": "warn",
|
'solid/prefer-for': 'warn',
|
||||||
"solid/self-closing-comp": "error",
|
'solid/self-closing-comp': 'error',
|
||||||
"solid/style-prop": "error",
|
'solid/style-prop': 'error',
|
||||||
"solid/no-innerhtml": "error",
|
'solid/no-innerhtml': 'error',
|
||||||
"solid/no-unknown-namespaces": "error",
|
'solid/no-unknown-namespaces': 'error',
|
||||||
"solid/event-handlers": [
|
'solid/event-handlers': [
|
||||||
"error",
|
'error',
|
||||||
{
|
{
|
||||||
ignoreCase: false,
|
ignoreCase: false,
|
||||||
warnOnSpread: true,
|
warnOnSpread: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"solid/imports": "error",
|
'solid/imports': 'error',
|
||||||
"solid/no-proxy-apis": "off",
|
'solid/no-proxy-apis': 'off',
|
||||||
|
|
||||||
"jsx-a11y/alt-text": "error",
|
'jsx-a11y/alt-text': 'error',
|
||||||
"jsx-a11y/anchor-has-content": "error",
|
'jsx-a11y/anchor-has-content': 'error',
|
||||||
"jsx-a11y/click-events-have-key-events": "error",
|
'jsx-a11y/click-events-have-key-events': 'error',
|
||||||
"jsx-a11y/no-static-element-interactions": "error",
|
'jsx-a11y/no-static-element-interactions': 'error',
|
||||||
"jsx-a11y/no-noninteractive-element-interactions": "warn",
|
'jsx-a11y/no-noninteractive-element-interactions': 'warn',
|
||||||
"jsx-a11y/aria-props": "error",
|
'jsx-a11y/aria-props': 'error',
|
||||||
"jsx-a11y/aria-role": "error",
|
'jsx-a11y/aria-role': 'error',
|
||||||
"jsx-a11y/role-has-required-aria-props": "error",
|
'jsx-a11y/role-has-required-aria-props': 'error',
|
||||||
|
|
||||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
"no-debugger": "error",
|
'no-debugger': 'error',
|
||||||
"no-alert": "error",
|
'no-alert': 'error',
|
||||||
"no-var": "error",
|
'no-var': 'error',
|
||||||
"prefer-const": "error",
|
'prefer-const': 'error',
|
||||||
"prefer-template": "error",
|
'prefer-template': 'error',
|
||||||
"object-shorthand": "error",
|
'object-shorthand': 'error',
|
||||||
"no-nested-ternary": "error",
|
'no-nested-ternary': 'error',
|
||||||
"max-depth": ["error", 6],
|
'max-depth': ['error', 6],
|
||||||
"max-lines": [
|
'max-lines': [
|
||||||
"error",
|
'error',
|
||||||
{ max: 2000, skipBlankLines: true, skipComments: true },
|
{ max: 2000, skipBlankLines: true, skipComments: true },
|
||||||
],
|
],
|
||||||
complexity: ["error", 55],
|
complexity: ['error', 55],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
files: ["src/index.tsx"],
|
files: ['src/index.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-non-null-assertion": "off",
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
prettierConfig
|
prettierConfig
|
||||||
);
|
)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,36 +7,38 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --write .",
|
||||||
"lint:eslint": "eslint .",
|
"lint:eslint": "eslint .",
|
||||||
"lint:eslint:fix": "eslint . --fix",
|
"lint:eslint:fix": "eslint . --fix",
|
||||||
"lint:types": "tsc --noEmit",
|
"format": "biome format --write .",
|
||||||
"lint": "npm run lint:eslint && npm run lint:types",
|
"format:prettier": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nanostores/persistent": "^1.2.0",
|
"@nanostores/persistent": "^1.3.3",
|
||||||
"@nanostores/solid": "^1.1.1",
|
"@nanostores/solid": "^1.1.1",
|
||||||
"@solidjs/router": "^0.15.3",
|
"@solidjs/router": "^0.15.4",
|
||||||
"@tanstack/solid-query": "^5.90.22",
|
"@tanstack/solid-query": "^5.90.23",
|
||||||
"nanostores": "^1.1.0",
|
"nanostores": "^1.1.0",
|
||||||
"solid-js": "^1.9.10"
|
"solid-js": "^1.9.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.2",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.2.3",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-solid": "^0.14.5",
|
"eslint-plugin-solid": "^0.14.5",
|
||||||
"globals": "^17.1.0",
|
"globals": "^17.3.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.2.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.56.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-solid": "^2.11.10"
|
"vite-plugin-solid": "^2.11.10"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,14 +2,14 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// App.tsx
|
// App.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Route } from "@solidjs/router";
|
import { Route } from '@solidjs/router'
|
||||||
import { type Component, lazy } from "solid-js";
|
import { type Component, lazy } from 'solid-js'
|
||||||
|
|
||||||
const Home = lazy(() => import("./pages/Home"));
|
const Home = lazy(() => import('./pages/Home'))
|
||||||
const Register = lazy(() => import("./pages/Register"));
|
const Register = lazy(() => import('./pages/Register'))
|
||||||
const Login = lazy(() => import("./pages/Login"));
|
const Login = lazy(() => import('./pages/Login'))
|
||||||
const Chat = lazy(() => import("./pages/Chat"));
|
const Chat = lazy(() => import('./pages/Chat'))
|
||||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
const NotFound = lazy(() => import('./pages/NotFound'))
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -20,7 +20,7 @@ const App: Component = () => {
|
||||||
<Route path="/chat" component={Chat} />
|
<Route path="/chat" component={Chat} />
|
||||||
<Route path="*" component={NotFound} />
|
<Route path="*" component={NotFound} />
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
* 8-bit styled auth card container
|
* 8-bit styled auth card container
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ParentProps, JSX } from "solid-js"
|
import type { JSX, ParentProps } from 'solid-js'
|
||||||
import { Show } from "solid-js"
|
import { Show } from 'solid-js'
|
||||||
|
|
||||||
interface AuthCardProps extends ParentProps {
|
interface AuthCardProps extends ParentProps {
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -39,9 +39,7 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex justify-center">
|
<div class="mt-4 flex justify-center">
|
||||||
<span class="font-pixel text-[8px] text-gray">
|
<span class="font-pixel text-[8px] text-gray">END-TO-END ENCRYPTED</span>
|
||||||
END-TO-END ENCRYPTED
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -49,13 +47,61 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
|
||||||
|
|
||||||
function LockIcon(): JSX.Element {
|
function LockIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<svg
|
||||||
<rect x="7" y="4" width="10" height="2" fill="currentColor" class="text-orange" />
|
width="24"
|
||||||
<rect x="5" y="6" width="2" height="5" fill="currentColor" class="text-orange" />
|
height="24"
|
||||||
<rect x="17" y="6" width="2" height="5" fill="currentColor" class="text-orange" />
|
viewBox="0 0 24 24"
|
||||||
<rect x="4" y="11" width="16" height="2" fill="currentColor" class="text-orange" />
|
fill="none"
|
||||||
<rect x="4" y="13" width="16" height="8" fill="currentColor" class="text-orange" />
|
aria-hidden="true"
|
||||||
<rect x="11" y="15" width="2" height="4" fill="currentColor" class="text-black" />
|
>
|
||||||
|
<rect
|
||||||
|
x="7"
|
||||||
|
y="4"
|
||||||
|
width="10"
|
||||||
|
height="2"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="5"
|
||||||
|
y="6"
|
||||||
|
width="2"
|
||||||
|
height="5"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="17"
|
||||||
|
y="6"
|
||||||
|
width="2"
|
||||||
|
height="5"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="11"
|
||||||
|
width="16"
|
||||||
|
height="2"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="13"
|
||||||
|
width="16"
|
||||||
|
height="8"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="11"
|
||||||
|
y="15"
|
||||||
|
width="2"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-black"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,41 +3,40 @@
|
||||||
// AuthForm.tsx
|
// AuthForm.tsx
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
import { createSignal, Show } from "solid-js"
|
import { A } from '@solidjs/router'
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import { A } from "@solidjs/router"
|
import { createSignal, Show } from 'solid-js'
|
||||||
import { Input } from "../UI/Input"
|
import { validateDisplayName, validateUsername } from '../../lib/validators'
|
||||||
import { PasskeyButton } from "./PasskeyButton"
|
import { authService } from '../../services'
|
||||||
import { AuthCard } from "./AuthCard"
|
import { showToast } from '../../stores'
|
||||||
import {
|
import { ApiError } from '../../types'
|
||||||
validateUsername,
|
import { Input } from '../UI/Input'
|
||||||
validateDisplayName,
|
import { AuthCard } from './AuthCard'
|
||||||
} from "../../lib/validators"
|
import { PasskeyButton } from './PasskeyButton'
|
||||||
import { authService } from "../../services"
|
|
||||||
import { showToast } from "../../stores"
|
|
||||||
import { ApiError } from "../../types"
|
|
||||||
|
|
||||||
type AuthMode = "login" | "register"
|
type AuthMode = 'login' | 'register'
|
||||||
|
|
||||||
interface AuthFormProps {
|
interface AuthFormProps {
|
||||||
mode: AuthMode
|
mode: AuthMode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthForm(props: AuthFormProps): JSX.Element {
|
export function AuthForm(props: AuthFormProps): JSX.Element {
|
||||||
const [username, setUsername] = createSignal("")
|
const [username, setUsername] = createSignal('')
|
||||||
const [displayName, setDisplayName] = createSignal("")
|
const [displayName, setDisplayName] = createSignal('')
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [usernameError, setUsernameError] = createSignal<string | undefined>()
|
const [usernameError, setUsernameError] = createSignal<string | undefined>()
|
||||||
const [displayNameError, setDisplayNameError] = createSignal<string | undefined>()
|
const [displayNameError, setDisplayNameError] = createSignal<
|
||||||
|
string | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
const isRegister = (): boolean => props.mode === "register"
|
const isRegister = (): boolean => props.mode === 'register'
|
||||||
|
|
||||||
const title = (): string => isRegister() ? "CREATE ACCOUNT" : "WELCOME BACK"
|
const title = (): string => (isRegister() ? 'CREATE ACCOUNT' : 'WELCOME BACK')
|
||||||
|
|
||||||
const subtitle = (): string =>
|
const subtitle = (): string =>
|
||||||
isRegister()
|
isRegister()
|
||||||
? "REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION"
|
? 'REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION'
|
||||||
: "SIGN IN WITH YOUR PASSKEY TO CONTINUE"
|
: 'SIGN IN WITH YOUR PASSKEY TO CONTINUE'
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
let valid = true
|
let valid = true
|
||||||
|
|
@ -73,29 +72,37 @@ export function AuthForm(props: AuthFormProps): JSX.Element {
|
||||||
try {
|
try {
|
||||||
if (isRegister()) {
|
if (isRegister()) {
|
||||||
await authService.register(username(), displayName())
|
await authService.register(username(), displayName())
|
||||||
showToast("success", "REGISTRATION COMPLETE", "YOUR PASSKEY HAS BEEN CREATED")
|
showToast(
|
||||||
|
'success',
|
||||||
|
'REGISTRATION COMPLETE',
|
||||||
|
'YOUR PASSKEY HAS BEEN CREATED'
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const trimmedUsername = username().trim()
|
const trimmedUsername = username().trim()
|
||||||
const usernameValue = trimmedUsername === "" ? undefined : trimmedUsername
|
const usernameValue = trimmedUsername === '' ? undefined : trimmedUsername
|
||||||
await authService.login(usernameValue)
|
await authService.login(usernameValue)
|
||||||
showToast("success", "LOGIN SUCCESSFUL", "WELCOME BACK")
|
showToast('success', 'LOGIN SUCCESSFUL', 'WELCOME BACK')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = "AN UNEXPECTED ERROR OCCURRED"
|
let message = 'AN UNEXPECTED ERROR OCCURRED'
|
||||||
|
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
message = error.message.toUpperCase()
|
message = error.message.toUpperCase()
|
||||||
} else if (error instanceof Error) {
|
} else if (error instanceof Error) {
|
||||||
if (error.name === "NotAllowedError") {
|
if (error.name === 'NotAllowedError') {
|
||||||
message = "PASSKEY OPERATION CANCELLED"
|
message = 'PASSKEY OPERATION CANCELLED'
|
||||||
} else if (error.name === "InvalidStateError") {
|
} else if (error.name === 'InvalidStateError') {
|
||||||
message = "PASSKEY ALREADY REGISTERED"
|
message = 'PASSKEY ALREADY REGISTERED'
|
||||||
} else {
|
} else {
|
||||||
message = error.message.toUpperCase()
|
message = error.message.toUpperCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast("error", isRegister() ? "REGISTRATION FAILED" : "LOGIN FAILED", message)
|
showToast(
|
||||||
|
'error',
|
||||||
|
isRegister() ? 'REGISTRATION FAILED' : 'LOGIN FAILED',
|
||||||
|
message
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -158,14 +165,14 @@ export function AuthForm(props: AuthFormProps): JSX.Element {
|
||||||
when={isRegister()}
|
when={isRegister()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
DON'T HAVE AN ACCOUNT?{" "}
|
DON'T HAVE AN ACCOUNT?{' '}
|
||||||
<A href="/register" class="text-orange hover:underline">
|
<A href="/register" class="text-orange hover:underline">
|
||||||
REGISTER
|
REGISTER
|
||||||
</A>
|
</A>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
ALREADY HAVE AN ACCOUNT?{" "}
|
ALREADY HAVE AN ACCOUNT?{' '}
|
||||||
<A href="/login" class="text-orange hover:underline">
|
<A href="/login" class="text-orange hover:underline">
|
||||||
SIGN IN
|
SIGN IN
|
||||||
</A>
|
</A>
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,16 @@
|
||||||
// PasskeyButton.tsx
|
// PasskeyButton.tsx
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
import type { JSX } from 'solid-js'
|
||||||
|
import { createSignal, onMount, Show } from 'solid-js'
|
||||||
import {
|
import {
|
||||||
Show,
|
|
||||||
createSignal,
|
|
||||||
onMount,
|
|
||||||
} from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
|
||||||
import { Button } from "../UI/Button"
|
|
||||||
import {
|
|
||||||
isWebAuthnSupported,
|
|
||||||
isPlatformAuthenticatorAvailable,
|
isPlatformAuthenticatorAvailable,
|
||||||
} from "../../services"
|
isWebAuthnSupported,
|
||||||
|
} from '../../services'
|
||||||
|
import { Button } from '../UI/Button'
|
||||||
|
|
||||||
interface PasskeyButtonProps {
|
interface PasskeyButtonProps {
|
||||||
mode: "register" | "login"
|
mode: 'register' | 'login'
|
||||||
onClick: () => void | Promise<void>
|
onClick: () => void | Promise<void>
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
|
@ -40,9 +36,11 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
|
||||||
|
|
||||||
const buttonText = (): string => {
|
const buttonText = (): string => {
|
||||||
if (!webAuthnSupported()) {
|
if (!webAuthnSupported()) {
|
||||||
return "WEBAUTHN NOT SUPPORTED"
|
return 'WEBAUTHN NOT SUPPORTED'
|
||||||
}
|
}
|
||||||
return props.mode === "register" ? "REGISTER WITH PASSKEY" : "LOGIN WITH PASSKEY"
|
return props.mode === 'register'
|
||||||
|
? 'REGISTER WITH PASSKEY'
|
||||||
|
: 'LOGIN WITH PASSKEY'
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDisabled = (): boolean => {
|
const isDisabled = (): boolean => {
|
||||||
|
|
@ -50,7 +48,7 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col gap-2 ${props.class ?? ""}`}>
|
<div class={`flex flex-col gap-2 ${props.class ?? ''}`}>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -80,7 +78,13 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
|
||||||
|
|
||||||
function PasskeyIcon(): JSX.Element {
|
function PasskeyIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="2" y="6" width="2" height="6" />
|
<rect x="2" y="6" width="2" height="6" />
|
||||||
<rect x="4" y="4" width="2" height="2" />
|
<rect x="4" y="4" width="2" height="2" />
|
||||||
<rect x="6" y="2" width="4" height="2" />
|
<rect x="6" y="2" width="4" height="2" />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export { PasskeyButton } from "./PasskeyButton"
|
|
||||||
export { AuthCard } from "./AuthCard"
|
export { AuthCard } from './AuthCard'
|
||||||
export { AuthForm } from "./AuthForm"
|
export { AuthForm } from './AuthForm'
|
||||||
|
export { PasskeyButton } from './PasskeyButton'
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// ChatHeader.tsx
|
// ChatHeader.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { Room, Participant } from "../../types"
|
import { Show } from 'solid-js'
|
||||||
import { OnlineStatus } from "./OnlineStatus"
|
import { getUserStatus } from '../../stores'
|
||||||
import { EncryptionBadge } from "./EncryptionBadge"
|
import type { Participant, Room } from '../../types'
|
||||||
import { getUserStatus } from "../../stores"
|
import { IconButton } from '../UI/IconButton'
|
||||||
import { IconButton } from "../UI/IconButton"
|
import { EncryptionBadge } from './EncryptionBadge'
|
||||||
|
import { OnlineStatus } from './OnlineStatus'
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
room: Room | null
|
room: Room | null
|
||||||
|
|
@ -19,16 +20,16 @@ interface ChatHeaderProps {
|
||||||
|
|
||||||
export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
||||||
const otherParticipant = (): Participant | null => {
|
const otherParticipant = (): Participant | null => {
|
||||||
if (props.room?.type !== "direct") return null
|
if (props.room?.type !== 'direct') return null
|
||||||
return props.room.participants[0] ?? null
|
return props.room.participants[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = (): string => {
|
const displayName = (): string => {
|
||||||
const room = props.room
|
const room = props.room
|
||||||
if (room === null) return "CHAT"
|
if (room === null) return 'CHAT'
|
||||||
if (room.name !== undefined && room.name !== null) return room.name
|
if (room.name !== undefined && room.name !== null) return room.name
|
||||||
const other = otherParticipant()
|
const other = otherParticipant()
|
||||||
return other?.display_name ?? other?.username ?? "CHAT"
|
return other?.display_name ?? other?.username ?? 'CHAT'
|
||||||
}
|
}
|
||||||
|
|
||||||
const initials = (): string => {
|
const initials = (): string => {
|
||||||
|
|
@ -37,14 +38,14 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex-shrink-0 px-4 py-3 border-b-2 border-orange ${props.class ?? ""}`}>
|
<div
|
||||||
|
class={`flex-shrink-0 px-4 py-3 border-b-2 border-orange ${props.class ?? ''}`}
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="w-10 h-10 bg-black border-2 border-orange flex items-center justify-center">
|
<div class="w-10 h-10 bg-black border-2 border-orange flex items-center justify-center">
|
||||||
<span class="font-pixel text-[10px] text-orange">
|
<span class="font-pixel text-[10px] text-orange">{initials()}</span>
|
||||||
{initials()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={otherParticipant()} keyed>
|
<Show when={otherParticipant()} keyed>
|
||||||
{(participant) => (
|
{(participant) => (
|
||||||
|
|
@ -59,14 +60,12 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-pixel text-xs text-white">
|
<h2 class="font-pixel text-xs text-white">{displayName()}</h2>
|
||||||
{displayName()}
|
|
||||||
</h2>
|
|
||||||
<div class="flex items-center gap-2 mt-0.5">
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
<Show when={props.room?.is_encrypted}>
|
<Show when={props.room?.is_encrypted}>
|
||||||
<EncryptionBadge isEncrypted showLabel size="sm" />
|
<EncryptionBadge isEncrypted showLabel size="sm" />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.room?.type === "group"}>
|
<Show when={props.room?.type === 'group'}>
|
||||||
<span class="font-pixel text-[8px] text-gray">
|
<span class="font-pixel text-[8px] text-gray">
|
||||||
{props.room?.participants.length ?? 0} MEMBERS
|
{props.room?.participants.length ?? 0} MEMBERS
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -107,7 +106,13 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
|
||||||
|
|
||||||
function InfoIcon(): JSX.Element {
|
function InfoIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="7" y="3" width="2" height="2" />
|
<rect x="7" y="3" width="2" height="2" />
|
||||||
<rect x="7" y="7" width="2" height="6" />
|
<rect x="7" y="7" width="2" height="6" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -116,7 +121,13 @@ function InfoIcon(): JSX.Element {
|
||||||
|
|
||||||
function SettingsIcon(): JSX.Element {
|
function SettingsIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="7" y="1" width="2" height="3" />
|
<rect x="7" y="1" width="2" height="3" />
|
||||||
<rect x="7" y="12" width="2" height="3" />
|
<rect x="7" y="12" width="2" height="3" />
|
||||||
<rect x="1" y="7" width="3" height="2" />
|
<rect x="1" y="7" width="3" height="2" />
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// ChatInput.tsx
|
// ChatInput.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { createSignal, Show, onCleanup } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import { Button } from "../UI/Button"
|
import { createSignal, onCleanup, Show } from 'solid-js'
|
||||||
import { wsManager } from "../../websocket"
|
import { MESSAGE_MAX_LENGTH } from '../../types'
|
||||||
import { MESSAGE_MAX_LENGTH } from "../../types"
|
import { wsManager } from '../../websocket'
|
||||||
|
import { Button } from '../UI/Button'
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
roomId: string
|
roomId: string
|
||||||
|
|
@ -17,14 +18,15 @@ interface ChatInputProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput(props: ChatInputProps): JSX.Element {
|
export function ChatInput(props: ChatInputProps): JSX.Element {
|
||||||
const [message, setMessage] = createSignal("")
|
const [message, setMessage] = createSignal('')
|
||||||
const [isTyping, setIsTyping] = createSignal(false)
|
const [isTyping, setIsTyping] = createSignal(false)
|
||||||
let typingTimeout: ReturnType<typeof setTimeout> | undefined
|
let typingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
let inputRef: HTMLInputElement | undefined
|
let inputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
const charCount = (): number => message().length
|
const charCount = (): number => message().length
|
||||||
const isOverLimit = (): boolean => charCount() > MESSAGE_MAX_LENGTH
|
const isOverLimit = (): boolean => charCount() > MESSAGE_MAX_LENGTH
|
||||||
const canSend = (): boolean => message().trim().length > 0 && !isOverLimit() && (props.disabled !== true)
|
const canSend = (): boolean =>
|
||||||
|
message().trim().length > 0 && !isOverLimit() && props.disabled !== true
|
||||||
|
|
||||||
const handleInput = (e: Event): void => {
|
const handleInput = (e: Event): void => {
|
||||||
const target = e.target as HTMLInputElement
|
const target = e.target as HTMLInputElement
|
||||||
|
|
@ -50,7 +52,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
||||||
|
|
||||||
const content = message().trim()
|
const content = message().trim()
|
||||||
props.onSend?.(content)
|
props.onSend?.(content)
|
||||||
setMessage("")
|
setMessage('')
|
||||||
|
|
||||||
if (isTyping()) {
|
if (isTyping()) {
|
||||||
setIsTyping(false)
|
setIsTyping(false)
|
||||||
|
|
@ -61,7 +63,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSend()
|
handleSend()
|
||||||
}
|
}
|
||||||
|
|
@ -77,10 +79,14 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex-shrink-0 p-4 border-t-2 border-orange ${props.class ?? ""}`}>
|
<div
|
||||||
|
class={`flex-shrink-0 p-4 border-t-2 border-orange ${props.class ?? ''}`}
|
||||||
|
>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="flex-1 flex items-center bg-black border-2 border-orange">
|
<div class="flex-1 flex items-center bg-black border-2 border-orange">
|
||||||
<span class="font-pixel text-[10px] text-orange px-2">>>></span>
|
<span class="font-pixel text-[10px] text-orange px-2">
|
||||||
|
>>>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -106,11 +112,11 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-2">
|
<div class="flex items-center justify-between mt-2">
|
||||||
<Show when={isOverLimit()}>
|
<Show when={isOverLimit()}>
|
||||||
<span class="font-pixel text-[8px] text-error">
|
<span class="font-pixel text-[8px] text-error">MESSAGE TOO LONG</span>
|
||||||
MESSAGE TOO LONG
|
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
<span class={`font-pixel text-[8px] ml-auto ${isOverLimit() ? "text-error" : "text-gray"}`}>
|
<span
|
||||||
|
class={`font-pixel text-[8px] ml-auto ${isOverLimit() ? 'text-error' : 'text-gray'}`}
|
||||||
|
>
|
||||||
{charCount()}/{MESSAGE_MAX_LENGTH}
|
{charCount()}/{MESSAGE_MAX_LENGTH}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,7 +126,13 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
|
||||||
|
|
||||||
function SendIcon(): JSX.Element {
|
function SendIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="0" y="5" width="8" height="2" />
|
<rect x="0" y="5" width="8" height="2" />
|
||||||
<rect x="6" y="3" width="2" height="2" />
|
<rect x="6" y="3" width="2" height="2" />
|
||||||
<rect x="6" y="7" width="2" height="2" />
|
<rect x="6" y="7" width="2" height="2" />
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// ConversationItem.tsx
|
// ConversationItem.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { Room, Participant } from "../../types"
|
import { Show } from 'solid-js'
|
||||||
import { OnlineStatus } from "./OnlineStatus"
|
import { formatRelativeTime } from '../../lib/date'
|
||||||
import { EncryptionBadge } from "./EncryptionBadge"
|
import { getUserStatus } from '../../stores'
|
||||||
import { getUserStatus } from "../../stores"
|
import type { Participant, Room } from '../../types'
|
||||||
import { formatRelativeTime } from "../../lib/date"
|
import { EncryptionBadge } from './EncryptionBadge'
|
||||||
|
import { OnlineStatus } from './OnlineStatus'
|
||||||
|
|
||||||
interface ConversationItemProps {
|
interface ConversationItemProps {
|
||||||
room: Room
|
room: Room
|
||||||
|
|
@ -19,14 +20,14 @@ interface ConversationItemProps {
|
||||||
|
|
||||||
export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
||||||
const otherParticipant = (): Participant | null => {
|
const otherParticipant = (): Participant | null => {
|
||||||
if (props.room.type !== "direct") return null
|
if (props.room.type !== 'direct') return null
|
||||||
return props.room.participants[0] ?? null
|
return props.room.participants[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = (): string => {
|
const displayName = (): string => {
|
||||||
if (props.room.name) return props.room.name
|
if (props.room.name) return props.room.name
|
||||||
const other = otherParticipant()
|
const other = otherParticipant()
|
||||||
return other?.display_name ?? other?.username ?? "CHAT"
|
return other?.display_name ?? other?.username ?? 'CHAT'
|
||||||
}
|
}
|
||||||
|
|
||||||
const initials = (): string => {
|
const initials = (): string => {
|
||||||
|
|
@ -36,20 +37,20 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
||||||
|
|
||||||
const lastMessagePreview = (): string => {
|
const lastMessagePreview = (): string => {
|
||||||
const msg = props.room.last_message
|
const msg = props.room.last_message
|
||||||
if (msg === null || msg === undefined) return "NO MESSAGES"
|
if (msg === null || msg === undefined) return 'NO MESSAGES'
|
||||||
if (msg.is_encrypted) return "ENCRYPTED MESSAGE"
|
if (msg.is_encrypted) return 'ENCRYPTED MESSAGE'
|
||||||
const content = msg.content.slice(0, 40)
|
const content = msg.content.slice(0, 40)
|
||||||
return content.length < msg.content.length ? `${content}...` : content
|
return content.length < msg.content.length ? `${content}...` : content
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastMessageTime = (): string => {
|
const lastMessageTime = (): string => {
|
||||||
const msg = props.room.last_message
|
const msg = props.room.last_message
|
||||||
if (msg === null || msg === undefined) return ""
|
if (msg === null || msg === undefined) return ''
|
||||||
return formatRelativeTime(msg.created_at)
|
return formatRelativeTime(msg.created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerClasses = (): string => {
|
const containerClasses = (): string => {
|
||||||
const base = "w-full p-3 border-2 transition-colors cursor-pointer"
|
const base = 'w-full p-3 border-2 transition-colors cursor-pointer'
|
||||||
if (props.isActive) {
|
if (props.isActive) {
|
||||||
return `${base} bg-orange text-black border-orange`
|
return `${base} bg-orange text-black border-orange`
|
||||||
}
|
}
|
||||||
|
|
@ -59,13 +60,17 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`${containerClasses()} ${props.class ?? ""}`}
|
class={`${containerClasses()} ${props.class ?? ''}`}
|
||||||
onClick={() => props.onClick()}
|
onClick={() => props.onClick()}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="relative flex-shrink-0">
|
<div class="relative flex-shrink-0">
|
||||||
<div class={`w-10 h-10 border-2 flex items-center justify-center ${props.isActive ? "border-black" : "border-orange"}`}>
|
<div
|
||||||
<span class={`font-pixel text-[10px] ${props.isActive ? "text-black" : "text-orange"}`}>
|
class={`w-10 h-10 border-2 flex items-center justify-center ${props.isActive ? 'border-black' : 'border-orange'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={`font-pixel text-[10px] ${props.isActive ? 'text-black' : 'text-orange'}`}
|
||||||
|
>
|
||||||
{initials()}
|
{initials()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -83,16 +88,22 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
||||||
|
|
||||||
<div class="flex-1 min-w-0 text-left">
|
<div class="flex-1 min-w-0 text-left">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class={`font-pixel text-[10px] truncate ${props.isActive ? "text-black" : "text-white"}`}>
|
<span
|
||||||
|
class={`font-pixel text-[10px] truncate ${props.isActive ? 'text-black' : 'text-white'}`}
|
||||||
|
>
|
||||||
{displayName()}
|
{displayName()}
|
||||||
</span>
|
</span>
|
||||||
<span class={`font-pixel text-[8px] flex-shrink-0 ${props.isActive ? "text-black/60" : "text-gray"}`}>
|
<span
|
||||||
|
class={`font-pixel text-[8px] flex-shrink-0 ${props.isActive ? 'text-black/60' : 'text-gray'}`}
|
||||||
|
>
|
||||||
{lastMessageTime()}
|
{lastMessageTime()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2 mt-1">
|
<div class="flex items-center justify-between gap-2 mt-1">
|
||||||
<span class={`font-pixel text-[8px] truncate ${props.isActive ? "text-black/80" : "text-gray"}`}>
|
<span
|
||||||
|
class={`font-pixel text-[8px] truncate ${props.isActive ? 'text-black/80' : 'text-gray'}`}
|
||||||
|
>
|
||||||
{lastMessagePreview()}
|
{lastMessagePreview()}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-1 flex-shrink-0">
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
|
@ -102,7 +113,9 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
|
||||||
<Show when={props.room.unread_count > 0}>
|
<Show when={props.room.unread_count > 0}>
|
||||||
<div class="min-w-[18px] h-[18px] bg-orange flex items-center justify-center">
|
<div class="min-w-[18px] h-[18px] bg-orange flex items-center justify-center">
|
||||||
<span class="font-pixel text-[8px] text-black">
|
<span class="font-pixel text-[8px] text-black">
|
||||||
{props.room.unread_count > 99 ? "99+" : props.room.unread_count}
|
{props.room.unread_count > 99
|
||||||
|
? '99+'
|
||||||
|
: props.room.unread_count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// ConversationList.tsx
|
// ConversationList.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show, For, createMemo } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import { useStore } from "@nanostores/solid"
|
import type { JSX } from 'solid-js'
|
||||||
import { $rooms, $activeRoomId, setActiveRoom } from "../../stores"
|
import { createMemo, For, Show } from 'solid-js'
|
||||||
import { ConversationItem } from "./ConversationItem"
|
import { $activeRoomId, $rooms, setActiveRoom } from '../../stores'
|
||||||
import { Spinner } from "../UI/Spinner"
|
import type { Room } from '../../types'
|
||||||
import type { Room } from "../../types"
|
import { Spinner } from '../UI/Spinner'
|
||||||
|
import { ConversationItem } from './ConversationItem'
|
||||||
|
|
||||||
interface ConversationListProps {
|
interface ConversationListProps {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
|
@ -35,11 +36,9 @@ export function ConversationList(props: ConversationListProps): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col h-full ${props.class ?? ""}`}>
|
<div class={`flex flex-col h-full ${props.class ?? ''}`}>
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b-2 border-dark-gray">
|
<div class="flex items-center justify-between px-4 py-3 border-b-2 border-dark-gray">
|
||||||
<h2 class="font-pixel text-[10px] text-orange">
|
<h2 class="font-pixel text-[10px] text-orange">CONVERSATIONS</h2>
|
||||||
CONVERSATIONS
|
|
||||||
</h2>
|
|
||||||
<Show when={props.onNewChat}>
|
<Show when={props.onNewChat}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -112,7 +111,13 @@ function EmptyConversations(props: EmptyConversationsProps): JSX.Element {
|
||||||
|
|
||||||
function PlusIcon(): JSX.Element {
|
function PlusIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="5" y="1" width="2" height="10" />
|
<rect x="5" y="1" width="2" height="10" />
|
||||||
<rect x="1" y="5" width="10" height="2" />
|
<rect x="1" y="5" width="10" height="2" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -121,7 +126,14 @@ function PlusIcon(): JSX.Element {
|
||||||
|
|
||||||
function ChatIcon(): JSX.Element {
|
function ChatIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="currentColor" class="text-dark-gray">
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-dark-gray"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="6" y="6" width="28" height="3" />
|
<rect x="6" y="6" width="28" height="3" />
|
||||||
<rect x="3" y="9" width="3" height="20" />
|
<rect x="3" y="9" width="3" height="20" />
|
||||||
<rect x="34" y="9" width="3" height="20" />
|
<rect x="34" y="9" width="3" height="20" />
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,31 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// EncryptionBadge.tsx
|
// EncryptionBadge.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
|
import { Show } from 'solid-js'
|
||||||
|
|
||||||
interface EncryptionBadgeProps {
|
interface EncryptionBadgeProps {
|
||||||
isEncrypted: boolean
|
isEncrypted: boolean
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
size?: "sm" | "md"
|
size?: 'sm' | 'md'
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EncryptionBadge(props: EncryptionBadgeProps): JSX.Element {
|
export function EncryptionBadge(props: EncryptionBadgeProps): JSX.Element {
|
||||||
const iconSize = (): string => props.size === "sm" ? "w-3 h-3" : "w-4 h-4"
|
const iconSize = (): string => (props.size === 'sm' ? 'w-3 h-3' : 'w-4 h-4')
|
||||||
const textSize = (): string => props.size === "sm" ? "text-[6px]" : "text-[8px]"
|
const textSize = (): string =>
|
||||||
|
props.size === 'sm' ? 'text-[6px]' : 'text-[8px]'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.isEncrypted}>
|
<Show when={props.isEncrypted}>
|
||||||
<div
|
<div
|
||||||
class={`flex items-center gap-1 ${props.class ?? ""}`}
|
class={`flex items-center gap-1 ${props.class ?? ''}`}
|
||||||
title="END-TO-END ENCRYPTED"
|
title="END-TO-END ENCRYPTED"
|
||||||
>
|
>
|
||||||
<LockIcon class={`${iconSize()} text-success`} />
|
<LockIcon class={`${iconSize()} text-success`} />
|
||||||
<Show when={props.showLabel}>
|
<Show when={props.showLabel}>
|
||||||
<span class={`font-pixel ${textSize()} text-success`}>
|
<span class={`font-pixel ${textSize()} text-success`}>E2E</span>
|
||||||
E2E
|
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -50,31 +50,37 @@ function LockIcon(props: LockIconProps): JSX.Element {
|
||||||
<rect x="11" y="4" width="1" height="4" />
|
<rect x="11" y="4" width="1" height="4" />
|
||||||
<rect x="3" y="8" width="10" height="1" />
|
<rect x="3" y="8" width="10" height="1" />
|
||||||
<rect x="3" y="9" width="10" height="5" />
|
<rect x="3" y="9" width="10" height="5" />
|
||||||
<rect x="7" y="11" width="2" height="2" fill="currentColor" class="text-black" />
|
<rect
|
||||||
|
x="7"
|
||||||
|
y="11"
|
||||||
|
width="2"
|
||||||
|
height="2"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-black"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnencryptedBadgeProps {
|
interface UnencryptedBadgeProps {
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
size?: "sm" | "md"
|
size?: 'sm' | 'md'
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnencryptedBadge(props: UnencryptedBadgeProps): JSX.Element {
|
export function UnencryptedBadge(props: UnencryptedBadgeProps): JSX.Element {
|
||||||
const iconSize = (): string => props.size === "sm" ? "w-3 h-3" : "w-4 h-4"
|
const iconSize = (): string => (props.size === 'sm' ? 'w-3 h-3' : 'w-4 h-4')
|
||||||
const textSize = (): string => props.size === "sm" ? "text-[6px]" : "text-[8px]"
|
const textSize = (): string =>
|
||||||
|
props.size === 'sm' ? 'text-[6px]' : 'text-[8px]'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`flex items-center gap-1 ${props.class ?? ""}`}
|
class={`flex items-center gap-1 ${props.class ?? ''}`}
|
||||||
title="NOT ENCRYPTED"
|
title="NOT ENCRYPTED"
|
||||||
>
|
>
|
||||||
<UnlockIcon class={`${iconSize()} text-gray`} />
|
<UnlockIcon class={`${iconSize()} text-gray`} />
|
||||||
<Show when={props.showLabel}>
|
<Show when={props.showLabel}>
|
||||||
<span class={`font-pixel ${textSize()} text-gray`}>
|
<span class={`font-pixel ${textSize()} text-gray`}>UNENCRYPTED</span>
|
||||||
UNENCRYPTED
|
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -93,7 +99,14 @@ function UnlockIcon(props: LockIconProps): JSX.Element {
|
||||||
<rect x="11" y="2" width="1" height="2" />
|
<rect x="11" y="2" width="1" height="2" />
|
||||||
<rect x="3" y="8" width="10" height="1" />
|
<rect x="3" y="8" width="10" height="1" />
|
||||||
<rect x="3" y="9" width="10" height="5" />
|
<rect x="3" y="9" width="10" height="5" />
|
||||||
<rect x="7" y="11" width="2" height="2" fill="currentColor" class="text-black" />
|
<rect
|
||||||
|
x="7"
|
||||||
|
y="11"
|
||||||
|
width="2"
|
||||||
|
height="2"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-black"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// MessageBubble.tsx
|
// MessageBubble.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show, Switch, Match } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { Message, MessageStatus } from "../../types"
|
import { Match, Show, Switch } from 'solid-js'
|
||||||
import { EncryptionBadge } from "./EncryptionBadge"
|
import { formatTime } from '../../lib/date'
|
||||||
import { formatTime } from "../../lib/date"
|
import type { Message, MessageStatus } from '../../types'
|
||||||
|
import { EncryptionBadge } from './EncryptionBadge'
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: Message
|
message: Message
|
||||||
|
|
@ -17,7 +18,7 @@ interface MessageBubbleProps {
|
||||||
|
|
||||||
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
||||||
const bubbleClasses = (): string => {
|
const bubbleClasses = (): string => {
|
||||||
const base = "max-w-[70%] p-4"
|
const base = 'max-w-[70%] p-4'
|
||||||
if (props.isOwnMessage) {
|
if (props.isOwnMessage) {
|
||||||
return `${base} bg-orange text-black`
|
return `${base} bg-orange text-black`
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +26,9 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex px-4 ${props.isOwnMessage ? "justify-end" : "justify-start"} ${props.class ?? ""}`}>
|
<div
|
||||||
|
class={`flex px-4 ${props.isOwnMessage ? 'justify-end' : 'justify-start'} ${props.class ?? ''}`}
|
||||||
|
>
|
||||||
<div class={bubbleClasses()}>
|
<div class={bubbleClasses()}>
|
||||||
<Show when={props.showSender === true && !props.isOwnMessage}>
|
<Show when={props.showSender === true && !props.isOwnMessage}>
|
||||||
<div class="font-pixel text-[10px] text-orange mb-2">
|
<div class="font-pixel text-[10px] text-orange mb-2">
|
||||||
|
|
@ -33,7 +36,10 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="font-placeholder break-words whitespace-pre-wrap" style="font-size: 32px; line-height: 1.4;">
|
<div
|
||||||
|
class="font-placeholder break-words whitespace-pre-wrap"
|
||||||
|
style="font-size: 32px; line-height: 1.4;"
|
||||||
|
>
|
||||||
{props.message.content}
|
{props.message.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -41,7 +47,9 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
||||||
<Show when={props.message.is_encrypted}>
|
<Show when={props.message.is_encrypted}>
|
||||||
<EncryptionBadge isEncrypted size="sm" />
|
<EncryptionBadge isEncrypted size="sm" />
|
||||||
</Show>
|
</Show>
|
||||||
<span class={`font-pixel text-[8px] ${props.isOwnMessage ? "text-black/60" : "text-gray"}`}>
|
<span
|
||||||
|
class={`font-pixel text-[8px] ${props.isOwnMessage ? 'text-black/60' : 'text-gray'}`}
|
||||||
|
>
|
||||||
{formatTime(props.message.created_at)}
|
{formatTime(props.message.created_at)}
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.isOwnMessage}>
|
<Show when={props.isOwnMessage}>
|
||||||
|
|
@ -59,20 +67,20 @@ interface MessageStatusIconProps {
|
||||||
|
|
||||||
function MessageStatusIcon(props: MessageStatusIconProps): JSX.Element {
|
function MessageStatusIcon(props: MessageStatusIconProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Switch fallback={<></>}>
|
<Switch fallback={null}>
|
||||||
<Match when={props.status === "sending"}>
|
<Match when={props.status === 'sending'}>
|
||||||
<ClockIcon class="w-3 h-3 text-black/40" />
|
<ClockIcon class="w-3 h-3 text-black/40" />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.status === "sent"}>
|
<Match when={props.status === 'sent'}>
|
||||||
<CheckIcon class="w-3 h-3 text-black/60" />
|
<CheckIcon class="w-3 h-3 text-black/60" />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.status === "delivered"}>
|
<Match when={props.status === 'delivered'}>
|
||||||
<DoubleCheckIcon class="w-3 h-3 text-black/60" />
|
<DoubleCheckIcon class="w-3 h-3 text-black/60" />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.status === "read"}>
|
<Match when={props.status === 'read'}>
|
||||||
<DoubleCheckIcon class="w-3 h-3 text-success" />
|
<DoubleCheckIcon class="w-3 h-3 text-success" />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.status === "failed"}>
|
<Match when={props.status === 'failed'}>
|
||||||
<ErrorIcon class="w-3 h-3 text-error" />
|
<ErrorIcon class="w-3 h-3 text-error" />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
@ -85,7 +93,12 @@ interface IconProps {
|
||||||
|
|
||||||
function ClockIcon(props: IconProps): JSX.Element {
|
function ClockIcon(props: IconProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
|
<svg
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="currentColor"
|
||||||
|
class={props.class}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="5" y="1" width="2" height="1" />
|
<rect x="5" y="1" width="2" height="1" />
|
||||||
<rect x="3" y="2" width="2" height="1" />
|
<rect x="3" y="2" width="2" height="1" />
|
||||||
<rect x="7" y="2" width="2" height="1" />
|
<rect x="7" y="2" width="2" height="1" />
|
||||||
|
|
@ -106,7 +119,12 @@ function ClockIcon(props: IconProps): JSX.Element {
|
||||||
|
|
||||||
function CheckIcon(props: IconProps): JSX.Element {
|
function CheckIcon(props: IconProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
|
<svg
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="currentColor"
|
||||||
|
class={props.class}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="2" y="6" width="2" height="2" />
|
<rect x="2" y="6" width="2" height="2" />
|
||||||
<rect x="4" y="8" width="2" height="2" />
|
<rect x="4" y="8" width="2" height="2" />
|
||||||
<rect x="6" y="6" width="2" height="2" />
|
<rect x="6" y="6" width="2" height="2" />
|
||||||
|
|
@ -118,7 +136,12 @@ function CheckIcon(props: IconProps): JSX.Element {
|
||||||
|
|
||||||
function DoubleCheckIcon(props: IconProps): JSX.Element {
|
function DoubleCheckIcon(props: IconProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 12" fill="currentColor" class={props.class}>
|
<svg
|
||||||
|
viewBox="0 0 16 12"
|
||||||
|
fill="currentColor"
|
||||||
|
class={props.class}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="0" y="6" width="2" height="2" />
|
<rect x="0" y="6" width="2" height="2" />
|
||||||
<rect x="2" y="8" width="2" height="2" />
|
<rect x="2" y="8" width="2" height="2" />
|
||||||
<rect x="4" y="6" width="2" height="2" />
|
<rect x="4" y="6" width="2" height="2" />
|
||||||
|
|
@ -135,7 +158,12 @@ function DoubleCheckIcon(props: IconProps): JSX.Element {
|
||||||
|
|
||||||
function ErrorIcon(props: IconProps): JSX.Element {
|
function ErrorIcon(props: IconProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 12 12" fill="currentColor" class={props.class}>
|
<svg
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="currentColor"
|
||||||
|
class={props.class}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="5" y="1" width="2" height="1" />
|
<rect x="5" y="1" width="2" height="1" />
|
||||||
<rect x="3" y="2" width="2" height="1" />
|
<rect x="3" y="2" width="2" height="1" />
|
||||||
<rect x="7" y="2" width="2" height="1" />
|
<rect x="7" y="2" width="2" height="1" />
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,19 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// MessageList.tsx
|
// MessageList.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show, For, createEffect, createSignal, onMount } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import { useStore } from "@nanostores/solid"
|
import type { JSX } from 'solid-js'
|
||||||
import { $activeRoomMessages, $activeRoomPendingMessages } from "../../stores"
|
import { createEffect, createSignal, For, onMount, Show } from 'solid-js'
|
||||||
import { $userId } from "../../stores"
|
import {
|
||||||
import { MessageBubble } from "./MessageBubble"
|
$activeRoomMessages,
|
||||||
import { TypingIndicator } from "./TypingIndicator"
|
$activeRoomPendingMessages,
|
||||||
import { Spinner } from "../UI/Spinner"
|
$userId,
|
||||||
import type { Message } from "../../types"
|
} from '../../stores'
|
||||||
|
import type { Message } from '../../types'
|
||||||
|
import { Spinner } from '../UI/Spinner'
|
||||||
|
import { MessageBubble } from './MessageBubble'
|
||||||
|
import { TypingIndicator } from './TypingIndicator'
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
roomId: string
|
roomId: string
|
||||||
|
|
@ -39,7 +43,12 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
||||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
|
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
|
||||||
setAutoScroll(isAtBottom)
|
setAutoScroll(isAtBottom)
|
||||||
|
|
||||||
if (scrollTop < 100 && props.hasMore === true && props.loading !== true && props.onLoadMore !== undefined) {
|
if (
|
||||||
|
scrollTop < 100 &&
|
||||||
|
props.hasMore === true &&
|
||||||
|
props.loading !== true &&
|
||||||
|
props.onLoadMore !== undefined
|
||||||
|
) {
|
||||||
props.onLoadMore()
|
props.onLoadMore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,10 +70,10 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
||||||
const groups = new Map<string, Message[]>()
|
const groups = new Map<string, Message[]>()
|
||||||
|
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
const date = new Date(msg.created_at).toLocaleDateString("en-US", {
|
const date = new Date(msg.created_at).toLocaleDateString('en-US', {
|
||||||
weekday: "short",
|
weekday: 'short',
|
||||||
month: "short",
|
month: 'short',
|
||||||
day: "numeric",
|
day: 'numeric',
|
||||||
})
|
})
|
||||||
|
|
||||||
const existing = groups.get(date) ?? []
|
const existing = groups.get(date) ?? []
|
||||||
|
|
@ -77,7 +86,7 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
class={`flex-1 overflow-y-auto scrollbar-pixel p-4 ${props.class ?? ""}`}
|
class={`flex-1 overflow-y-auto scrollbar-pixel p-4 ${props.class ?? ''}`}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<Show when={props.loading}>
|
<Show when={props.loading}>
|
||||||
|
|
@ -86,10 +95,7 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show
|
<Show when={allMessages().length > 0} fallback={<EmptyMessages />}>
|
||||||
when={allMessages().length > 0}
|
|
||||||
fallback={<EmptyMessages />}
|
|
||||||
>
|
|
||||||
<For each={Array.from(groupMessagesByDate(allMessages()).entries())}>
|
<For each={Array.from(groupMessagesByDate(allMessages()).entries())}>
|
||||||
{([date, dateMessages]) => (
|
{([date, dateMessages]) => (
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
|
|
@ -101,7 +107,9 @@ export function MessageList(props: MessageListProps): JSX.Element {
|
||||||
index() > 0 ? dateMessages[index() - 1] : undefined
|
index() > 0 ? dateMessages[index() - 1] : undefined
|
||||||
const showSender = (): boolean => {
|
const showSender = (): boolean => {
|
||||||
const prev = prevMessage()
|
const prev = prevMessage()
|
||||||
return prev === undefined || prev.sender_id !== message.sender_id
|
return (
|
||||||
|
prev === undefined || prev.sender_id !== message.sender_id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -130,12 +138,8 @@ function EmptyMessages(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div class="h-full flex flex-col items-center justify-center py-12">
|
<div class="h-full flex flex-col items-center justify-center py-12">
|
||||||
<MessageIcon />
|
<MessageIcon />
|
||||||
<p class="font-pixel text-[10px] text-gray mt-4">
|
<p class="font-pixel text-[10px] text-gray mt-4">NO MESSAGES YET</p>
|
||||||
NO MESSAGES YET
|
<p class="font-pixel text-[8px] text-gray mt-1">START THE CONVERSATION</p>
|
||||||
</p>
|
|
||||||
<p class="font-pixel text-[8px] text-gray mt-1">
|
|
||||||
START THE CONVERSATION
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -148,9 +152,7 @@ function DateSeparator(props: DateSeparatorProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-4 my-4">
|
<div class="flex items-center gap-4 my-4">
|
||||||
<div class="flex-1 h-px bg-dark-gray" />
|
<div class="flex-1 h-px bg-dark-gray" />
|
||||||
<span class="font-pixel text-[8px] text-gray uppercase">
|
<span class="font-pixel text-[8px] text-gray uppercase">{props.date}</span>
|
||||||
{props.date}
|
|
||||||
</span>
|
|
||||||
<div class="flex-1 h-px bg-dark-gray" />
|
<div class="flex-1 h-px bg-dark-gray" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -158,7 +160,14 @@ function DateSeparator(props: DateSeparatorProps): JSX.Element {
|
||||||
|
|
||||||
function MessageIcon(): JSX.Element {
|
function MessageIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="currentColor" class="text-dark-gray">
|
<svg
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-dark-gray"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="8" y="8" width="32" height="4" />
|
<rect x="8" y="8" width="32" height="4" />
|
||||||
<rect x="4" y="12" width="4" height="24" />
|
<rect x="4" y="12" width="4" height="24" />
|
||||||
<rect x="40" y="12" width="4" height="24" />
|
<rect x="40" y="12" width="4" height="24" />
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// NewConversation.tsx
|
// NewConversation.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { createSignal, Show } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import { Modal } from "../UI/Modal"
|
import { createSignal, Show } from 'solid-js'
|
||||||
import { Button } from "../UI/Button"
|
import { showToast } from '../../stores'
|
||||||
import { UserSearch } from "./UserSearch"
|
import type { User } from '../../types'
|
||||||
import type { User } from "../../types"
|
import { Button } from '../UI/Button'
|
||||||
import { showToast } from "../../stores"
|
import { Modal } from '../UI/Modal'
|
||||||
|
import { UserSearch } from './UserSearch'
|
||||||
|
|
||||||
interface NewConversationProps {
|
interface NewConversationProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
|
|
@ -32,10 +33,14 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await props.onCreateRoom(user.id)
|
await props.onCreateRoom(user.id)
|
||||||
showToast("success", "CHAT CREATED", `STARTED CONVERSATION WITH ${user.display_name.toUpperCase()}`)
|
showToast(
|
||||||
|
'success',
|
||||||
|
'CHAT CREATED',
|
||||||
|
`STARTED CONVERSATION WITH ${user.display_name.toUpperCase()}`
|
||||||
|
)
|
||||||
handleClose()
|
handleClose()
|
||||||
} catch (_error: unknown) {
|
} catch (_error: unknown) {
|
||||||
showToast("error", "FAILED TO CREATE CHAT", "PLEASE TRY AGAIN")
|
showToast('error', 'FAILED TO CREATE CHAT', 'PLEASE TRY AGAIN')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -56,9 +61,9 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="font-pixel text-[10px] text-gray block mb-2">
|
<span class="font-pixel text-[10px] text-gray block mb-2">
|
||||||
SEARCH FOR A USER
|
SEARCH FOR A USER
|
||||||
</label>
|
</span>
|
||||||
<UserSearch
|
<UserSearch
|
||||||
onSelect={handleUserSelect}
|
onSelect={handleUserSelect}
|
||||||
placeholder="ENTER USERNAME..."
|
placeholder="ENTER USERNAME..."
|
||||||
|
|
@ -97,12 +102,7 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 pt-4 border-t-2 border-dark-gray">
|
<div class="flex items-center gap-3 pt-4 border-t-2 border-dark-gray">
|
||||||
<Button
|
<Button variant="secondary" size="md" onClick={handleClose} fullWidth>
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
onClick={handleClose}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
CANCEL
|
CANCEL
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -123,7 +123,13 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
|
||||||
|
|
||||||
function CloseIcon(): JSX.Element {
|
function CloseIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="1" y="2" width="2" height="2" />
|
<rect x="1" y="2" width="2" height="2" />
|
||||||
<rect x="2" y="3" width="2" height="2" />
|
<rect x="2" y="3" width="2" height="2" />
|
||||||
<rect x="3" y="4" width="2" height="2" />
|
<rect x="3" y="4" width="2" height="2" />
|
||||||
|
|
|
||||||
|
|
@ -2,57 +2,57 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// OnlineStatus.tsx
|
// OnlineStatus.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { PresenceStatus } from "../../types"
|
import { Show } from 'solid-js'
|
||||||
|
import type { PresenceStatus } from '../../types'
|
||||||
|
|
||||||
interface OnlineStatusProps {
|
interface OnlineStatusProps {
|
||||||
status: PresenceStatus
|
status: PresenceStatus
|
||||||
size?: "sm" | "md" | "lg"
|
size?: 'sm' | 'md' | 'lg'
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE_MAP = {
|
const SIZE_MAP = {
|
||||||
sm: "w-2 h-2",
|
sm: 'w-2 h-2',
|
||||||
md: "w-3 h-3",
|
md: 'w-3 h-3',
|
||||||
lg: "w-4 h-4",
|
lg: 'w-4 h-4',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnlineStatus(props: OnlineStatusProps): JSX.Element {
|
export function OnlineStatus(props: OnlineStatusProps): JSX.Element {
|
||||||
const sizeClass = (): string => SIZE_MAP[props.size ?? "md"]
|
const sizeClass = (): string => SIZE_MAP[props.size ?? 'md']
|
||||||
|
|
||||||
const statusColor = (): string => {
|
const statusColor = (): string => {
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
case "online":
|
case 'online':
|
||||||
return "bg-success"
|
return 'bg-success'
|
||||||
case "away":
|
case 'away':
|
||||||
return "bg-away"
|
return 'bg-away'
|
||||||
case "offline":
|
case 'offline':
|
||||||
return "bg-offline"
|
return 'bg-offline'
|
||||||
default:
|
default:
|
||||||
return "bg-gray"
|
return 'bg-gray'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabel = (): string => {
|
const statusLabel = (): string => {
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
case "online":
|
case 'online':
|
||||||
return "ONLINE"
|
return 'ONLINE'
|
||||||
case "away":
|
case 'away':
|
||||||
return "AWAY"
|
return 'AWAY'
|
||||||
case "offline":
|
case 'offline':
|
||||||
return "OFFLINE"
|
return 'OFFLINE'
|
||||||
default:
|
default:
|
||||||
return "UNKNOWN"
|
return 'UNKNOWN'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
|
<div class={`flex items-center gap-2 ${props.class ?? ''}`}>
|
||||||
<div
|
<output
|
||||||
class={`${sizeClass()} ${statusColor()}`}
|
class={`${sizeClass()} ${statusColor()} block`}
|
||||||
role="status"
|
|
||||||
aria-label={statusLabel()}
|
aria-label={statusLabel()}
|
||||||
/>
|
/>
|
||||||
<Show when={props.showLabel === true}>
|
<Show when={props.showLabel === true}>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// TypingIndicator.tsx
|
// TypingIndicator.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show, For } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import { useStore } from "@nanostores/solid"
|
import type { JSX } from 'solid-js'
|
||||||
import { $activeRoomTypingUsernames } from "../../stores"
|
import { For, Show } from 'solid-js'
|
||||||
|
import { $activeRoomTypingUsernames } from '../../stores'
|
||||||
|
|
||||||
interface TypingIndicatorProps {
|
interface TypingIndicatorProps {
|
||||||
class?: string
|
class?: string
|
||||||
|
|
@ -16,7 +17,7 @@ export function TypingIndicator(props: TypingIndicatorProps): JSX.Element {
|
||||||
|
|
||||||
const typingText = (): string => {
|
const typingText = (): string => {
|
||||||
const users = typingUsernames()
|
const users = typingUsernames()
|
||||||
if (users.length === 0) return ""
|
if (users.length === 0) return ''
|
||||||
if (users.length === 1) return `${users[0]} IS TYPING`
|
if (users.length === 1) return `${users[0]} IS TYPING`
|
||||||
if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
|
if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
|
||||||
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
|
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
|
||||||
|
|
@ -24,11 +25,9 @@ export function TypingIndicator(props: TypingIndicatorProps): JSX.Element {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={typingUsernames().length > 0}>
|
<Show when={typingUsernames().length > 0}>
|
||||||
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
|
<div class={`flex items-center gap-2 ${props.class ?? ''}`}>
|
||||||
<TypingDots />
|
<TypingDots />
|
||||||
<span class="font-pixel text-[8px] text-gray">
|
<span class="font-pixel text-[8px] text-gray">{typingText()}</span>
|
||||||
{typingText()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
@ -41,7 +40,7 @@ function TypingDots(): JSX.Element {
|
||||||
{(index) => (
|
{(index) => (
|
||||||
<div
|
<div
|
||||||
class="w-1 h-1 bg-orange animate-bounce-pixel"
|
class="w-1 h-1 bg-orange animate-bounce-pixel"
|
||||||
style={{ "animation-delay": `${index * 150}ms` }}
|
style={{ 'animation-delay': `${index * 150}ms` }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -54,10 +53,12 @@ interface TypingIndicatorInlineProps {
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.Element {
|
export function TypingIndicatorInline(
|
||||||
|
props: TypingIndicatorInlineProps
|
||||||
|
): JSX.Element {
|
||||||
const typingText = (): string => {
|
const typingText = (): string => {
|
||||||
const users = props.usernames
|
const users = props.usernames
|
||||||
if (users.length === 0) return ""
|
if (users.length === 0) return ''
|
||||||
if (users.length === 1) return `${users[0]} IS TYPING`
|
if (users.length === 1) return `${users[0]} IS TYPING`
|
||||||
if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
|
if (users.length === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
|
||||||
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
|
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
|
||||||
|
|
@ -65,11 +66,9 @@ export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.El
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.usernames.length > 0}>
|
<Show when={props.usernames.length > 0}>
|
||||||
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
|
<div class={`flex items-center gap-2 ${props.class ?? ''}`}>
|
||||||
<TypingDots />
|
<TypingDots />
|
||||||
<span class="font-pixel text-[8px] text-gray">
|
<span class="font-pixel text-[8px] text-gray">{typingText()}</span>
|
||||||
{typingText()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// UserSearch.tsx
|
// UserSearch.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { createSignal, Show, For, onCleanup } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import { Input } from "../UI/Input"
|
import { createSignal, For, onCleanup, Show } from 'solid-js'
|
||||||
import { Spinner } from "../UI/Spinner"
|
import { USER_SEARCH_DEFAULT_LIMIT, USER_SEARCH_MIN_LENGTH } from '../../config'
|
||||||
import { api } from "../../lib/api-client"
|
import { api } from '../../lib/api-client'
|
||||||
import { USER_SEARCH_MIN_LENGTH, USER_SEARCH_DEFAULT_LIMIT } from "../../config"
|
import type { User } from '../../types'
|
||||||
import type { User } from "../../types"
|
import { Input } from '../UI/Input'
|
||||||
|
import { Spinner } from '../UI/Spinner'
|
||||||
|
|
||||||
interface UserSearchProps {
|
interface UserSearchProps {
|
||||||
onSelect: (user: User) => void
|
onSelect: (user: User) => void
|
||||||
|
|
@ -24,7 +25,7 @@ interface SearchResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserSearch(props: UserSearchProps): JSX.Element {
|
export function UserSearch(props: UserSearchProps): JSX.Element {
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal('')
|
||||||
const [result, setResult] = createSignal<SearchResult>({
|
const [result, setResult] = createSignal<SearchResult>({
|
||||||
users: [],
|
users: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
@ -61,14 +62,15 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
||||||
})
|
})
|
||||||
setResult({ users: response.users, loading: false, error: null })
|
setResult({ users: response.users, loading: false, error: null })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Search failed"
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : 'Search failed'
|
||||||
setResult({ users: [], loading: false, error: errorMessage })
|
setResult({ users: [], loading: false, error: errorMessage })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelect = (user: User): void => {
|
const handleSelect = (user: User): void => {
|
||||||
props.onSelect(user)
|
props.onSelect(user)
|
||||||
setQuery("")
|
setQuery('')
|
||||||
setResult({ users: [], loading: false, error: null })
|
setResult({ users: [], loading: false, error: null })
|
||||||
setIsFocused(false)
|
setIsFocused(false)
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +81,10 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
const showResults = (): boolean => {
|
const showResults = (): boolean => {
|
||||||
return isFocused() && (result().loading || filteredUsers().length > 0 || result().error !== null)
|
return (
|
||||||
|
isFocused() &&
|
||||||
|
(result().loading || filteredUsers().length > 0 || result().error !== null)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|
@ -89,10 +94,10 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`relative ${props.class ?? ""}`}>
|
<div class={`relative ${props.class ?? ''}`}>
|
||||||
<Input
|
<Input
|
||||||
name="user-search"
|
name="user-search"
|
||||||
placeholder={props.placeholder ?? "SEARCH USERS..."}
|
placeholder={props.placeholder ?? 'SEARCH USERS...'}
|
||||||
value={query()}
|
value={query()}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
|
|
@ -143,11 +148,16 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!result().loading && !result().error && filteredUsers().length === 0 && query().length >= USER_SEARCH_MIN_LENGTH}>
|
<Show
|
||||||
|
when={
|
||||||
|
!result().loading &&
|
||||||
|
!result().error &&
|
||||||
|
filteredUsers().length === 0 &&
|
||||||
|
query().length >= USER_SEARCH_MIN_LENGTH
|
||||||
|
}
|
||||||
|
>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<span class="font-pixel text-[10px] text-gray">
|
<span class="font-pixel text-[10px] text-gray">NO USERS FOUND</span>
|
||||||
NO USERS FOUND
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -158,7 +168,14 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
|
||||||
|
|
||||||
function SearchIcon(): JSX.Element {
|
function SearchIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" class="text-gray">
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-gray"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="4" y="1" width="4" height="1" />
|
<rect x="4" y="1" width="4" height="1" />
|
||||||
<rect x="2" y="2" width="2" height="1" />
|
<rect x="2" y="2" width="2" height="1" />
|
||||||
<rect x="8" y="2" width="2" height="1" />
|
<rect x="8" y="2" width="2" height="1" />
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export * from "./ConversationList"
|
|
||||||
export * from "./ConversationItem"
|
export * from './ChatHeader'
|
||||||
export * from "./MessageList"
|
export * from './ChatInput'
|
||||||
export * from "./MessageBubble"
|
export * from './ConversationItem'
|
||||||
export * from "./ChatInput"
|
export * from './ConversationList'
|
||||||
export * from "./ChatHeader"
|
export * from './EncryptionBadge'
|
||||||
export * from "./TypingIndicator"
|
export * from './MessageBubble'
|
||||||
export * from "./OnlineStatus"
|
export * from './MessageList'
|
||||||
export * from "./EncryptionBadge"
|
export * from './NewConversation'
|
||||||
export * from "./UserSearch"
|
export * from './OnlineStatus'
|
||||||
export * from "./NewConversation"
|
export * from './TypingIndicator'
|
||||||
|
export * from './UserSearch'
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
* Main application shell layout
|
* Main application shell layout
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ParentProps, JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import { Show } from "solid-js"
|
import type { JSX, ParentProps } from 'solid-js'
|
||||||
import { useStore } from "@nanostores/solid"
|
import { Show } from 'solid-js'
|
||||||
import { $sidebarOpen, $isMobile } from "../../stores"
|
import { $isMobile, $sidebarOpen } from '../../stores'
|
||||||
import { Sidebar } from "./Sidebar"
|
import { Header } from './Header'
|
||||||
import { Header } from "./Header"
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
interface AppShellProps extends ParentProps {
|
interface AppShellProps extends ParentProps {
|
||||||
showSidebar?: boolean
|
showSidebar?: boolean
|
||||||
|
|
@ -34,15 +34,19 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<main class="flex-1 overflow-hidden bg-black">
|
<main class="flex-1 overflow-hidden bg-black">{props.children}</main>
|
||||||
{props.children}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isMobile() && sidebarOpen()}>
|
<Show when={isMobile() && sidebarOpen()}>
|
||||||
<div
|
<button
|
||||||
class="fixed inset-0 z-30 bg-black/80"
|
type="button"
|
||||||
|
class="fixed inset-0 z-30 bg-black/80 w-full h-full border-0 cursor-default appearance-none"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Close sidebar"
|
||||||
onClick={() => $sidebarOpen.set(false)}
|
onClick={() => $sidebarOpen.set(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') $sidebarOpen.set(false)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@
|
||||||
* 8-bit styled header/navbar component
|
* 8-bit styled header/navbar component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import { Show } from "solid-js"
|
import { A, useLocation } from '@solidjs/router'
|
||||||
import { A, useLocation } from "@solidjs/router"
|
import type { JSX } from 'solid-js'
|
||||||
import { useStore } from "@nanostores/solid"
|
import { Show } from 'solid-js'
|
||||||
import {
|
import {
|
||||||
|
$currentUser,
|
||||||
$isAuthenticated,
|
$isAuthenticated,
|
||||||
$sidebarOpen,
|
$sidebarOpen,
|
||||||
$currentUser,
|
|
||||||
toggleSidebar,
|
|
||||||
openModal,
|
openModal,
|
||||||
} from "../../stores"
|
toggleSidebar,
|
||||||
import { IconButton } from "../UI/IconButton"
|
} from '../../stores'
|
||||||
import { Badge } from "../UI/Badge"
|
import { Avatar } from '../UI/Avatar'
|
||||||
import { Avatar } from "../UI/Avatar"
|
import { Badge } from '../UI/Badge'
|
||||||
|
import { IconButton } from '../UI/IconButton'
|
||||||
|
|
||||||
export function Header(): JSX.Element {
|
export function Header(): JSX.Element {
|
||||||
const isAuthenticated = useStore($isAuthenticated)
|
const isAuthenticated = useStore($isAuthenticated)
|
||||||
|
|
@ -30,7 +30,7 @@ export function Header(): JSX.Element {
|
||||||
<Show when={isAuthenticated()}>
|
<Show when={isAuthenticated()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={sidebarOpen() ? <CloseMenuIcon /> : <MenuIcon />}
|
icon={sidebarOpen() ? <CloseMenuIcon /> : <MenuIcon />}
|
||||||
ariaLabel={sidebarOpen() ? "Close menu" : "Open menu"}
|
ariaLabel={sidebarOpen() ? 'Close menu' : 'Open menu'}
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|
@ -75,20 +75,26 @@ export function Header(): JSX.Element {
|
||||||
icon={<SearchIcon />}
|
icon={<SearchIcon />}
|
||||||
ariaLabel="Search"
|
ariaLabel="Search"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openModal("new-conversation")}
|
onClick={() => openModal('new-conversation')}
|
||||||
/>
|
/>
|
||||||
<Show when={currentUser()} keyed>
|
<Show when={currentUser()} keyed>
|
||||||
{(user) => (
|
{(user) => (
|
||||||
<A
|
<A
|
||||||
href="/settings"
|
href="/settings"
|
||||||
class={`flex items-center gap-2 px-2 py-1 border-2 ${
|
class={`flex items-center gap-2 px-2 py-1 border-2 ${
|
||||||
location.pathname === "/settings"
|
location.pathname === '/settings'
|
||||||
? "border-orange bg-orange text-black"
|
? 'border-orange bg-orange text-black'
|
||||||
: "border-transparent text-white hover:text-orange"
|
: 'border-transparent text-white hover:text-orange'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar alt={user.display_name} size="xs" fallback={user.display_name.slice(0, 2)} />
|
<Avatar
|
||||||
<span class="font-pixel text-[10px] hidden sm:block">{user.display_name}</span>
|
alt={user.display_name}
|
||||||
|
size="xs"
|
||||||
|
fallback={user.display_name.slice(0, 2)}
|
||||||
|
/>
|
||||||
|
<span class="font-pixel text-[10px] hidden sm:block">
|
||||||
|
{user.display_name}
|
||||||
|
</span>
|
||||||
</A>
|
</A>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -101,7 +107,13 @@ export function Header(): JSX.Element {
|
||||||
|
|
||||||
function MenuIcon(): JSX.Element {
|
function MenuIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="2" y="3" width="12" height="2" />
|
<rect x="2" y="3" width="12" height="2" />
|
||||||
<rect x="2" y="7" width="12" height="2" />
|
<rect x="2" y="7" width="12" height="2" />
|
||||||
<rect x="2" y="11" width="12" height="2" />
|
<rect x="2" y="11" width="12" height="2" />
|
||||||
|
|
@ -111,7 +123,13 @@ function MenuIcon(): JSX.Element {
|
||||||
|
|
||||||
function CloseMenuIcon(): JSX.Element {
|
function CloseMenuIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="3" y="3" width="2" height="2" />
|
<rect x="3" y="3" width="2" height="2" />
|
||||||
<rect x="5" y="5" width="2" height="2" />
|
<rect x="5" y="5" width="2" height="2" />
|
||||||
<rect x="7" y="7" width="2" height="2" />
|
<rect x="7" y="7" width="2" height="2" />
|
||||||
|
|
@ -127,20 +145,74 @@ function CloseMenuIcon(): JSX.Element {
|
||||||
|
|
||||||
function LockIcon(): JSX.Element {
|
function LockIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<svg
|
||||||
<rect x="6" y="3" width="8" height="2" fill="currentColor" class="text-orange" />
|
width="20"
|
||||||
<rect x="4" y="5" width="2" height="4" fill="currentColor" class="text-orange" />
|
height="20"
|
||||||
<rect x="14" y="5" width="2" height="4" fill="currentColor" class="text-orange" />
|
viewBox="0 0 20 20"
|
||||||
<rect x="3" y="9" width="14" height="2" fill="currentColor" class="text-orange" />
|
fill="none"
|
||||||
<rect x="3" y="11" width="14" height="6" fill="currentColor" class="text-orange" />
|
aria-hidden="true"
|
||||||
<rect x="9" y="12" width="2" height="4" fill="currentColor" class="text-black" />
|
>
|
||||||
|
<rect
|
||||||
|
x="6"
|
||||||
|
y="3"
|
||||||
|
width="8"
|
||||||
|
height="2"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="5"
|
||||||
|
width="2"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="14"
|
||||||
|
y="5"
|
||||||
|
width="2"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="9"
|
||||||
|
width="14"
|
||||||
|
height="2"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="11"
|
||||||
|
width="14"
|
||||||
|
height="6"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="9"
|
||||||
|
y="12"
|
||||||
|
width="2"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-black"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchIcon(): JSX.Element {
|
function SearchIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="4" y="2" width="6" height="2" />
|
<rect x="4" y="2" width="6" height="2" />
|
||||||
<rect x="2" y="4" width="2" height="6" />
|
<rect x="2" y="4" width="2" height="6" />
|
||||||
<rect x="10" y="4" width="2" height="6" />
|
<rect x="10" y="4" width="2" height="6" />
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
* Protected route wrapper that redirects unauthenticated users
|
* Protected route wrapper that redirects unauthenticated users
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ParentProps, JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import { Show, createEffect } from "solid-js"
|
import { useLocation, useNavigate } from '@solidjs/router'
|
||||||
import { useNavigate, useLocation } from "@solidjs/router"
|
import type { JSX, ParentProps } from 'solid-js'
|
||||||
import { useStore } from "@nanostores/solid"
|
import { createEffect, Show } from 'solid-js'
|
||||||
import { $isAuthenticated } from "../../stores"
|
import { $isAuthenticated } from '../../stores'
|
||||||
import { Spinner } from "../UI/Spinner"
|
import { Spinner } from '../UI/Spinner'
|
||||||
|
|
||||||
interface ProtectedRouteProps extends ParentProps {
|
interface ProtectedRouteProps extends ParentProps {
|
||||||
redirectTo?: string
|
redirectTo?: string
|
||||||
|
|
@ -18,14 +18,15 @@ export function ProtectedRoute(props: ProtectedRouteProps): JSX.Element {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isAuthenticated = useStore($isAuthenticated)
|
const isAuthenticated = useStore($isAuthenticated)
|
||||||
|
|
||||||
const redirectPath = (): string => props.redirectTo ?? "/login"
|
const redirectPath = (): string => props.redirectTo ?? '/login'
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!isAuthenticated()) {
|
if (!isAuthenticated()) {
|
||||||
const currentPath = location.pathname
|
const currentPath = location.pathname
|
||||||
const redirectUrl = currentPath !== "/"
|
const redirectUrl =
|
||||||
? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}`
|
currentPath !== '/'
|
||||||
: redirectPath()
|
? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}`
|
||||||
|
: redirectPath()
|
||||||
|
|
||||||
navigate(redirectUrl, { replace: true })
|
navigate(redirectUrl, { replace: true })
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +59,7 @@ export function GuestRoute(props: ParentProps): JSX.Element {
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isAuthenticated()) {
|
if (isAuthenticated()) {
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
const redirectPath = params.get("redirect") ?? "/chat"
|
const redirectPath = params.get('redirect') ?? '/chat'
|
||||||
navigate(redirectPath, { replace: true })
|
navigate(redirectPath, { replace: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -70,9 +71,7 @@ export function GuestRoute(props: ParentProps): JSX.Element {
|
||||||
<div class="h-full flex items-center justify-center bg-black">
|
<div class="h-full flex items-center justify-center bg-black">
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
<Spinner size="lg" />
|
<Spinner size="lg" />
|
||||||
<span class="font-pixel text-[10px] text-orange">
|
<span class="font-pixel text-[10px] text-orange">REDIRECTING...</span>
|
||||||
REDIRECTING...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,21 @@
|
||||||
* Sidebar - Room list only
|
* Sidebar - Room list only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import { Show, Index, createMemo } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { Participant } from "../../types"
|
import { createMemo, Index, Show } from 'solid-js'
|
||||||
import { useStore } from "@nanostores/solid"
|
|
||||||
import {
|
import {
|
||||||
|
$activeRoomId,
|
||||||
$currentUser,
|
$currentUser,
|
||||||
$rooms,
|
$rooms,
|
||||||
$activeRoomId,
|
|
||||||
$totalUnreadCount,
|
$totalUnreadCount,
|
||||||
setActiveRoom,
|
|
||||||
openModal,
|
openModal,
|
||||||
} from "../../stores"
|
setActiveRoom,
|
||||||
import { Avatar } from "../UI/Avatar"
|
} from '../../stores'
|
||||||
import { Badge } from "../UI/Badge"
|
import type { Participant } from '../../types'
|
||||||
import { IconButton } from "../UI/IconButton"
|
import { Avatar } from '../UI/Avatar'
|
||||||
|
import { Badge } from '../UI/Badge'
|
||||||
|
import { IconButton } from '../UI/IconButton'
|
||||||
|
|
||||||
export function Sidebar(): JSX.Element {
|
export function Sidebar(): JSX.Element {
|
||||||
const currentUser = useStore($currentUser)
|
const currentUser = useStore($currentUser)
|
||||||
|
|
@ -39,7 +39,9 @@ export function Sidebar(): JSX.Element {
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
|
<h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
|
||||||
<Show when={totalUnread() > 0}>
|
<Show when={totalUnread() > 0}>
|
||||||
<Badge variant="primary" size="xs">{totalUnread()}</Badge>
|
<Badge variant="primary" size="xs">
|
||||||
|
{totalUnread()}
|
||||||
|
</Badge>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,16 +53,22 @@ export function Sidebar(): JSX.Element {
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full justify-start gap-2 px-3"
|
class="w-full justify-start gap-2 px-3"
|
||||||
onClick={() => openModal("new-conversation")}
|
onClick={() => openModal('new-conversation')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-2">
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
<Index each={sortedRooms()}>
|
<Index each={sortedRooms()}>
|
||||||
{(room, idx) => {
|
{(room, _idx) => {
|
||||||
const isActive = createMemo(() => activeRoomId() === room().id)
|
const isActive = createMemo(() => activeRoomId() === room().id)
|
||||||
const other = createMemo(() => room().participants?.find((p: Participant) => p.user_id !== currentUser()?.id))
|
const other = createMemo(() =>
|
||||||
const displayName = createMemo(() => room().name ?? other()?.display_name ?? "Chat")
|
room().participants?.find(
|
||||||
|
(p: Participant) => p.user_id !== currentUser()?.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const displayName = createMemo(
|
||||||
|
() => room().name ?? other()?.display_name ?? 'Chat'
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -69,17 +77,19 @@ export function Sidebar(): JSX.Element {
|
||||||
data-active={isActive()}
|
data-active={isActive()}
|
||||||
onClick={() => setActiveRoom(room().id)}
|
onClick={() => setActiveRoom(room().id)}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: '100%',
|
||||||
display: "flex",
|
display: 'flex',
|
||||||
"align-items": "center",
|
'align-items': 'center',
|
||||||
gap: "12px",
|
gap: '12px',
|
||||||
padding: "12px",
|
padding: '12px',
|
||||||
"margin-bottom": "4px",
|
'margin-bottom': '4px',
|
||||||
border: isActive() ? "2px solid #FF5300" : "2px solid transparent",
|
border: isActive()
|
||||||
background: isActive() ? "#FF5300" : "black",
|
? '2px solid #FF5300'
|
||||||
color: isActive() ? "black" : "white",
|
: '2px solid transparent',
|
||||||
cursor: "pointer",
|
background: isActive() ? '#FF5300' : 'black',
|
||||||
"text-align": "left",
|
color: isActive() ? 'black' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'text-align': 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|
@ -87,14 +97,14 @@ export function Sidebar(): JSX.Element {
|
||||||
size="sm"
|
size="sm"
|
||||||
fallback={displayName().slice(0, 2)}
|
fallback={displayName().slice(0, 2)}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, "min-width": 0 }}>
|
<div style={{ flex: 1, 'min-width': 0 }}>
|
||||||
<span class="font-pixel text-[10px] truncate block">
|
<span class="font-pixel text-[10px] truncate block">
|
||||||
{displayName()}
|
{displayName()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={room().last_message}>
|
<Show when={room().last_message}>
|
||||||
<p
|
<p
|
||||||
class="font-pixel text-[8px] truncate mt-0.5"
|
class="font-pixel text-[8px] truncate mt-0.5"
|
||||||
style={{ color: isActive() ? "rgba(0,0,0,0.7)" : "#888" }}
|
style={{ color: isActive() ? 'rgba(0,0,0,0.7)' : '#888' }}
|
||||||
>
|
>
|
||||||
{room().last_message?.content}
|
{room().last_message?.content}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -116,7 +126,13 @@ export function Sidebar(): JSX.Element {
|
||||||
|
|
||||||
function PlusIcon(): JSX.Element {
|
function PlusIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="7" y="2" width="2" height="12" />
|
<rect x="7" y="2" width="2" height="12" />
|
||||||
<rect x="2" y="7" width="12" height="2" />
|
<rect x="2" y="7" width="12" height="2" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export { AppShell } from "./AppShell"
|
export { AppShell } from './AppShell'
|
||||||
export { Sidebar } from "./Sidebar"
|
export { Header } from './Header'
|
||||||
export { Header } from "./Header"
|
export { GuestRoute, ProtectedRoute } from './ProtectedRoute'
|
||||||
export { ProtectedRoute, GuestRoute } from "./ProtectedRoute"
|
export { Sidebar } from './Sidebar'
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,37 @@
|
||||||
* 8-bit styled avatar component
|
* 8-bit styled avatar component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show, createSignal, onMount } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { createSignal, onMount, Show } from 'solid-js'
|
||||||
import type { AvatarProps, Size, PresenceStatus } from "../../types"
|
import type { AvatarProps, PresenceStatus, Size } from '../../types'
|
||||||
|
|
||||||
const SIZE_CLASSES: Record<Size, string> = {
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
xs: "w-6 h-6 text-[8px]",
|
xs: 'w-6 h-6 text-[8px]',
|
||||||
sm: "w-8 h-8 text-[10px]",
|
sm: 'w-8 h-8 text-[10px]',
|
||||||
md: "w-10 h-10 text-xs",
|
md: 'w-10 h-10 text-xs',
|
||||||
lg: "w-12 h-12 text-sm",
|
lg: 'w-12 h-12 text-sm',
|
||||||
xl: "w-16 h-16 text-base",
|
xl: 'w-16 h-16 text-base',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<PresenceStatus, string> = {
|
const STATUS_COLORS: Record<PresenceStatus, string> = {
|
||||||
online: "bg-success",
|
online: 'bg-success',
|
||||||
away: "bg-orange",
|
away: 'bg-orange',
|
||||||
offline: "bg-gray",
|
offline: 'bg-gray',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_SIZE: Record<Size, string> = {
|
const STATUS_SIZE: Record<Size, string> = {
|
||||||
xs: "w-2 h-2",
|
xs: 'w-2 h-2',
|
||||||
sm: "w-2.5 h-2.5",
|
sm: 'w-2.5 h-2.5',
|
||||||
md: "w-3 h-3",
|
md: 'w-3 h-3',
|
||||||
lg: "w-3.5 h-3.5",
|
lg: 'w-3.5 h-3.5',
|
||||||
xl: "w-4 h-4",
|
xl: 'w-4 h-4',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar(props: AvatarProps): JSX.Element {
|
export function Avatar(props: AvatarProps): JSX.Element {
|
||||||
const [imageError, setImageError] = createSignal(false)
|
const [imageError, setImageError] = createSignal(false)
|
||||||
let imgRef: HTMLImageElement | undefined
|
let imgRef: HTMLImageElement | undefined
|
||||||
|
|
||||||
const size = (): Size => props.size ?? "md"
|
const size = (): Size => props.size ?? 'md'
|
||||||
const showStatus = (): boolean => props.showStatus ?? false
|
const showStatus = (): boolean => props.showStatus ?? false
|
||||||
|
|
||||||
const getFallbackInitials = (): string => {
|
const getFallbackInitials = (): string => {
|
||||||
|
|
@ -44,7 +44,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (imgRef !== undefined) {
|
if (imgRef !== undefined) {
|
||||||
imgRef.addEventListener("error", () => setImageError(true))
|
imgRef.addEventListener('error', () => setImageError(true))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
|
||||||
relative inline-flex items-center justify-center
|
relative inline-flex items-center justify-center
|
||||||
bg-black border-2 border-orange
|
bg-black border-2 border-orange
|
||||||
${SIZE_CLASSES[size()]}
|
${SIZE_CLASSES[size()]}
|
||||||
${props.class ?? ""}
|
${props.class ?? ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
|
|
@ -74,7 +74,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
|
||||||
src={props.src}
|
src={props.src}
|
||||||
alt={props.alt}
|
alt={props.alt}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
style={{ "image-rendering": "pixelated" }}
|
style={{ 'image-rendering': 'pixelated' }}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,37 @@
|
||||||
* 8-bit styled badge component
|
* 8-bit styled badge component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { Show } from 'solid-js'
|
||||||
import type { BadgeProps, Size, BadgeVariant } from "../../types"
|
import type { BadgeProps, BadgeVariant, Size } from '../../types'
|
||||||
|
|
||||||
const SIZE_CLASSES: Record<Size, string> = {
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
xs: "px-1 py-0.5 text-[6px]",
|
xs: 'px-1 py-0.5 text-[6px]',
|
||||||
sm: "px-1.5 py-0.5 text-[8px]",
|
sm: 'px-1.5 py-0.5 text-[8px]',
|
||||||
md: "px-2 py-1 text-[10px]",
|
md: 'px-2 py-1 text-[10px]',
|
||||||
lg: "px-3 py-1 text-xs",
|
lg: 'px-3 py-1 text-xs',
|
||||||
xl: "px-4 py-1.5 text-sm",
|
xl: 'px-4 py-1.5 text-sm',
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||||
default: "bg-dark-gray text-white border-gray",
|
default: 'bg-dark-gray text-white border-gray',
|
||||||
primary: "bg-black text-orange border-orange",
|
primary: 'bg-black text-orange border-orange',
|
||||||
success: "bg-black text-success border-success",
|
success: 'bg-black text-success border-success',
|
||||||
warning: "bg-black text-orange border-orange",
|
warning: 'bg-black text-orange border-orange',
|
||||||
error: "bg-black text-error border-error",
|
error: 'bg-black text-error border-error',
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOT_COLORS: Record<BadgeVariant, string> = {
|
const DOT_COLORS: Record<BadgeVariant, string> = {
|
||||||
default: "bg-gray",
|
default: 'bg-gray',
|
||||||
primary: "bg-orange",
|
primary: 'bg-orange',
|
||||||
success: "bg-success",
|
success: 'bg-success',
|
||||||
warning: "bg-orange",
|
warning: 'bg-orange',
|
||||||
error: "bg-error",
|
error: 'bg-error',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Badge(props: BadgeProps): JSX.Element {
|
export function Badge(props: BadgeProps): JSX.Element {
|
||||||
const variant = (): BadgeVariant => props.variant ?? "default"
|
const variant = (): BadgeVariant => props.variant ?? 'default'
|
||||||
const size = (): Size => props.size ?? "md"
|
const size = (): Size => props.size ?? 'md'
|
||||||
const showDot = (): boolean => props.dot ?? false
|
const showDot = (): boolean => props.dot ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -42,7 +42,7 @@ export function Badge(props: BadgeProps): JSX.Element {
|
||||||
font-pixel border-2 uppercase
|
font-pixel border-2 uppercase
|
||||||
${SIZE_CLASSES[size()]}
|
${SIZE_CLASSES[size()]}
|
||||||
${VARIANT_CLASSES[variant()]}
|
${VARIANT_CLASSES[variant()]}
|
||||||
${props.class ?? ""}
|
${props.class ?? ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Show when={showDot()}>
|
<Show when={showDot()}>
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,17 @@
|
||||||
* 8-bit styled button component
|
* 8-bit styled button component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show, splitProps } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { Show, splitProps } from 'solid-js'
|
||||||
import type { ButtonProps, Size, ButtonVariant } from "../../types"
|
import type { ButtonProps, ButtonVariant, Size } from '../../types'
|
||||||
import { Spinner } from "./Spinner"
|
import { Spinner } from './Spinner'
|
||||||
|
|
||||||
const SIZE_CLASSES: Record<Size, string> = {
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
xs: "px-2 py-1 text-[8px]",
|
xs: 'px-2 py-1 text-[8px]',
|
||||||
sm: "px-3 py-1.5 text-[10px]",
|
sm: 'px-3 py-1.5 text-[10px]',
|
||||||
md: "px-4 py-2 text-xs",
|
md: 'px-4 py-2 text-xs',
|
||||||
lg: "px-6 py-3 text-sm",
|
lg: 'px-6 py-3 text-sm',
|
||||||
xl: "px-8 py-4 text-base",
|
xl: 'px-8 py-4 text-base',
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||||
|
|
@ -49,21 +49,21 @@ const DISABLED_CLASSES = `
|
||||||
|
|
||||||
export function Button(props: ButtonProps): JSX.Element {
|
export function Button(props: ButtonProps): JSX.Element {
|
||||||
const [local, rest] = splitProps(props, [
|
const [local, rest] = splitProps(props, [
|
||||||
"variant",
|
'variant',
|
||||||
"size",
|
'size',
|
||||||
"fullWidth",
|
'fullWidth',
|
||||||
"disabled",
|
'disabled',
|
||||||
"loading",
|
'loading',
|
||||||
"leftIcon",
|
'leftIcon',
|
||||||
"rightIcon",
|
'rightIcon',
|
||||||
"type",
|
'type',
|
||||||
"onClick",
|
'onClick',
|
||||||
"class",
|
'class',
|
||||||
"children",
|
'children',
|
||||||
])
|
])
|
||||||
|
|
||||||
const variant = (): ButtonVariant => local.variant ?? "primary"
|
const variant = (): ButtonVariant => local.variant ?? 'primary'
|
||||||
const size = (): Size => local.size ?? "md"
|
const size = (): Size => local.size ?? 'md'
|
||||||
const isDisabled = (): boolean => local.disabled ?? false
|
const isDisabled = (): boolean => local.disabled ?? false
|
||||||
const isLoading = (): boolean => local.loading ?? false
|
const isLoading = (): boolean => local.loading ?? false
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ export function Button(props: ButtonProps): JSX.Element {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={local.type ?? "button"}
|
type={local.type ?? 'button'}
|
||||||
disabled={isDisabled() || isLoading()}
|
disabled={isDisabled() || isLoading()}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
class={`
|
class={`
|
||||||
|
|
@ -84,22 +84,18 @@ export function Button(props: ButtonProps): JSX.Element {
|
||||||
focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-2 focus:ring-offset-black
|
focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-2 focus:ring-offset-black
|
||||||
${SIZE_CLASSES[size()]}
|
${SIZE_CLASSES[size()]}
|
||||||
${VARIANT_CLASSES[variant()]}
|
${VARIANT_CLASSES[variant()]}
|
||||||
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""}
|
${isDisabled() || isLoading() ? DISABLED_CLASSES : ''}
|
||||||
${local.fullWidth === true ? "w-full" : ""}
|
${local.fullWidth === true ? 'w-full' : ''}
|
||||||
${local.class ?? ""}
|
${local.class ?? ''}
|
||||||
`}
|
`}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Show when={isLoading()}>
|
<Show when={isLoading()}>
|
||||||
<Spinner size="xs" />
|
<Spinner size="xs" />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isLoading() && local.leftIcon}>
|
<Show when={!isLoading() && local.leftIcon}>{local.leftIcon}</Show>
|
||||||
{local.leftIcon}
|
|
||||||
</Show>
|
|
||||||
<span>{local.children}</span>
|
<span>{local.children}</span>
|
||||||
<Show when={!isLoading() && local.rightIcon}>
|
<Show when={!isLoading() && local.rightIcon}>{local.rightIcon}</Show>
|
||||||
{local.rightIcon}
|
|
||||||
</Show>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,30 +2,32 @@
|
||||||
* 8-bit styled dropdown menu component
|
* 8-bit styled dropdown menu component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show, For, createSignal, onCleanup, createEffect } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { createEffect, createSignal, For, onCleanup, Show } from 'solid-js'
|
||||||
import type { DropdownProps, DropdownItem } from "../../types"
|
import type { DropdownItem, DropdownProps } from '../../types'
|
||||||
|
|
||||||
type DropdownAlign = "left" | "right"
|
type DropdownAlign = 'left' | 'right'
|
||||||
|
|
||||||
export function Dropdown(props: DropdownProps): JSX.Element {
|
export function Dropdown(props: DropdownProps): JSX.Element {
|
||||||
const [isOpen, setIsOpen] = createSignal(false)
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
const align = (): DropdownAlign => props.align ?? "left"
|
const align = (): DropdownAlign => props.align ?? 'left'
|
||||||
|
|
||||||
const getItemClasses = (item: DropdownItem): string => {
|
const getItemClasses = (item: DropdownItem): string => {
|
||||||
if (item.disabled === true) {
|
if (item.disabled === true) {
|
||||||
return "text-gray cursor-not-allowed"
|
return 'text-gray cursor-not-allowed'
|
||||||
}
|
}
|
||||||
if (item.danger === true) {
|
if (item.danger === true) {
|
||||||
return "text-error hover:bg-error hover:text-white"
|
return 'text-error hover:bg-error hover:text-white'
|
||||||
}
|
}
|
||||||
return "text-white hover:bg-orange hover:text-black"
|
return 'text-white hover:bg-orange hover:text-black'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getItemActiveClass = (item: DropdownItem): string => {
|
const getItemActiveClass = (item: DropdownItem): string => {
|
||||||
return item.disabled === true ? "" : "active:translate-x-[1px] active:translate-y-[1px]"
|
return item.disabled === true
|
||||||
|
? ''
|
||||||
|
: 'active:translate-x-[1px] active:translate-y-[1px]'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggle = (): void => {
|
const handleToggle = (): void => {
|
||||||
|
|
@ -45,41 +47,38 @@ export function Dropdown(props: DropdownProps): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === 'Escape') {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isOpen()) {
|
if (isOpen()) {
|
||||||
document.addEventListener("click", handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
document.removeEventListener("click", handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={containerRef} class={`relative inline-block ${props.class ?? ''}`}>
|
||||||
ref={containerRef}
|
<button
|
||||||
class={`relative inline-block ${props.class ?? ""}`}
|
type="button"
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleToggle()
|
handleToggle()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.trigger}
|
{props.trigger}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<Show when={isOpen()}>
|
<Show when={isOpen()}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -88,7 +87,7 @@ export function Dropdown(props: DropdownProps): JSX.Element {
|
||||||
bg-black border-2 border-orange
|
bg-black border-2 border-orange
|
||||||
shadow-[4px_4px_0_var(--color-orange)]
|
shadow-[4px_4px_0_var(--color-orange)]
|
||||||
animate-scale-in origin-top
|
animate-scale-in origin-top
|
||||||
${align() === "right" ? "right-0" : "left-0"}
|
${align() === 'right' ? 'right-0' : 'left-0'}
|
||||||
`}
|
`}
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,27 @@
|
||||||
* 8-bit styled icon button component
|
* 8-bit styled icon button component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show, splitProps } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { Show, splitProps } from 'solid-js'
|
||||||
import type { IconButtonProps, Size } from "../../types"
|
import type { IconButtonProps, Size } from '../../types'
|
||||||
import { Spinner } from "./Spinner"
|
import { Spinner } from './Spinner'
|
||||||
|
|
||||||
type IconButtonVariant = "ghost" | "subtle"
|
type IconButtonVariant = 'ghost' | 'subtle'
|
||||||
|
|
||||||
const SIZE_CLASSES: Record<Size, string> = {
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
xs: "w-6 h-6",
|
xs: 'w-6 h-6',
|
||||||
sm: "w-8 h-8",
|
sm: 'w-8 h-8',
|
||||||
md: "w-10 h-10",
|
md: 'w-10 h-10',
|
||||||
lg: "w-12 h-12",
|
lg: 'w-12 h-12',
|
||||||
xl: "w-14 h-14",
|
xl: 'w-14 h-14',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_SIZE_CLASSES: Record<Size, string> = {
|
const ICON_SIZE_CLASSES: Record<Size, string> = {
|
||||||
xs: "w-3 h-3",
|
xs: 'w-3 h-3',
|
||||||
sm: "w-4 h-4",
|
sm: 'w-4 h-4',
|
||||||
md: "w-5 h-5",
|
md: 'w-5 h-5',
|
||||||
lg: "w-6 h-6",
|
lg: 'w-6 h-6',
|
||||||
xl: "w-7 h-7",
|
xl: 'w-7 h-7',
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_CLASSES: Record<IconButtonVariant, string> = {
|
const VARIANT_CLASSES: Record<IconButtonVariant, string> = {
|
||||||
|
|
@ -38,22 +38,22 @@ const VARIANT_CLASSES: Record<IconButtonVariant, string> = {
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISABLED_CLASSES = "opacity-50 cursor-not-allowed hover:bg-transparent"
|
const DISABLED_CLASSES = 'opacity-50 cursor-not-allowed hover:bg-transparent'
|
||||||
|
|
||||||
export function IconButton(props: IconButtonProps): JSX.Element {
|
export function IconButton(props: IconButtonProps): JSX.Element {
|
||||||
const [local, rest] = splitProps(props, [
|
const [local, rest] = splitProps(props, [
|
||||||
"icon",
|
'icon',
|
||||||
"onClick",
|
'onClick',
|
||||||
"size",
|
'size',
|
||||||
"variant",
|
'variant',
|
||||||
"ariaLabel",
|
'ariaLabel',
|
||||||
"disabled",
|
'disabled',
|
||||||
"loading",
|
'loading',
|
||||||
"class",
|
'class',
|
||||||
])
|
])
|
||||||
|
|
||||||
const size = (): Size => local.size ?? "md"
|
const size = (): Size => local.size ?? 'md'
|
||||||
const variant = (): IconButtonVariant => local.variant ?? "ghost"
|
const variant = (): IconButtonVariant => local.variant ?? 'ghost'
|
||||||
const isDisabled = (): boolean => local.disabled ?? false
|
const isDisabled = (): boolean => local.disabled ?? false
|
||||||
const isLoading = (): boolean => local.loading ?? false
|
const isLoading = (): boolean => local.loading ?? false
|
||||||
|
|
||||||
|
|
@ -75,18 +75,16 @@ export function IconButton(props: IconButtonProps): JSX.Element {
|
||||||
focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-1 focus:ring-offset-black
|
focus:outline-none focus:ring-2 focus:ring-orange focus:ring-offset-1 focus:ring-offset-black
|
||||||
${SIZE_CLASSES[size()]}
|
${SIZE_CLASSES[size()]}
|
||||||
${VARIANT_CLASSES[variant()]}
|
${VARIANT_CLASSES[variant()]}
|
||||||
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""}
|
${isDisabled() || isLoading() ? DISABLED_CLASSES : ''}
|
||||||
${local.class ?? ""}
|
${local.class ?? ''}
|
||||||
`}
|
`}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={!isLoading()}
|
when={!isLoading()}
|
||||||
fallback={<Spinner size={size() === "xs" ? "xs" : "sm"} />}
|
fallback={<Spinner size={size() === 'xs' ? 'xs' : 'sm'} />}
|
||||||
>
|
>
|
||||||
<span class={ICON_SIZE_CLASSES[size()]}>
|
<span class={ICON_SIZE_CLASSES[size()]}>{local.icon}</span>
|
||||||
{local.icon}
|
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,32 @@
|
||||||
* 8-bit styled input component
|
* 8-bit styled input component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show, splitProps, createSignal } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { createSignal, Show, splitProps } from 'solid-js'
|
||||||
import type { InputProps } from "../../types"
|
import type { InputProps } from '../../types'
|
||||||
|
|
||||||
export function Input(props: InputProps): JSX.Element {
|
export function Input(props: InputProps): JSX.Element {
|
||||||
const [local, rest] = splitProps(props, [
|
const [local, rest] = splitProps(props, [
|
||||||
"type",
|
'type',
|
||||||
"name",
|
'name',
|
||||||
"placeholder",
|
'placeholder',
|
||||||
"value",
|
'value',
|
||||||
"onInput",
|
'onInput',
|
||||||
"onChange",
|
'onChange',
|
||||||
"onFocus",
|
'onFocus',
|
||||||
"onBlur",
|
'onBlur',
|
||||||
"disabled",
|
'disabled',
|
||||||
"error",
|
'error',
|
||||||
"label",
|
'label',
|
||||||
"hint",
|
'hint',
|
||||||
"leftIcon",
|
'leftIcon',
|
||||||
"rightIcon",
|
'rightIcon',
|
||||||
"fullWidth",
|
'fullWidth',
|
||||||
"maxLength",
|
'maxLength',
|
||||||
"minLength",
|
'minLength',
|
||||||
"required",
|
'required',
|
||||||
"autofocus",
|
'autofocus',
|
||||||
"class",
|
'class',
|
||||||
])
|
])
|
||||||
|
|
||||||
const [focused, setFocused] = createSignal(false)
|
const [focused, setFocused] = createSignal(false)
|
||||||
|
|
@ -48,7 +48,9 @@ export function Input(props: InputProps): JSX.Element {
|
||||||
const isDisabled = (): boolean => local.disabled ?? false
|
const isDisabled = (): boolean => local.disabled ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col gap-1 ${local.fullWidth === true ? "w-full" : ""}`}>
|
<div
|
||||||
|
class={`flex flex-col gap-1 ${local.fullWidth === true ? 'w-full' : ''}`}
|
||||||
|
>
|
||||||
<Show when={local.label}>
|
<Show when={local.label}>
|
||||||
<label
|
<label
|
||||||
class="font-pixel text-[10px] text-orange uppercase tracking-wider"
|
class="font-pixel text-[10px] text-orange uppercase tracking-wider"
|
||||||
|
|
@ -66,9 +68,9 @@ export function Input(props: InputProps): JSX.Element {
|
||||||
relative flex items-center
|
relative flex items-center
|
||||||
bg-black border-2
|
bg-black border-2
|
||||||
transition-all duration-100
|
transition-all duration-100
|
||||||
${focused() ? "border-orange shadow-[0_0_0_2px_var(--color-orange)]" : "border-dark-gray"}
|
${focused() ? 'border-orange shadow-[0_0_0_2px_var(--color-orange)]' : 'border-dark-gray'}
|
||||||
${hasError() ? "border-error shadow-[0_0_0_2px_var(--color-error)]" : ""}
|
${hasError() ? 'border-error shadow-[0_0_0_2px_var(--color-error)]' : ''}
|
||||||
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""}
|
${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Show when={local.leftIcon}>
|
<Show when={local.leftIcon}>
|
||||||
|
|
@ -76,11 +78,11 @@ export function Input(props: InputProps): JSX.Element {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type={local.type ?? "text"}
|
type={local.type ?? 'text'}
|
||||||
name={local.name}
|
name={local.name}
|
||||||
id={local.name}
|
id={local.name}
|
||||||
placeholder={local.placeholder}
|
placeholder={local.placeholder}
|
||||||
value={local.value ?? ""}
|
value={local.value ?? ''}
|
||||||
disabled={isDisabled()}
|
disabled={isDisabled()}
|
||||||
maxLength={local.maxLength}
|
maxLength={local.maxLength}
|
||||||
minLength={local.minLength}
|
minLength={local.minLength}
|
||||||
|
|
@ -102,9 +104,9 @@ export function Input(props: InputProps): JSX.Element {
|
||||||
placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray
|
placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray
|
||||||
focus:outline-none
|
focus:outline-none
|
||||||
disabled:cursor-not-allowed
|
disabled:cursor-not-allowed
|
||||||
${local.leftIcon !== undefined ? "pl-1" : ""}
|
${local.leftIcon !== undefined ? 'pl-1' : ''}
|
||||||
${local.rightIcon !== undefined ? "pr-1" : ""}
|
${local.rightIcon !== undefined ? 'pr-1' : ''}
|
||||||
${local.class ?? ""}
|
${local.class ?? ''}
|
||||||
`}
|
`}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,50 +2,44 @@
|
||||||
* 8-bit styled modal component
|
* 8-bit styled modal component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show, createEffect, onCleanup } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { createEffect, onCleanup, Show } from 'solid-js'
|
||||||
import type { ModalProps, Size } from "../../types"
|
import type { ModalProps, Size } from '../../types'
|
||||||
import { IconButton } from "./IconButton"
|
import { IconButton } from './IconButton'
|
||||||
|
|
||||||
const SIZE_CLASSES: Record<Size, string> = {
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
xs: "max-w-xs",
|
xs: 'max-w-xs',
|
||||||
sm: "max-w-sm",
|
sm: 'max-w-sm',
|
||||||
md: "max-w-md",
|
md: 'max-w-md',
|
||||||
lg: "max-w-lg",
|
lg: 'max-w-lg',
|
||||||
xl: "max-w-xl",
|
xl: 'max-w-xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal(props: ModalProps): JSX.Element {
|
export function Modal(props: ModalProps): JSX.Element {
|
||||||
const size = (): Size => props.size ?? "md"
|
const size = (): Size => props.size ?? 'md'
|
||||||
const closeOnOverlayClick = (): boolean => props.closeOnOverlayClick ?? true
|
const closeOnOverlayClick = (): boolean => props.closeOnOverlayClick ?? true
|
||||||
const showCloseButton = (): boolean => props.showCloseButton ?? true
|
const showCloseButton = (): boolean => props.showCloseButton ?? true
|
||||||
|
|
||||||
const handleOverlayClick = (e: MouseEvent): void => {
|
|
||||||
if (e.target === e.currentTarget && closeOnOverlayClick()) {
|
|
||||||
props.onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === 'Escape') {
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.isOpen) {
|
if (props.isOpen) {
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
document.body.style.overflow = "hidden"
|
document.body.style.overflow = 'hidden'
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
document.body.style.overflow = ""
|
document.body.style.overflow = ''
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleOverlayKeyDown = (e: KeyboardEvent): void => {
|
const handleOverlayKeyDown = (e: KeyboardEvent): void => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (closeOnOverlayClick()) {
|
if (closeOnOverlayClick()) {
|
||||||
props.onClose()
|
props.onClose()
|
||||||
|
|
@ -55,15 +49,17 @@ export function Modal(props: ModalProps): JSX.Element {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.isOpen}>
|
<Show when={props.isOpen}>
|
||||||
<div
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
<button
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
class="absolute inset-0 w-full h-full bg-black/80 animate-fade-in border-0 cursor-default appearance-none"
|
||||||
onClick={handleOverlayClick}
|
tabIndex={-1}
|
||||||
onKeyDown={handleOverlayKeyDown}
|
aria-label="Close modal"
|
||||||
aria-label="Close modal overlay"
|
onClick={() => {
|
||||||
>
|
if (closeOnOverlayClick()) props.onClose()
|
||||||
<div class="absolute inset-0 bg-black/80 animate-fade-in" />
|
}}
|
||||||
|
onKeyDown={handleOverlayKeyDown}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`
|
class={`
|
||||||
|
|
@ -75,9 +71,14 @@ export function Modal(props: ModalProps): JSX.Element {
|
||||||
`}
|
`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={props.title ? "modal-title" : undefined}
|
aria-labelledby={props.title ? 'modal-title' : undefined}
|
||||||
>
|
>
|
||||||
<Show when={(props.title !== undefined && props.title !== "") || showCloseButton()}>
|
<Show
|
||||||
|
when={
|
||||||
|
(props.title !== undefined && props.title !== '') ||
|
||||||
|
showCloseButton()
|
||||||
|
}
|
||||||
|
>
|
||||||
<div class="flex items-start justify-between p-4 border-b-2 border-orange">
|
<div class="flex items-start justify-between p-4 border-b-2 border-orange">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<Show when={props.title}>
|
<Show when={props.title}>
|
||||||
|
|
@ -107,9 +108,7 @@ export function Modal(props: ModalProps): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">{props.children}</div>
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -124,6 +123,7 @@ function CloseIcon(): JSX.Element {
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M2 2L14 14M14 2L2 14"
|
d="M2 2L14 14M14 2L2 14"
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,24 @@
|
||||||
* 8-bit styled skeleton loading component
|
* 8-bit styled skeleton loading component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { For } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { For } from 'solid-js'
|
||||||
import type { SkeletonProps } from "../../types"
|
import type { SkeletonProps } from '../../types'
|
||||||
|
|
||||||
type SkeletonVariant = "text" | "circular" | "rectangular"
|
type SkeletonVariant = 'text' | 'circular' | 'rectangular'
|
||||||
|
|
||||||
export function Skeleton(props: SkeletonProps): JSX.Element {
|
export function Skeleton(props: SkeletonProps): JSX.Element {
|
||||||
const variant = (): SkeletonVariant => props.variant ?? "text"
|
const variant = (): SkeletonVariant => props.variant ?? 'text'
|
||||||
const lines = (): number => props.lines ?? 1
|
const lines = (): number => props.lines ?? 1
|
||||||
|
|
||||||
const getVariantClasses = (): string => {
|
const getVariantClasses = (): string => {
|
||||||
switch (variant()) {
|
switch (variant()) {
|
||||||
case "circular":
|
case 'circular':
|
||||||
return "rounded-none aspect-square"
|
return 'rounded-none aspect-square'
|
||||||
case "rectangular":
|
case 'rectangular':
|
||||||
return ""
|
return ''
|
||||||
case "text":
|
|
||||||
default:
|
default:
|
||||||
return "h-4"
|
return 'h-4'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +38,7 @@ export function Skeleton(props: SkeletonProps): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col gap-2 ${props.class ?? ""}`}>
|
<div class={`flex flex-col gap-2 ${props.class ?? ''}`}>
|
||||||
<For each={Array(lines()).fill(0)}>
|
<For each={Array(lines()).fill(0)}>
|
||||||
{(_, index) => (
|
{(_, index) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -47,7 +46,7 @@ export function Skeleton(props: SkeletonProps): JSX.Element {
|
||||||
bg-dark-gray
|
bg-dark-gray
|
||||||
animate-pixel-pulse
|
animate-pixel-pulse
|
||||||
${getVariantClasses()}
|
${getVariantClasses()}
|
||||||
${index() === lines() - 1 && variant() === "text" ? "w-3/4" : "w-full"}
|
${index() === lines() - 1 && variant() === 'text' ? 'w-3/4' : 'w-full'}
|
||||||
`}
|
`}
|
||||||
style={getStyle()}
|
style={getStyle()}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,27 @@
|
||||||
* 8-bit styled spinner component
|
* 8-bit styled spinner component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { SpinnerProps, Size } from "../../types"
|
import type { Size, SpinnerProps } from '../../types'
|
||||||
|
|
||||||
const SIZE_CLASSES: Record<Size, string> = {
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
xs: "w-3 h-3",
|
xs: 'w-3 h-3',
|
||||||
sm: "w-4 h-4",
|
sm: 'w-4 h-4',
|
||||||
md: "w-6 h-6",
|
md: 'w-6 h-6',
|
||||||
lg: "w-8 h-8",
|
lg: 'w-8 h-8',
|
||||||
xl: "w-12 h-12",
|
xl: 'w-12 h-12',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Spinner(props: SpinnerProps): JSX.Element {
|
export function Spinner(props: SpinnerProps): JSX.Element {
|
||||||
const size = (): Size => props.size ?? "md"
|
const size = (): Size => props.size ?? 'md'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<output
|
||||||
class={`
|
class={`
|
||||||
inline-block animate-pixel-spin
|
inline-block animate-pixel-spin
|
||||||
${SIZE_CLASSES[size()]}
|
${SIZE_CLASSES[size()]}
|
||||||
${props.class ?? ""}
|
${props.class ?? ''}
|
||||||
`}
|
`}
|
||||||
role="status"
|
|
||||||
aria-label="Loading"
|
aria-label="Loading"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -31,17 +30,74 @@ export function Spinner(props: SpinnerProps): JSX.Element {
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<rect x="6" y="0" width="4" height="4" fill="currentColor" class="text-orange" />
|
<rect
|
||||||
<rect x="10" y="2" width="4" height="4" fill="currentColor" class="text-orange/80" />
|
x="6"
|
||||||
<rect x="12" y="6" width="4" height="4" fill="currentColor" class="text-orange/60" />
|
y="0"
|
||||||
<rect x="10" y="10" width="4" height="4" fill="currentColor" class="text-orange/40" />
|
width="4"
|
||||||
<rect x="6" y="12" width="4" height="4" fill="currentColor" class="text-orange/30" />
|
height="4"
|
||||||
<rect x="2" y="10" width="4" height="4" fill="currentColor" class="text-orange/20" />
|
fill="currentColor"
|
||||||
<rect x="0" y="6" width="4" height="4" fill="currentColor" class="text-orange/10" />
|
class="text-orange"
|
||||||
<rect x="2" y="2" width="4" height="4" fill="currentColor" class="text-orange/90" />
|
/>
|
||||||
|
<rect
|
||||||
|
x="10"
|
||||||
|
y="2"
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange/80"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="12"
|
||||||
|
y="6"
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange/60"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="10"
|
||||||
|
y="10"
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange/40"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="6"
|
||||||
|
y="12"
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange/30"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="2"
|
||||||
|
y="10"
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange/20"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y="6"
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange/10"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="2"
|
||||||
|
y="2"
|
||||||
|
width="4"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange/90"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</output>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,23 @@
|
||||||
* 8-bit styled textarea component
|
* 8-bit styled textarea component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show, splitProps, createSignal, createEffect, onMount } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { createEffect, createSignal, onMount, Show, splitProps } from 'solid-js'
|
||||||
import type { TextAreaProps } from "../../types"
|
import type { TextAreaProps } from '../../types'
|
||||||
|
|
||||||
export function TextArea(props: TextAreaProps): JSX.Element {
|
export function TextArea(props: TextAreaProps): JSX.Element {
|
||||||
const [local, rest] = splitProps(props, [
|
const [local, rest] = splitProps(props, [
|
||||||
"name",
|
'name',
|
||||||
"placeholder",
|
'placeholder',
|
||||||
"value",
|
'value',
|
||||||
"onInput",
|
'onInput',
|
||||||
"disabled",
|
'disabled',
|
||||||
"error",
|
'error',
|
||||||
"label",
|
'label',
|
||||||
"rows",
|
'rows',
|
||||||
"maxLength",
|
'maxLength',
|
||||||
"autoResize",
|
'autoResize',
|
||||||
"class",
|
'class',
|
||||||
])
|
])
|
||||||
|
|
||||||
const [focused, setFocused] = createSignal(false)
|
const [focused, setFocused] = createSignal(false)
|
||||||
|
|
@ -36,7 +36,7 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
||||||
|
|
||||||
const adjustHeight = (): void => {
|
const adjustHeight = (): void => {
|
||||||
if (textareaRef !== undefined) {
|
if (textareaRef !== undefined) {
|
||||||
textareaRef.style.height = "auto"
|
textareaRef.style.height = 'auto'
|
||||||
textareaRef.style.height = `${textareaRef.scrollHeight}px`
|
textareaRef.style.height = `${textareaRef.scrollHeight}px`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -48,14 +48,18 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (local.autoResize === true && local.value !== undefined && textareaRef !== undefined) {
|
if (
|
||||||
|
local.autoResize === true &&
|
||||||
|
local.value !== undefined &&
|
||||||
|
textareaRef !== undefined
|
||||||
|
) {
|
||||||
adjustHeight()
|
adjustHeight()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasError = (): boolean => Boolean(local.error)
|
const hasError = (): boolean => Boolean(local.error)
|
||||||
const isDisabled = (): boolean => local.disabled ?? false
|
const isDisabled = (): boolean => local.disabled ?? false
|
||||||
const currentLength = (): number => (local.value ?? "").length
|
const currentLength = (): number => (local.value ?? '').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-1 w-full">
|
<div class="flex flex-col gap-1 w-full">
|
||||||
|
|
@ -73,9 +77,9 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
||||||
relative
|
relative
|
||||||
bg-black border-2
|
bg-black border-2
|
||||||
transition-all duration-100
|
transition-all duration-100
|
||||||
${focused() ? "border-orange shadow-[0_0_0_2px_var(--color-orange)]" : "border-dark-gray"}
|
${focused() ? 'border-orange shadow-[0_0_0_2px_var(--color-orange)]' : 'border-dark-gray'}
|
||||||
${hasError() ? "border-error shadow-[0_0_0_2px_var(--color-error)]" : ""}
|
${hasError() ? 'border-error shadow-[0_0_0_2px_var(--color-error)]' : ''}
|
||||||
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""}
|
${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -83,7 +87,7 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
||||||
name={local.name}
|
name={local.name}
|
||||||
id={local.name}
|
id={local.name}
|
||||||
placeholder={local.placeholder}
|
placeholder={local.placeholder}
|
||||||
value={local.value ?? ""}
|
value={local.value ?? ''}
|
||||||
disabled={isDisabled()}
|
disabled={isDisabled()}
|
||||||
rows={local.rows ?? 3}
|
rows={local.rows ?? 3}
|
||||||
maxLength={local.maxLength}
|
maxLength={local.maxLength}
|
||||||
|
|
@ -97,8 +101,8 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
||||||
focus:outline-none
|
focus:outline-none
|
||||||
disabled:cursor-not-allowed
|
disabled:cursor-not-allowed
|
||||||
resize-none
|
resize-none
|
||||||
${local.autoResize === true ? "overflow-hidden" : ""}
|
${local.autoResize === true ? 'overflow-hidden' : ''}
|
||||||
${local.class ?? ""}
|
${local.class ?? ''}
|
||||||
`}
|
`}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
|
@ -116,8 +120,8 @@ export function TextArea(props: TextAreaProps): JSX.Element {
|
||||||
<span
|
<span
|
||||||
class={`font-pixel text-[8px] ${
|
class={`font-pixel text-[8px] ${
|
||||||
currentLength() > (local.maxLength ?? 0) * 0.9
|
currentLength() > (local.maxLength ?? 0) * 0.9
|
||||||
? "text-error"
|
? 'text-error'
|
||||||
: "text-gray"
|
: 'text-gray'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{currentLength()}/{local.maxLength}
|
{currentLength()}/{local.maxLength}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@
|
||||||
* 8-bit styled toast notification component
|
* 8-bit styled toast notification component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { For, Show } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { ToastProps } from "../../types"
|
import { For, Show } from 'solid-js'
|
||||||
import { $toasts, dismissToast } from "../../stores"
|
import { $toasts, dismissToast } from '../../stores'
|
||||||
import { useStore } from "@nanostores/solid"
|
import type { ToastProps } from '../../types'
|
||||||
import { IconButton } from "./IconButton"
|
import { IconButton } from './IconButton'
|
||||||
|
|
||||||
type ToastVariant = "info" | "success" | "warning" | "error"
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
||||||
info: "border-info text-info",
|
info: 'border-info text-info',
|
||||||
success: "border-success text-success",
|
success: 'border-success text-success',
|
||||||
warning: "border-orange text-orange",
|
warning: 'border-orange text-orange',
|
||||||
error: "border-error text-error",
|
error: 'border-error text-error',
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_ICONS: Record<ToastVariant, () => JSX.Element> = {
|
const VARIANT_ICONS: Record<ToastVariant, () => JSX.Element> = {
|
||||||
|
|
@ -41,22 +41,17 @@ export function Toast(props: ToastProps): JSX.Element {
|
||||||
`}
|
`}
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<span class="flex-shrink-0 mt-0.5">
|
<span class="flex-shrink-0 mt-0.5">{VARIANT_ICONS[props.variant]()}</span>
|
||||||
{VARIANT_ICONS[props.variant]()}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-pixel text-[10px] text-white uppercase">
|
<p class="font-pixel text-[10px] text-white uppercase">{props.title}</p>
|
||||||
{props.title}
|
|
||||||
</p>
|
|
||||||
<Show when={props.description}>
|
<Show when={props.description}>
|
||||||
<p class="font-pixel text-[8px] text-gray mt-1">
|
<p class="font-pixel text-[8px] text-gray mt-1">{props.description}</p>
|
||||||
{props.description}
|
|
||||||
</p>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.action} keyed>
|
<Show when={props.action} keyed>
|
||||||
{(action) => (
|
{(action) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => action.onClick()}
|
onClick={() => action.onClick()}
|
||||||
class="font-pixel text-[8px] text-orange hover:underline mt-2"
|
class="font-pixel text-[8px] text-orange hover:underline mt-2"
|
||||||
>
|
>
|
||||||
|
|
@ -82,16 +77,20 @@ export function ToastContainer(): JSX.Element {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
<For each={toasts()}>
|
<For each={toasts()}>{(toast) => <Toast {...toast} />}</For>
|
||||||
{(toast) => <Toast {...toast} />}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoIcon(): JSX.Element {
|
function InfoIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="7" y="3" width="2" height="2" />
|
<rect x="7" y="3" width="2" height="2" />
|
||||||
<rect x="7" y="7" width="2" height="6" />
|
<rect x="7" y="7" width="2" height="6" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -100,7 +99,13 @@ function InfoIcon(): JSX.Element {
|
||||||
|
|
||||||
function CheckIcon(): JSX.Element {
|
function CheckIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="3" y="8" width="2" height="2" />
|
<rect x="3" y="8" width="2" height="2" />
|
||||||
<rect x="5" y="10" width="2" height="2" />
|
<rect x="5" y="10" width="2" height="2" />
|
||||||
<rect x="7" y="8" width="2" height="2" />
|
<rect x="7" y="8" width="2" height="2" />
|
||||||
|
|
@ -112,7 +117,13 @@ function CheckIcon(): JSX.Element {
|
||||||
|
|
||||||
function WarningIcon(): JSX.Element {
|
function WarningIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="7" y="2" width="2" height="8" />
|
<rect x="7" y="2" width="2" height="8" />
|
||||||
<rect x="7" y="12" width="2" height="2" />
|
<rect x="7" y="12" width="2" height="2" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -121,7 +132,13 @@ function WarningIcon(): JSX.Element {
|
||||||
|
|
||||||
function ErrorIcon(): JSX.Element {
|
function ErrorIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="3" y="3" width="2" height="2" />
|
<rect x="3" y="3" width="2" height="2" />
|
||||||
<rect x="5" y="5" width="2" height="2" />
|
<rect x="5" y="5" width="2" height="2" />
|
||||||
<rect x="7" y="7" width="2" height="2" />
|
<rect x="7" y="7" width="2" height="2" />
|
||||||
|
|
@ -137,7 +154,13 @@ function ErrorIcon(): JSX.Element {
|
||||||
|
|
||||||
function CloseIcon(): JSX.Element {
|
function CloseIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="2" y="2" width="2" height="2" />
|
<rect x="2" y="2" width="2" height="2" />
|
||||||
<rect x="4" y="4" width="2" height="2" />
|
<rect x="4" y="4" width="2" height="2" />
|
||||||
<rect x="6" y="4" width="2" height="2" />
|
<rect x="6" y="4" width="2" height="2" />
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,26 @@
|
||||||
* 8-bit styled tooltip component
|
* 8-bit styled tooltip component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show, createSignal, onCleanup } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { JSX } from "solid-js"
|
import { createSignal, onCleanup, Show } from 'solid-js'
|
||||||
import type { TooltipProps } from "../../types"
|
import type { TooltipProps } from '../../types'
|
||||||
|
|
||||||
type TooltipPosition = "top" | "bottom" | "left" | "right"
|
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
|
||||||
const POSITION_CLASSES: Record<TooltipPosition, string> = {
|
const POSITION_CLASSES: Record<TooltipPosition, string> = {
|
||||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||||
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||||
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARROW_CLASSES: Record<TooltipPosition, string> = {
|
const ARROW_CLASSES: Record<TooltipPosition, string> = {
|
||||||
top: "top-full left-1/2 -translate-x-1/2 border-t-orange border-x-transparent border-b-transparent",
|
top: 'top-full left-1/2 -translate-x-1/2 border-t-orange border-x-transparent border-b-transparent',
|
||||||
bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-orange border-x-transparent border-t-transparent",
|
bottom:
|
||||||
left: "left-full top-1/2 -translate-y-1/2 border-l-orange border-y-transparent border-r-transparent",
|
'bottom-full left-1/2 -translate-x-1/2 border-b-orange border-x-transparent border-t-transparent',
|
||||||
right: "right-full top-1/2 -translate-y-1/2 border-r-orange border-y-transparent border-l-transparent",
|
left: 'left-full top-1/2 -translate-y-1/2 border-l-orange border-y-transparent border-r-transparent',
|
||||||
|
right:
|
||||||
|
'right-full top-1/2 -translate-y-1/2 border-r-orange border-y-transparent border-l-transparent',
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DELAY_MS = 300
|
const DEFAULT_DELAY_MS = 300
|
||||||
|
|
@ -28,7 +30,7 @@ export function Tooltip(props: TooltipProps): JSX.Element {
|
||||||
const [visible, setVisible] = createSignal(false)
|
const [visible, setVisible] = createSignal(false)
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
const position = (): TooltipPosition => props.position ?? "top"
|
const position = (): TooltipPosition => props.position ?? 'top'
|
||||||
const delay = (): number => props.delay ?? DEFAULT_DELAY_MS
|
const delay = (): number => props.delay ?? DEFAULT_DELAY_MS
|
||||||
|
|
||||||
const showTooltip = (): void => {
|
const showTooltip = (): void => {
|
||||||
|
|
@ -51,9 +53,8 @@ export function Tooltip(props: TooltipProps): JSX.Element {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<span
|
||||||
class="relative inline-block"
|
class="relative inline-block"
|
||||||
role="presentation"
|
|
||||||
onMouseEnter={showTooltip}
|
onMouseEnter={showTooltip}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
onFocus={showTooltip}
|
onFocus={showTooltip}
|
||||||
|
|
@ -91,6 +92,6 @@ export function Tooltip(props: TooltipProps): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,21 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export { Button } from "./Button"
|
|
||||||
export { Input } from "./Input"
|
export { Avatar } from './Avatar'
|
||||||
export { TextArea } from "./TextArea"
|
export { Badge } from './Badge'
|
||||||
export { Avatar } from "./Avatar"
|
export { Button } from './Button'
|
||||||
export { Badge } from "./Badge"
|
export { Dropdown } from './Dropdown'
|
||||||
export { Modal } from "./Modal"
|
export { IconButton } from './IconButton'
|
||||||
export { Spinner, LoadingOverlay } from "./Spinner"
|
export { Input } from './Input'
|
||||||
export { Toast, ToastContainer } from "./Toast"
|
export { Modal } from './Modal'
|
||||||
export { Skeleton, MessageSkeleton, ConversationSkeleton, AvatarSkeleton } from "./Skeleton"
|
export {
|
||||||
export { Tooltip } from "./Tooltip"
|
AvatarSkeleton,
|
||||||
export { Dropdown } from "./Dropdown"
|
ConversationSkeleton,
|
||||||
export { IconButton } from "./IconButton"
|
MessageSkeleton,
|
||||||
|
Skeleton,
|
||||||
|
} from './Skeleton'
|
||||||
|
export { LoadingOverlay, Spinner } from './Spinner'
|
||||||
|
export { TextArea } from './TextArea'
|
||||||
|
export { Toast, ToastContainer } from './Toast'
|
||||||
|
export { Tooltip } from './Tooltip'
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,32 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// config.ts
|
// config.ts
|
||||||
// ===================
|
// ===================
|
||||||
const envApiUrl: string | undefined = import.meta.env.VITE_API_URL as string | undefined;
|
const envApiUrl: string | undefined = import.meta.env.VITE_API_URL as
|
||||||
const envWsUrl: string | undefined = import.meta.env.VITE_WS_URL as string | undefined;
|
| string
|
||||||
const envRpId: string | undefined = import.meta.env.VITE_RP_ID as string | undefined;
|
| undefined
|
||||||
|
const envWsUrl: string | undefined = import.meta.env.VITE_WS_URL as
|
||||||
|
| string
|
||||||
|
| undefined
|
||||||
|
const envRpId: string | undefined = import.meta.env.VITE_RP_ID as
|
||||||
|
| string
|
||||||
|
| undefined
|
||||||
|
|
||||||
export const API_URL = envApiUrl !== undefined && envApiUrl !== "" ? envApiUrl : "http://localhost:8000";
|
export const API_URL =
|
||||||
export const WS_URL = envWsUrl !== undefined && envWsUrl !== "" ? envWsUrl : "ws://localhost:8000";
|
envApiUrl !== undefined && envApiUrl !== ''
|
||||||
export const RP_ID = envRpId !== undefined && envRpId !== "" ? envRpId : "localhost";
|
? envApiUrl
|
||||||
|
: 'http://localhost:8000'
|
||||||
|
export const WS_URL =
|
||||||
|
envWsUrl !== undefined && envWsUrl !== '' ? envWsUrl : 'ws://localhost:8000'
|
||||||
|
export const RP_ID =
|
||||||
|
envRpId !== undefined && envRpId !== '' ? envRpId : 'localhost'
|
||||||
|
|
||||||
export const WS_HEARTBEAT_INTERVAL = 30000;
|
export const WS_HEARTBEAT_INTERVAL = 30000
|
||||||
export const WS_RECONNECT_DELAY = 5000;
|
export const WS_RECONNECT_DELAY = 5000
|
||||||
export const MESSAGE_MAX_LENGTH = 50000;
|
export const MESSAGE_MAX_LENGTH = 50000
|
||||||
export const USERNAME_MIN_LENGTH = 3;
|
export const USERNAME_MIN_LENGTH = 3
|
||||||
export const USERNAME_MAX_LENGTH = 50;
|
export const USERNAME_MAX_LENGTH = 50
|
||||||
export const DISPLAY_NAME_MIN_LENGTH = 1;
|
export const DISPLAY_NAME_MIN_LENGTH = 1
|
||||||
export const DISPLAY_NAME_MAX_LENGTH = 100;
|
export const DISPLAY_NAME_MAX_LENGTH = 100
|
||||||
|
|
||||||
export const USER_SEARCH_MIN_LENGTH = 2;
|
export const USER_SEARCH_MIN_LENGTH = 2
|
||||||
export const USER_SEARCH_DEFAULT_LIMIT = 10;
|
export const USER_SEARCH_DEFAULT_LIMIT = 10
|
||||||
|
|
|
||||||
|
|
@ -2,53 +2,54 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// crypto-service.ts
|
// crypto-service.ts
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
import { api } from '../lib/api-client'
|
||||||
import type {
|
import type {
|
||||||
IdentityKeyPair,
|
|
||||||
SignedPreKey,
|
|
||||||
OneTimePreKey,
|
|
||||||
DoubleRatchetState,
|
DoubleRatchetState,
|
||||||
EncryptedMessage,
|
EncryptedMessage,
|
||||||
X3DHHeader,
|
|
||||||
FullMessageHeader,
|
FullMessageHeader,
|
||||||
|
IdentityKeyPair,
|
||||||
MessageHeader,
|
MessageHeader,
|
||||||
} from "../types"
|
OneTimePreKey,
|
||||||
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
|
SignedPreKey,
|
||||||
|
X3DHHeader,
|
||||||
|
} from '../types'
|
||||||
|
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from '../types'
|
||||||
import {
|
import {
|
||||||
generateIdentityKeyPair,
|
|
||||||
generateSignedPreKey,
|
|
||||||
generateOneTimePreKeys,
|
|
||||||
initiateX3DH,
|
|
||||||
receiveX3DH,
|
|
||||||
} from "./x3dh"
|
|
||||||
import {
|
|
||||||
initializeRatchetSender,
|
|
||||||
initializeRatchetReceiver,
|
|
||||||
encryptMessage,
|
|
||||||
decryptMessage,
|
decryptMessage,
|
||||||
serializeRatchetState,
|
|
||||||
deserializeRatchetState,
|
deserializeRatchetState,
|
||||||
} from "./double-ratchet"
|
encryptMessage,
|
||||||
|
initializeRatchetReceiver,
|
||||||
|
initializeRatchetSender,
|
||||||
|
serializeRatchetState,
|
||||||
|
} from './double-ratchet'
|
||||||
import {
|
import {
|
||||||
saveIdentityKey,
|
|
||||||
getIdentityKey,
|
|
||||||
saveSignedPreKey,
|
|
||||||
getLatestSignedPreKey,
|
|
||||||
saveOneTimePreKeys,
|
|
||||||
getUnusedOneTimePreKeys,
|
|
||||||
getOneTimePreKeyByPublicKey,
|
|
||||||
markOneTimePreKeyUsed,
|
|
||||||
saveRatchetState,
|
|
||||||
getRatchetState,
|
|
||||||
deleteRatchetState,
|
|
||||||
clearAllKeys,
|
clearAllKeys,
|
||||||
} from "./key-store"
|
deleteRatchetState,
|
||||||
import { api } from "../lib/api-client"
|
getIdentityKey,
|
||||||
|
getLatestSignedPreKey,
|
||||||
|
getOneTimePreKeyByPublicKey,
|
||||||
|
getRatchetState,
|
||||||
|
getUnusedOneTimePreKeys,
|
||||||
|
markOneTimePreKeyUsed,
|
||||||
|
saveIdentityKey,
|
||||||
|
saveOneTimePreKeys,
|
||||||
|
saveRatchetState,
|
||||||
|
saveSignedPreKey,
|
||||||
|
} from './key-store'
|
||||||
import {
|
import {
|
||||||
base64ToBytes,
|
base64ToBytes,
|
||||||
bytesToBase64,
|
bytesToBase64,
|
||||||
importX25519PrivateKey,
|
importX25519PrivateKey,
|
||||||
importX25519PublicKey,
|
importX25519PublicKey,
|
||||||
} from "./primitives"
|
} from './primitives'
|
||||||
|
import {
|
||||||
|
generateIdentityKeyPair,
|
||||||
|
generateOneTimePreKeys,
|
||||||
|
generateSignedPreKey,
|
||||||
|
initiateX3DH,
|
||||||
|
receiveX3DH,
|
||||||
|
} from './x3dh'
|
||||||
|
|
||||||
class CryptoService {
|
class CryptoService {
|
||||||
private userId: string | null = null
|
private userId: string | null = null
|
||||||
|
|
@ -72,7 +73,10 @@ class CryptoService {
|
||||||
|
|
||||||
this.signedPreKey = await getLatestSignedPreKey(userId)
|
this.signedPreKey = await getLatestSignedPreKey(userId)
|
||||||
|
|
||||||
if (this.signedPreKey === null || this.isSignedPreKeyExpired(this.signedPreKey)) {
|
if (
|
||||||
|
this.signedPreKey === null ||
|
||||||
|
this.isSignedPreKeyExpired(this.signedPreKey)
|
||||||
|
) {
|
||||||
await this.rotateSignedPreKey()
|
await this.rotateSignedPreKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,40 +93,49 @@ class CryptoService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateAndStoreKeys(): Promise<void> {
|
private async generateAndStoreKeys(): Promise<void> {
|
||||||
if (!this.userId) throw new Error("User ID not set")
|
if (!this.userId) throw new Error('User ID not set')
|
||||||
|
|
||||||
this.identityKeyPair = await generateIdentityKeyPair()
|
this.identityKeyPair = await generateIdentityKeyPair()
|
||||||
await saveIdentityKey(this.userId, this.identityKeyPair)
|
await saveIdentityKey(this.userId, this.identityKeyPair)
|
||||||
|
|
||||||
this.signedPreKey = await generateSignedPreKey(this.identityKeyPair.ed25519_private)
|
this.signedPreKey = await generateSignedPreKey(
|
||||||
|
this.identityKeyPair.ed25519_private
|
||||||
|
)
|
||||||
await saveSignedPreKey(this.userId, this.signedPreKey)
|
await saveSignedPreKey(this.userId, this.signedPreKey)
|
||||||
|
|
||||||
const oneTimePreKeys = await generateOneTimePreKeys(DEFAULT_ONE_TIME_PREKEY_COUNT)
|
const oneTimePreKeys = await generateOneTimePreKeys(
|
||||||
|
DEFAULT_ONE_TIME_PREKEY_COUNT
|
||||||
|
)
|
||||||
await saveOneTimePreKeys(this.userId, oneTimePreKeys)
|
await saveOneTimePreKeys(this.userId, oneTimePreKeys)
|
||||||
|
|
||||||
await this.uploadPublicKeys(oneTimePreKeys)
|
await this.uploadPublicKeys(oneTimePreKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async rotateSignedPreKey(): Promise<void> {
|
private async rotateSignedPreKey(): Promise<void> {
|
||||||
if (this.userId === null || this.identityKeyPair === null) throw new Error("Not initialized")
|
if (this.userId === null || this.identityKeyPair === null)
|
||||||
|
throw new Error('Not initialized')
|
||||||
|
|
||||||
this.signedPreKey = await generateSignedPreKey(this.identityKeyPair.ed25519_private)
|
this.signedPreKey = await generateSignedPreKey(
|
||||||
|
this.identityKeyPair.ed25519_private
|
||||||
|
)
|
||||||
await saveSignedPreKey(this.userId, this.signedPreKey)
|
await saveSignedPreKey(this.userId, this.signedPreKey)
|
||||||
|
|
||||||
await api.encryption.rotateSignedPrekey(this.userId)
|
await api.encryption.rotateSignedPrekey(this.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async replenishOneTimePreKeys(): Promise<void> {
|
private async replenishOneTimePreKeys(): Promise<void> {
|
||||||
if (!this.userId) throw new Error("User ID not set")
|
if (!this.userId) throw new Error('User ID not set')
|
||||||
|
|
||||||
const newPreKeys = await generateOneTimePreKeys(DEFAULT_ONE_TIME_PREKEY_COUNT / 2)
|
const newPreKeys = await generateOneTimePreKeys(
|
||||||
|
DEFAULT_ONE_TIME_PREKEY_COUNT / 2
|
||||||
|
)
|
||||||
await saveOneTimePreKeys(this.userId, newPreKeys)
|
await saveOneTimePreKeys(this.userId, newPreKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadPublicKeys(oneTimePreKeys: OneTimePreKey[]): Promise<void> {
|
private async uploadPublicKeys(oneTimePreKeys: OneTimePreKey[]): Promise<void> {
|
||||||
if (!this.userId) throw new Error("User ID not set")
|
if (!this.userId) throw new Error('User ID not set')
|
||||||
if (!this.identityKeyPair || !this.signedPreKey) {
|
if (!this.identityKeyPair || !this.signedPreKey) {
|
||||||
throw new Error("Keys not generated")
|
throw new Error('Keys not generated')
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.encryption.uploadKeys(this.userId, {
|
await api.encryption.uploadKeys(this.userId, {
|
||||||
|
|
@ -135,7 +148,8 @@ class CryptoService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async establishSession(peerId: string): Promise<void> {
|
async establishSession(peerId: string): Promise<void> {
|
||||||
if (this.identityKeyPair === null) throw new Error("Identity keys not initialized")
|
if (this.identityKeyPair === null)
|
||||||
|
throw new Error('Identity keys not initialized')
|
||||||
|
|
||||||
const existingState = await this.getRatchetState(peerId)
|
const existingState = await this.getRatchetState(peerId)
|
||||||
if (existingState !== null) return
|
if (existingState !== null) return
|
||||||
|
|
@ -157,7 +171,9 @@ class CryptoService {
|
||||||
const x3dhHeader: X3DHHeader = {
|
const x3dhHeader: X3DHHeader = {
|
||||||
identity_key: this.identityKeyPair.x25519_public,
|
identity_key: this.identityKeyPair.x25519_public,
|
||||||
ephemeral_key: x3dhResult.ephemeral_public_key,
|
ephemeral_key: x3dhResult.ephemeral_public_key,
|
||||||
one_time_prekey_id: x3dhResult.used_one_time_prekey ? peerBundle.one_time_prekey : null,
|
one_time_prekey_id: x3dhResult.used_one_time_prekey
|
||||||
|
? peerBundle.one_time_prekey
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
this.pendingX3DHHeaders.set(peerId, x3dhHeader)
|
this.pendingX3DHHeaders.set(peerId, x3dhHeader)
|
||||||
|
|
||||||
|
|
@ -172,7 +188,7 @@ class CryptoService {
|
||||||
oneTimePreKeyPublic: string | null
|
oneTimePreKeyPublic: string | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.identityKeyPair === null || this.signedPreKey === null) {
|
if (this.identityKeyPair === null || this.signedPreKey === null) {
|
||||||
throw new Error("Keys not initialized")
|
throw new Error('Keys not initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
let oneTimePreKey: OneTimePreKey | null = null
|
let oneTimePreKey: OneTimePreKey | null = null
|
||||||
|
|
@ -214,7 +230,10 @@ class CryptoService {
|
||||||
await saveRatchetState(serialized)
|
await saveRatchetState(serialized)
|
||||||
}
|
}
|
||||||
|
|
||||||
async encrypt(peerId: string, plaintext: string): Promise<{
|
async encrypt(
|
||||||
|
peerId: string,
|
||||||
|
plaintext: string
|
||||||
|
): Promise<{
|
||||||
ciphertext: string
|
ciphertext: string
|
||||||
nonce: string
|
nonce: string
|
||||||
header: string
|
header: string
|
||||||
|
|
@ -261,7 +280,7 @@ class CryptoService {
|
||||||
let ratchetHeader: MessageHeader
|
let ratchetHeader: MessageHeader
|
||||||
let x3dhHeader: X3DHHeader | undefined
|
let x3dhHeader: X3DHHeader | undefined
|
||||||
|
|
||||||
if ("ratchet" in parsedHeader) {
|
if ('ratchet' in parsedHeader) {
|
||||||
ratchetHeader = parsedHeader.ratchet
|
ratchetHeader = parsedHeader.ratchet
|
||||||
x3dhHeader = parsedHeader.x3dh
|
x3dhHeader = parsedHeader.x3dh
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -276,7 +295,7 @@ class CryptoService {
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
if (!x3dhHeader) {
|
if (!x3dhHeader) {
|
||||||
throw new Error("Cannot establish session: missing X3DH header")
|
throw new Error('Cannot establish session: missing X3DH header')
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.handleIncomingSession(
|
await this.handleIncomingSession(
|
||||||
|
|
@ -289,7 +308,7 @@ class CryptoService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
throw new Error("Failed to establish session")
|
throw new Error('Failed to establish session')
|
||||||
}
|
}
|
||||||
|
|
||||||
const plaintextBytes = await decryptMessage(state, encryptedMessage)
|
const plaintextBytes = await decryptMessage(state, encryptedMessage)
|
||||||
|
|
@ -300,7 +319,9 @@ class CryptoService {
|
||||||
return new TextDecoder().decode(plaintextBytes)
|
return new TextDecoder().decode(plaintextBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getRatchetState(peerId: string): Promise<DoubleRatchetState | null> {
|
private async getRatchetState(
|
||||||
|
peerId: string
|
||||||
|
): Promise<DoubleRatchetState | null> {
|
||||||
let state = this.ratchetStates.get(peerId)
|
let state = this.ratchetStates.get(peerId)
|
||||||
|
|
||||||
if (state === undefined) {
|
if (state === undefined) {
|
||||||
|
|
@ -337,18 +358,19 @@ class CryptoService {
|
||||||
this.ratchetStates.clear()
|
this.ratchetStates.clear()
|
||||||
this.pendingX3DHHeaders.clear()
|
this.pendingX3DHHeaders.clear()
|
||||||
|
|
||||||
const database = indexedDB.open("encrypted-chat-keys", 1)
|
const database = indexedDB.open('encrypted-chat-keys', 1)
|
||||||
database.onsuccess = () => {
|
database.onsuccess = () => {
|
||||||
const db = database.result
|
const db = database.result
|
||||||
const tx = db.transaction("ratchet_states", "readwrite")
|
const tx = db.transaction('ratchet_states', 'readwrite')
|
||||||
tx.objectStore("ratchet_states").clear()
|
tx.objectStore('ratchet_states').clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cryptoService = new CryptoService()
|
export const cryptoService = new CryptoService()
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== 'undefined') {
|
||||||
(window as unknown as { resetCryptoSessions: () => Promise<void> }).resetCryptoSessions =
|
;(
|
||||||
() => cryptoService.resetAllSessions()
|
window as unknown as { resetCryptoSessions: () => Promise<void> }
|
||||||
|
).resetCryptoSessions = () => cryptoService.resetAllSessions()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,32 +4,35 @@
|
||||||
// ===================
|
// ===================
|
||||||
import type {
|
import type {
|
||||||
DoubleRatchetState,
|
DoubleRatchetState,
|
||||||
SerializedRatchetState,
|
|
||||||
EncryptedMessage,
|
EncryptedMessage,
|
||||||
MessageHeader,
|
MessageHeader,
|
||||||
} from "../types"
|
SerializedRatchetState,
|
||||||
import { MAX_SKIP_MESSAGE_KEYS } from "../types"
|
} from '../types'
|
||||||
|
import { MAX_SKIP_MESSAGE_KEYS } from '../types'
|
||||||
import {
|
import {
|
||||||
generateX25519KeyPair,
|
|
||||||
x25519DeriveSharedSecret,
|
|
||||||
importX25519PublicKey,
|
|
||||||
importX25519PrivateKey,
|
|
||||||
exportPublicKey,
|
|
||||||
exportPrivateKey,
|
|
||||||
hkdfDerive,
|
|
||||||
aesGcmEncrypt,
|
|
||||||
aesGcmDecrypt,
|
aesGcmDecrypt,
|
||||||
hmacSha256,
|
aesGcmEncrypt,
|
||||||
concatBytes,
|
|
||||||
bytesToBase64,
|
|
||||||
base64ToBytes,
|
base64ToBytes,
|
||||||
} from "./primitives"
|
bytesToBase64,
|
||||||
|
concatBytes,
|
||||||
|
exportPrivateKey,
|
||||||
|
exportPublicKey,
|
||||||
|
generateX25519KeyPair,
|
||||||
|
hkdfDerive,
|
||||||
|
hmacSha256,
|
||||||
|
importX25519PrivateKey,
|
||||||
|
importX25519PublicKey,
|
||||||
|
x25519DeriveSharedSecret,
|
||||||
|
} from './primitives'
|
||||||
|
|
||||||
const RATCHET_INFO = new TextEncoder().encode("DoubleRatchet")
|
const RATCHET_INFO = new TextEncoder().encode('DoubleRatchet')
|
||||||
const MESSAGE_KEY_INFO = new TextEncoder().encode("MessageKey")
|
const MESSAGE_KEY_INFO = new TextEncoder().encode('MessageKey')
|
||||||
const CHAIN_KEY_INFO = new TextEncoder().encode("ChainKey")
|
const CHAIN_KEY_INFO = new TextEncoder().encode('ChainKey')
|
||||||
|
|
||||||
function createSkippedKeyId(dhPublicKey: Uint8Array, messageNumber: number): string {
|
function createSkippedKeyId(
|
||||||
|
dhPublicKey: Uint8Array,
|
||||||
|
messageNumber: number
|
||||||
|
): string {
|
||||||
return `${bytesToBase64(dhPublicKey)}:${messageNumber}`
|
return `${bytesToBase64(dhPublicKey)}:${messageNumber}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +48,12 @@ export async function initializeRatchetSender(
|
||||||
const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey)
|
const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey)
|
||||||
|
|
||||||
const rootChainInput = concatBytes(sharedKey, dhOutput)
|
const rootChainInput = concatBytes(sharedKey, dhOutput)
|
||||||
const derivedKeys = await hkdfDerive(rootChainInput, new Uint8Array(32), RATCHET_INFO, 64)
|
const derivedKeys = await hkdfDerive(
|
||||||
|
rootChainInput,
|
||||||
|
new Uint8Array(32),
|
||||||
|
RATCHET_INFO,
|
||||||
|
64
|
||||||
|
)
|
||||||
|
|
||||||
const rootKey = derivedKeys.slice(0, 32)
|
const rootKey = derivedKeys.slice(0, 32)
|
||||||
const sendingChainKey = derivedKeys.slice(32, 64)
|
const sendingChainKey = derivedKeys.slice(32, 64)
|
||||||
|
|
@ -105,7 +113,7 @@ async function performDHRatchet(
|
||||||
peerPublicKey: Uint8Array
|
peerPublicKey: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (state.dh_private_key === null) {
|
if (state.dh_private_key === null) {
|
||||||
throw new Error("DH private key not initialized")
|
throw new Error('DH private key not initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
state.previous_sending_chain_length = state.sending_message_number
|
state.previous_sending_chain_length = state.sending_message_number
|
||||||
|
|
@ -114,10 +122,18 @@ async function performDHRatchet(
|
||||||
state.dh_peer_public_key = peerPublicKey
|
state.dh_peer_public_key = peerPublicKey
|
||||||
|
|
||||||
const peerKey = await importX25519PublicKey(peerPublicKey)
|
const peerKey = await importX25519PublicKey(peerPublicKey)
|
||||||
const dhOutput = await x25519DeriveSharedSecret(state.dh_private_key.privateKey, peerKey)
|
const dhOutput = await x25519DeriveSharedSecret(
|
||||||
|
state.dh_private_key.privateKey,
|
||||||
|
peerKey
|
||||||
|
)
|
||||||
|
|
||||||
const rootChainInput = concatBytes(state.root_key, dhOutput)
|
const rootChainInput = concatBytes(state.root_key, dhOutput)
|
||||||
const derivedKeys = await hkdfDerive(rootChainInput, new Uint8Array(32), RATCHET_INFO, 64)
|
const derivedKeys = await hkdfDerive(
|
||||||
|
rootChainInput,
|
||||||
|
new Uint8Array(32),
|
||||||
|
RATCHET_INFO,
|
||||||
|
64
|
||||||
|
)
|
||||||
|
|
||||||
state.root_key = derivedKeys.slice(0, 32)
|
state.root_key = derivedKeys.slice(0, 32)
|
||||||
state.receiving_chain_key = derivedKeys.slice(32, 64)
|
state.receiving_chain_key = derivedKeys.slice(32, 64)
|
||||||
|
|
@ -126,9 +142,17 @@ async function performDHRatchet(
|
||||||
state.dh_private_key = newDHKeyPair
|
state.dh_private_key = newDHKeyPair
|
||||||
state.dh_public_key = await exportPublicKey(newDHKeyPair.publicKey)
|
state.dh_public_key = await exportPublicKey(newDHKeyPair.publicKey)
|
||||||
|
|
||||||
const newDHOutput = await x25519DeriveSharedSecret(newDHKeyPair.privateKey, peerKey)
|
const newDHOutput = await x25519DeriveSharedSecret(
|
||||||
|
newDHKeyPair.privateKey,
|
||||||
|
peerKey
|
||||||
|
)
|
||||||
const newRootChainInput = concatBytes(state.root_key, newDHOutput)
|
const newRootChainInput = concatBytes(state.root_key, newDHOutput)
|
||||||
const newDerivedKeys = await hkdfDerive(newRootChainInput, new Uint8Array(32), RATCHET_INFO, 64)
|
const newDerivedKeys = await hkdfDerive(
|
||||||
|
newRootChainInput,
|
||||||
|
new Uint8Array(32),
|
||||||
|
RATCHET_INFO,
|
||||||
|
64
|
||||||
|
)
|
||||||
|
|
||||||
state.root_key = newDerivedKeys.slice(0, 32)
|
state.root_key = newDerivedKeys.slice(0, 32)
|
||||||
state.sending_chain_key = newDerivedKeys.slice(32, 64)
|
state.sending_chain_key = newDerivedKeys.slice(32, 64)
|
||||||
|
|
@ -138,16 +162,22 @@ async function skipMessageKeys(
|
||||||
state: DoubleRatchetState,
|
state: DoubleRatchetState,
|
||||||
until: number
|
until: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (state.receiving_chain_key === null || state.dh_peer_public_key === null) return
|
if (state.receiving_chain_key === null || state.dh_peer_public_key === null)
|
||||||
|
return
|
||||||
|
|
||||||
if (until - state.receiving_message_number > MAX_SKIP_MESSAGE_KEYS) {
|
if (until - state.receiving_message_number > MAX_SKIP_MESSAGE_KEYS) {
|
||||||
throw new Error("Too many skipped messages")
|
throw new Error('Too many skipped messages')
|
||||||
}
|
}
|
||||||
|
|
||||||
while (state.receiving_message_number < until) {
|
while (state.receiving_message_number < until) {
|
||||||
const { messageKey, nextChainKey } = await deriveMessageKey(state.receiving_chain_key)
|
const { messageKey, nextChainKey } = await deriveMessageKey(
|
||||||
|
state.receiving_chain_key
|
||||||
|
)
|
||||||
|
|
||||||
const keyId = createSkippedKeyId(state.dh_peer_public_key, state.receiving_message_number)
|
const keyId = createSkippedKeyId(
|
||||||
|
state.dh_peer_public_key,
|
||||||
|
state.receiving_message_number
|
||||||
|
)
|
||||||
state.skipped_message_keys.set(keyId, messageKey)
|
state.skipped_message_keys.set(keyId, messageKey)
|
||||||
|
|
||||||
state.receiving_chain_key = nextChainKey
|
state.receiving_chain_key = nextChainKey
|
||||||
|
|
@ -161,10 +191,12 @@ export async function encryptMessage(
|
||||||
associatedData?: Uint8Array
|
associatedData?: Uint8Array
|
||||||
): Promise<EncryptedMessage> {
|
): Promise<EncryptedMessage> {
|
||||||
if (state.dh_public_key === null) {
|
if (state.dh_public_key === null) {
|
||||||
throw new Error("DH public key not initialized")
|
throw new Error('DH public key not initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messageKey, nextChainKey } = await deriveMessageKey(state.sending_chain_key)
|
const { messageKey, nextChainKey } = await deriveMessageKey(
|
||||||
|
state.sending_chain_key
|
||||||
|
)
|
||||||
|
|
||||||
const header: MessageHeader = {
|
const header: MessageHeader = {
|
||||||
dh_public_key: bytesToBase64(state.dh_public_key),
|
dh_public_key: bytesToBase64(state.dh_public_key),
|
||||||
|
|
@ -173,7 +205,10 @@ export async function encryptMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerBytes = new TextEncoder().encode(JSON.stringify(header))
|
const headerBytes = new TextEncoder().encode(JSON.stringify(header))
|
||||||
const aad = associatedData !== undefined ? concatBytes(associatedData, headerBytes) : headerBytes
|
const aad =
|
||||||
|
associatedData !== undefined
|
||||||
|
? concatBytes(associatedData, headerBytes)
|
||||||
|
: headerBytes
|
||||||
|
|
||||||
const { ciphertext, nonce } = await aesGcmEncrypt(messageKey, plaintext, aad)
|
const { ciphertext, nonce } = await aesGcmEncrypt(messageKey, plaintext, aad)
|
||||||
|
|
||||||
|
|
@ -194,14 +229,20 @@ export async function decryptMessage(
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const peerPublicKey = base64ToBytes(message.header.dh_public_key)
|
const peerPublicKey = base64ToBytes(message.header.dh_public_key)
|
||||||
|
|
||||||
const skippedKeyId = createSkippedKeyId(peerPublicKey, message.header.message_number)
|
const skippedKeyId = createSkippedKeyId(
|
||||||
|
peerPublicKey,
|
||||||
|
message.header.message_number
|
||||||
|
)
|
||||||
const skippedKey = state.skipped_message_keys.get(skippedKeyId)
|
const skippedKey = state.skipped_message_keys.get(skippedKeyId)
|
||||||
|
|
||||||
if (skippedKey !== undefined) {
|
if (skippedKey !== undefined) {
|
||||||
state.skipped_message_keys.delete(skippedKeyId)
|
state.skipped_message_keys.delete(skippedKeyId)
|
||||||
|
|
||||||
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header))
|
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header))
|
||||||
const aad = associatedData !== undefined ? concatBytes(associatedData, headerBytes) : headerBytes
|
const aad =
|
||||||
|
associatedData !== undefined
|
||||||
|
? concatBytes(associatedData, headerBytes)
|
||||||
|
: headerBytes
|
||||||
|
|
||||||
return await aesGcmDecrypt(skippedKey, message.ciphertext, message.nonce, aad)
|
return await aesGcmDecrypt(skippedKey, message.ciphertext, message.nonce, aad)
|
||||||
}
|
}
|
||||||
|
|
@ -221,15 +262,25 @@ export async function decryptMessage(
|
||||||
await skipMessageKeys(state, message.header.message_number)
|
await skipMessageKeys(state, message.header.message_number)
|
||||||
|
|
||||||
if (state.receiving_chain_key === null) {
|
if (state.receiving_chain_key === null) {
|
||||||
throw new Error("Receiving chain key not initialized after ratchet")
|
throw new Error('Receiving chain key not initialized after ratchet')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messageKey, nextChainKey } = await deriveMessageKey(state.receiving_chain_key)
|
const { messageKey, nextChainKey } = await deriveMessageKey(
|
||||||
|
state.receiving_chain_key
|
||||||
|
)
|
||||||
|
|
||||||
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header))
|
const headerBytes = new TextEncoder().encode(JSON.stringify(message.header))
|
||||||
const aad = associatedData !== undefined ? concatBytes(associatedData, headerBytes) : headerBytes
|
const aad =
|
||||||
|
associatedData !== undefined
|
||||||
|
? concatBytes(associatedData, headerBytes)
|
||||||
|
: headerBytes
|
||||||
|
|
||||||
const plaintext = await aesGcmDecrypt(messageKey, message.ciphertext, message.nonce, aad)
|
const plaintext = await aesGcmDecrypt(
|
||||||
|
messageKey,
|
||||||
|
message.ciphertext,
|
||||||
|
message.nonce,
|
||||||
|
aad
|
||||||
|
)
|
||||||
|
|
||||||
state.receiving_chain_key = nextChainKey
|
state.receiving_chain_key = nextChainKey
|
||||||
state.receiving_message_number++
|
state.receiving_message_number++
|
||||||
|
|
@ -242,7 +293,9 @@ export async function serializeRatchetState(
|
||||||
): Promise<SerializedRatchetState> {
|
): Promise<SerializedRatchetState> {
|
||||||
let dhPrivateKey: string | null = null
|
let dhPrivateKey: string | null = null
|
||||||
if (state.dh_private_key !== null) {
|
if (state.dh_private_key !== null) {
|
||||||
const privateKeyBytes = await exportPrivateKey(state.dh_private_key.privateKey)
|
const privateKeyBytes = await exportPrivateKey(
|
||||||
|
state.dh_private_key.privateKey
|
||||||
|
)
|
||||||
dhPrivateKey = bytesToBase64(privateKeyBytes)
|
dhPrivateKey = bytesToBase64(privateKeyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,14 +308,17 @@ export async function serializeRatchetState(
|
||||||
peer_id: state.peer_id,
|
peer_id: state.peer_id,
|
||||||
root_key: bytesToBase64(state.root_key),
|
root_key: bytesToBase64(state.root_key),
|
||||||
sending_chain_key: bytesToBase64(state.sending_chain_key),
|
sending_chain_key: bytesToBase64(state.sending_chain_key),
|
||||||
receiving_chain_key: state.receiving_chain_key !== null
|
receiving_chain_key:
|
||||||
? bytesToBase64(state.receiving_chain_key)
|
state.receiving_chain_key !== null
|
||||||
: null,
|
? bytesToBase64(state.receiving_chain_key)
|
||||||
|
: null,
|
||||||
dh_private_key: dhPrivateKey,
|
dh_private_key: dhPrivateKey,
|
||||||
dh_public_key: state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null,
|
dh_public_key:
|
||||||
dh_peer_public_key: state.dh_peer_public_key !== null
|
state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null,
|
||||||
? bytesToBase64(state.dh_peer_public_key)
|
dh_peer_public_key:
|
||||||
: null,
|
state.dh_peer_public_key !== null
|
||||||
|
? bytesToBase64(state.dh_peer_public_key)
|
||||||
|
: null,
|
||||||
sending_message_number: state.sending_message_number,
|
sending_message_number: state.sending_message_number,
|
||||||
receiving_message_number: state.receiving_message_number,
|
receiving_message_number: state.receiving_message_number,
|
||||||
previous_sending_chain_length: state.previous_sending_chain_length,
|
previous_sending_chain_length: state.previous_sending_chain_length,
|
||||||
|
|
@ -332,7 +388,9 @@ interface SerializedEncryptedMessage {
|
||||||
header: MessageHeader
|
header: MessageHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deserializeEncryptedMessage(serialized: string): EncryptedMessage {
|
export function deserializeEncryptedMessage(
|
||||||
|
serialized: string
|
||||||
|
): EncryptedMessage {
|
||||||
const parsed = JSON.parse(serialized) as SerializedEncryptedMessage
|
const parsed = JSON.parse(serialized) as SerializedEncryptedMessage
|
||||||
return {
|
return {
|
||||||
ciphertext: base64ToBytes(parsed.ciphertext),
|
ciphertext: base64ToBytes(parsed.ciphertext),
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export * from "./primitives"
|
|
||||||
export * from "./key-store"
|
export * from './crypto-service'
|
||||||
export * from "./message-store"
|
export * from './double-ratchet'
|
||||||
export * from "./x3dh"
|
export * from './key-store'
|
||||||
export * from "./double-ratchet"
|
export * from './message-store'
|
||||||
export * from "./crypto-service"
|
export * from './primitives'
|
||||||
|
export * from './x3dh'
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,19 @@
|
||||||
// ===================
|
// ===================
|
||||||
import type {
|
import type {
|
||||||
IdentityKeyPair,
|
IdentityKeyPair,
|
||||||
SignedPreKey,
|
|
||||||
OneTimePreKey,
|
OneTimePreKey,
|
||||||
SerializedRatchetState,
|
SerializedRatchetState,
|
||||||
} from "../types"
|
SignedPreKey,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
const DB_NAME = "encrypted-chat-keys"
|
const DB_NAME = 'encrypted-chat-keys'
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 1
|
||||||
|
|
||||||
const STORES = {
|
const STORES = {
|
||||||
IDENTITY: "identity_keys",
|
IDENTITY: 'identity_keys',
|
||||||
SIGNED_PREKEYS: "signed_prekeys",
|
SIGNED_PREKEYS: 'signed_prekeys',
|
||||||
ONE_TIME_PREKEYS: "one_time_prekeys",
|
ONE_TIME_PREKEYS: 'one_time_prekeys',
|
||||||
RATCHET_STATES: "ratchet_states",
|
RATCHET_STATES: 'ratchet_states',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
let db: IDBDatabase | null = null
|
let db: IDBDatabase | null = null
|
||||||
|
|
@ -28,7 +28,7 @@ async function openDatabase(): Promise<IDBDatabase> {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
|
||||||
request.onerror = () => {
|
request.onerror = () => {
|
||||||
reject(new Error("Failed to open key database"))
|
reject(new Error('Failed to open key database'))
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
|
@ -40,22 +40,26 @@ async function openDatabase(): Promise<IDBDatabase> {
|
||||||
const database = (event.target as IDBOpenDBRequest).result
|
const database = (event.target as IDBOpenDBRequest).result
|
||||||
|
|
||||||
if (!database.objectStoreNames.contains(STORES.IDENTITY)) {
|
if (!database.objectStoreNames.contains(STORES.IDENTITY)) {
|
||||||
database.createObjectStore(STORES.IDENTITY, { keyPath: "userId" })
|
database.createObjectStore(STORES.IDENTITY, { keyPath: 'userId' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!database.objectStoreNames.contains(STORES.SIGNED_PREKEYS)) {
|
if (!database.objectStoreNames.contains(STORES.SIGNED_PREKEYS)) {
|
||||||
const signedStore = database.createObjectStore(STORES.SIGNED_PREKEYS, { keyPath: "id" })
|
const signedStore = database.createObjectStore(STORES.SIGNED_PREKEYS, {
|
||||||
signedStore.createIndex("userId", "userId", { unique: false })
|
keyPath: 'id',
|
||||||
|
})
|
||||||
|
signedStore.createIndex('userId', 'userId', { unique: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!database.objectStoreNames.contains(STORES.ONE_TIME_PREKEYS)) {
|
if (!database.objectStoreNames.contains(STORES.ONE_TIME_PREKEYS)) {
|
||||||
const otpStore = database.createObjectStore(STORES.ONE_TIME_PREKEYS, { keyPath: "id" })
|
const otpStore = database.createObjectStore(STORES.ONE_TIME_PREKEYS, {
|
||||||
otpStore.createIndex("userId", "userId", { unique: false })
|
keyPath: 'id',
|
||||||
otpStore.createIndex("isUsed", "is_used", { unique: false })
|
})
|
||||||
|
otpStore.createIndex('userId', 'userId', { unique: false })
|
||||||
|
otpStore.createIndex('isUsed', 'is_used', { unique: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!database.objectStoreNames.contains(STORES.RATCHET_STATES)) {
|
if (!database.objectStoreNames.contains(STORES.RATCHET_STATES)) {
|
||||||
database.createObjectStore(STORES.RATCHET_STATES, { keyPath: "peer_id" })
|
database.createObjectStore(STORES.RATCHET_STATES, { keyPath: 'peer_id' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -74,7 +78,8 @@ async function performTransaction<T>(
|
||||||
const request = operation(store)
|
const request = operation(store)
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result)
|
request.onsuccess = () => resolve(request.result)
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Database operation failed"))
|
request.onerror = () =>
|
||||||
|
reject(new Error(request.error?.message ?? 'Database operation failed'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,17 +96,17 @@ export async function saveIdentityKey(
|
||||||
...keyPair,
|
...keyPair,
|
||||||
}
|
}
|
||||||
|
|
||||||
await performTransaction(
|
await performTransaction(STORES.IDENTITY, 'readwrite', (store) =>
|
||||||
STORES.IDENTITY,
|
store.put(stored)
|
||||||
"readwrite",
|
|
||||||
(store) => store.put(stored)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getIdentityKey(userId: string): Promise<IdentityKeyPair | null> {
|
export async function getIdentityKey(
|
||||||
|
userId: string
|
||||||
|
): Promise<IdentityKeyPair | null> {
|
||||||
const result = await performTransaction<StoredIdentityKey | undefined>(
|
const result = await performTransaction<StoredIdentityKey | undefined>(
|
||||||
STORES.IDENTITY,
|
STORES.IDENTITY,
|
||||||
"readonly",
|
'readonly',
|
||||||
(store) => store.get(userId) as IDBRequest<StoredIdentityKey | undefined>
|
(store) => store.get(userId) as IDBRequest<StoredIdentityKey | undefined>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -116,10 +121,8 @@ export async function getIdentityKey(userId: string): Promise<IdentityKeyPair |
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteIdentityKey(userId: string): Promise<void> {
|
export async function deleteIdentityKey(userId: string): Promise<void> {
|
||||||
await performTransaction(
|
await performTransaction(STORES.IDENTITY, 'readwrite', (store) =>
|
||||||
STORES.IDENTITY,
|
store.delete(userId)
|
||||||
"readwrite",
|
|
||||||
(store) => store.delete(userId)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,17 +139,15 @@ export async function saveSignedPreKey(
|
||||||
...preKey,
|
...preKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
await performTransaction(
|
await performTransaction(STORES.SIGNED_PREKEYS, 'readwrite', (store) =>
|
||||||
STORES.SIGNED_PREKEYS,
|
store.put(stored)
|
||||||
"readwrite",
|
|
||||||
(store) => store.put(stored)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSignedPreKey(id: string): Promise<SignedPreKey | null> {
|
export async function getSignedPreKey(id: string): Promise<SignedPreKey | null> {
|
||||||
const result = await performTransaction<StoredSignedPreKey | undefined>(
|
const result = await performTransaction<StoredSignedPreKey | undefined>(
|
||||||
STORES.SIGNED_PREKEYS,
|
STORES.SIGNED_PREKEYS,
|
||||||
"readonly",
|
'readonly',
|
||||||
(store) => store.get(id) as IDBRequest<StoredSignedPreKey | undefined>
|
(store) => store.get(id) as IDBRequest<StoredSignedPreKey | undefined>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -162,13 +163,15 @@ export async function getSignedPreKey(id: string): Promise<SignedPreKey | null>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestSignedPreKey(userId: string): Promise<SignedPreKey | null> {
|
export async function getLatestSignedPreKey(
|
||||||
|
userId: string
|
||||||
|
): Promise<SignedPreKey | null> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.SIGNED_PREKEYS, "readonly")
|
const transaction = database.transaction(STORES.SIGNED_PREKEYS, 'readonly')
|
||||||
const store = transaction.objectStore(STORES.SIGNED_PREKEYS)
|
const store = transaction.objectStore(STORES.SIGNED_PREKEYS)
|
||||||
const index = store.index("userId")
|
const index = store.index('userId')
|
||||||
const request = index.getAll(userId)
|
const request = index.getAll(userId)
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
|
@ -179,7 +182,8 @@ export async function getLatestSignedPreKey(userId: string): Promise<SignedPreKe
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = results.sort(
|
const sorted = results.sort(
|
||||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
(a, b) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
const latest = sorted[0]
|
const latest = sorted[0]
|
||||||
|
|
@ -193,15 +197,14 @@ export async function getLatestSignedPreKey(userId: string): Promise<SignedPreKe
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get signed prekey"))
|
request.onerror = () =>
|
||||||
|
reject(new Error(request.error?.message ?? 'Failed to get signed prekey'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSignedPreKey(id: string): Promise<void> {
|
export async function deleteSignedPreKey(id: string): Promise<void> {
|
||||||
await performTransaction(
|
await performTransaction(STORES.SIGNED_PREKEYS, 'readwrite', (store) =>
|
||||||
STORES.SIGNED_PREKEYS,
|
store.delete(id)
|
||||||
"readwrite",
|
|
||||||
(store) => store.delete(id)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,10 +221,8 @@ export async function saveOneTimePreKey(
|
||||||
...preKey,
|
...preKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
await performTransaction(
|
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
|
||||||
STORES.ONE_TIME_PREKEYS,
|
store.put(stored)
|
||||||
"readwrite",
|
|
||||||
(store) => store.put(stored)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,11 +233,14 @@ export async function saveOneTimePreKeys(
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, "readwrite")
|
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, 'readwrite')
|
||||||
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
||||||
|
|
||||||
transaction.oncomplete = () => resolve()
|
transaction.oncomplete = () => resolve()
|
||||||
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to save one-time prekeys"))
|
transaction.onerror = () =>
|
||||||
|
reject(
|
||||||
|
new Error(transaction.error?.message ?? 'Failed to save one-time prekeys')
|
||||||
|
)
|
||||||
|
|
||||||
for (const preKey of preKeys) {
|
for (const preKey of preKeys) {
|
||||||
const stored: StoredOneTimePreKey = {
|
const stored: StoredOneTimePreKey = {
|
||||||
|
|
@ -248,10 +252,12 @@ export async function saveOneTimePreKeys(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOneTimePreKey(id: string): Promise<OneTimePreKey | null> {
|
export async function getOneTimePreKey(
|
||||||
|
id: string
|
||||||
|
): Promise<OneTimePreKey | null> {
|
||||||
const result = await performTransaction<StoredOneTimePreKey | undefined>(
|
const result = await performTransaction<StoredOneTimePreKey | undefined>(
|
||||||
STORES.ONE_TIME_PREKEYS,
|
STORES.ONE_TIME_PREKEYS,
|
||||||
"readonly",
|
'readonly',
|
||||||
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined>
|
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -266,11 +272,13 @@ export async function getOneTimePreKey(id: string): Promise<OneTimePreKey | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOneTimePreKeyByPublicKey(publicKey: string): Promise<OneTimePreKey | null> {
|
export async function getOneTimePreKeyByPublicKey(
|
||||||
|
publicKey: string
|
||||||
|
): Promise<OneTimePreKey | null> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, "readonly")
|
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, 'readonly')
|
||||||
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
||||||
const request = store.getAll()
|
const request = store.getAll()
|
||||||
|
|
||||||
|
|
@ -292,17 +300,22 @@ export async function getOneTimePreKeyByPublicKey(publicKey: string): Promise<On
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to find one-time prekey"))
|
request.onerror = () =>
|
||||||
|
reject(
|
||||||
|
new Error(request.error?.message ?? 'Failed to find one-time prekey')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePreKey[]> {
|
export async function getUnusedOneTimePreKeys(
|
||||||
|
userId: string
|
||||||
|
): Promise<OneTimePreKey[]> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, "readonly")
|
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, 'readonly')
|
||||||
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
||||||
const index = store.index("userId")
|
const index = store.index('userId')
|
||||||
const request = index.getAll(userId)
|
const request = index.getAll(userId)
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
|
@ -320,14 +333,19 @@ export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePr
|
||||||
resolve(unused)
|
resolve(unused)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get unused one-time prekeys"))
|
request.onerror = () =>
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
request.error?.message ?? 'Failed to get unused one-time prekeys'
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markOneTimePreKeyUsed(id: string): Promise<void> {
|
export async function markOneTimePreKeyUsed(id: string): Promise<void> {
|
||||||
const preKey = await performTransaction<StoredOneTimePreKey | undefined>(
|
const preKey = await performTransaction<StoredOneTimePreKey | undefined>(
|
||||||
STORES.ONE_TIME_PREKEYS,
|
STORES.ONE_TIME_PREKEYS,
|
||||||
"readonly",
|
'readonly',
|
||||||
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined>
|
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -335,33 +353,31 @@ export async function markOneTimePreKeyUsed(id: string): Promise<void> {
|
||||||
|
|
||||||
preKey.is_used = true
|
preKey.is_used = true
|
||||||
|
|
||||||
await performTransaction(
|
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
|
||||||
STORES.ONE_TIME_PREKEYS,
|
store.put(preKey)
|
||||||
"readwrite",
|
|
||||||
(store) => store.put(preKey)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteOneTimePreKey(id: string): Promise<void> {
|
export async function deleteOneTimePreKey(id: string): Promise<void> {
|
||||||
await performTransaction(
|
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
|
||||||
STORES.ONE_TIME_PREKEYS,
|
store.delete(id)
|
||||||
"readwrite",
|
|
||||||
(store) => store.delete(id)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveRatchetState(state: SerializedRatchetState): Promise<void> {
|
export async function saveRatchetState(
|
||||||
await performTransaction(
|
state: SerializedRatchetState
|
||||||
STORES.RATCHET_STATES,
|
): Promise<void> {
|
||||||
"readwrite",
|
await performTransaction(STORES.RATCHET_STATES, 'readwrite', (store) =>
|
||||||
(store) => store.put(state)
|
store.put(state)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRatchetState(peerId: string): Promise<SerializedRatchetState | null> {
|
export async function getRatchetState(
|
||||||
|
peerId: string
|
||||||
|
): Promise<SerializedRatchetState | null> {
|
||||||
const result = await performTransaction<SerializedRatchetState | undefined>(
|
const result = await performTransaction<SerializedRatchetState | undefined>(
|
||||||
STORES.RATCHET_STATES,
|
STORES.RATCHET_STATES,
|
||||||
"readonly",
|
'readonly',
|
||||||
(store) => store.get(peerId) as IDBRequest<SerializedRatchetState | undefined>
|
(store) => store.get(peerId) as IDBRequest<SerializedRatchetState | undefined>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -369,10 +385,8 @@ export async function getRatchetState(peerId: string): Promise<SerializedRatchet
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRatchetState(peerId: string): Promise<void> {
|
export async function deleteRatchetState(peerId: string): Promise<void> {
|
||||||
await performTransaction(
|
await performTransaction(STORES.RATCHET_STATES, 'readwrite', (store) =>
|
||||||
STORES.RATCHET_STATES,
|
store.delete(peerId)
|
||||||
"readwrite",
|
|
||||||
(store) => store.delete(peerId)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,12 +394,15 @@ export async function getAllRatchetStates(): Promise<SerializedRatchetState[]> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.RATCHET_STATES, "readonly")
|
const transaction = database.transaction(STORES.RATCHET_STATES, 'readonly')
|
||||||
const store = transaction.objectStore(STORES.RATCHET_STATES)
|
const store = transaction.objectStore(STORES.RATCHET_STATES)
|
||||||
const request = store.getAll()
|
const request = store.getAll()
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result as SerializedRatchetState[])
|
request.onsuccess = () => resolve(request.result as SerializedRatchetState[])
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get all ratchet states"))
|
request.onerror = () =>
|
||||||
|
reject(
|
||||||
|
new Error(request.error?.message ?? 'Failed to get all ratchet states')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,10 +417,11 @@ export async function clearAllKeys(): Promise<void> {
|
||||||
STORES.RATCHET_STATES,
|
STORES.RATCHET_STATES,
|
||||||
]
|
]
|
||||||
|
|
||||||
const transaction = database.transaction(storeNames, "readwrite")
|
const transaction = database.transaction(storeNames, 'readwrite')
|
||||||
|
|
||||||
transaction.oncomplete = () => resolve()
|
transaction.oncomplete = () => resolve()
|
||||||
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to clear all keys"))
|
transaction.onerror = () =>
|
||||||
|
reject(new Error(transaction.error?.message ?? 'Failed to clear all keys'))
|
||||||
|
|
||||||
for (const storeName of storeNames) {
|
for (const storeName of storeNames) {
|
||||||
transaction.objectStore(storeName).clear()
|
transaction.objectStore(storeName).clear()
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// message-store.ts
|
// message-store.ts
|
||||||
// ===================
|
// ===================
|
||||||
import type { Message } from "../types"
|
import type { Message } from '../types'
|
||||||
|
|
||||||
const DB_NAME = "encrypted-chat-messages"
|
const DB_NAME = 'encrypted-chat-messages'
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 1
|
||||||
|
|
||||||
const STORES = {
|
const STORES = {
|
||||||
MESSAGES: "decrypted_messages",
|
MESSAGES: 'decrypted_messages',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
let db: IDBDatabase | null = null
|
let db: IDBDatabase | null = null
|
||||||
|
|
@ -20,7 +20,7 @@ async function openDatabase(): Promise<IDBDatabase> {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
|
||||||
request.onerror = () => {
|
request.onerror = () => {
|
||||||
reject(new Error("Failed to open message database"))
|
reject(new Error('Failed to open message database'))
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
|
@ -32,10 +32,14 @@ async function openDatabase(): Promise<IDBDatabase> {
|
||||||
const database = (event.target as IDBOpenDBRequest).result
|
const database = (event.target as IDBOpenDBRequest).result
|
||||||
|
|
||||||
if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
|
if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
|
||||||
const messageStore = database.createObjectStore(STORES.MESSAGES, { keyPath: "id" })
|
const messageStore = database.createObjectStore(STORES.MESSAGES, {
|
||||||
messageStore.createIndex("room_id", "room_id", { unique: false })
|
keyPath: 'id',
|
||||||
messageStore.createIndex("created_at", "created_at", { unique: false })
|
})
|
||||||
messageStore.createIndex("room_created", ["room_id", "created_at"], { unique: false })
|
messageStore.createIndex('room_id', 'room_id', { unique: false })
|
||||||
|
messageStore.createIndex('created_at', 'created_at', { unique: false })
|
||||||
|
messageStore.createIndex('room_created', ['room_id', 'created_at'], {
|
||||||
|
unique: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -54,15 +58,14 @@ async function performTransaction<T>(
|
||||||
const request = operation(store)
|
const request = operation(store)
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result)
|
request.onsuccess = () => resolve(request.result)
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Database operation failed"))
|
request.onerror = () =>
|
||||||
|
reject(new Error(request.error?.message ?? 'Database operation failed'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveDecryptedMessage(message: Message): Promise<void> {
|
export async function saveDecryptedMessage(message: Message): Promise<void> {
|
||||||
await performTransaction(
|
await performTransaction(STORES.MESSAGES, 'readwrite', (store) =>
|
||||||
STORES.MESSAGES,
|
store.put(message)
|
||||||
"readwrite",
|
|
||||||
(store) => store.put(message)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,11 +73,12 @@ export async function saveDecryptedMessages(messages: Message[]): Promise<void>
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.MESSAGES, "readwrite")
|
const transaction = database.transaction(STORES.MESSAGES, 'readwrite')
|
||||||
const store = transaction.objectStore(STORES.MESSAGES)
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
|
|
||||||
transaction.oncomplete = () => resolve()
|
transaction.oncomplete = () => resolve()
|
||||||
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to save messages"))
|
transaction.onerror = () =>
|
||||||
|
reject(new Error(transaction.error?.message ?? 'Failed to save messages'))
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
store.put(message)
|
store.put(message)
|
||||||
|
|
@ -82,29 +86,35 @@ export async function saveDecryptedMessages(messages: Message[]): Promise<void>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDecryptedMessage(messageId: string): Promise<Message | null> {
|
export async function getDecryptedMessage(
|
||||||
|
messageId: string
|
||||||
|
): Promise<Message | null> {
|
||||||
const result = await performTransaction<Message | undefined>(
|
const result = await performTransaction<Message | undefined>(
|
||||||
STORES.MESSAGES,
|
STORES.MESSAGES,
|
||||||
"readonly",
|
'readonly',
|
||||||
(store) => store.get(messageId) as IDBRequest<Message | undefined>
|
(store) => store.get(messageId) as IDBRequest<Message | undefined>
|
||||||
)
|
)
|
||||||
|
|
||||||
return result ?? null
|
return result ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDecryptedMessages(roomId: string, limit?: number): Promise<Message[]> {
|
export async function getDecryptedMessages(
|
||||||
|
roomId: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<Message[]> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
const transaction = database.transaction(STORES.MESSAGES, 'readonly')
|
||||||
const store = transaction.objectStore(STORES.MESSAGES)
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
const index = store.index("room_id")
|
const index = store.index('room_id')
|
||||||
const request = index.getAll(roomId)
|
const request = index.getAll(roomId)
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
let results = request.result as Message[]
|
let results = request.result as Message[]
|
||||||
results = results.sort(
|
results = results.sort(
|
||||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
(a, b) =>
|
||||||
|
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (limit !== undefined && limit > 0) {
|
if (limit !== undefined && limit > 0) {
|
||||||
|
|
@ -114,19 +124,22 @@ export async function getDecryptedMessages(roomId: string, limit?: number): Prom
|
||||||
resolve(results)
|
resolve(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get messages"))
|
request.onerror = () =>
|
||||||
|
reject(new Error(request.error?.message ?? 'Failed to get messages'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestMessageTimestamp(roomId: string): Promise<string | null> {
|
export async function getLatestMessageTimestamp(
|
||||||
|
roomId: string
|
||||||
|
): Promise<string | null> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
const transaction = database.transaction(STORES.MESSAGES, 'readonly')
|
||||||
const store = transaction.objectStore(STORES.MESSAGES)
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
const index = store.index("room_created")
|
const index = store.index('room_created')
|
||||||
const range = IDBKeyRange.bound([roomId, ""], [roomId, "\uffff"])
|
const range = IDBKeyRange.bound([roomId, ''], [roomId, '\uffff'])
|
||||||
const request = index.openCursor(range, "prev")
|
const request = index.openCursor(range, 'prev')
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const cursor = request.result
|
const cursor = request.result
|
||||||
|
|
@ -138,19 +151,23 @@ export async function getLatestMessageTimestamp(roomId: string): Promise<string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get latest timestamp"))
|
request.onerror = () =>
|
||||||
|
reject(
|
||||||
|
new Error(request.error?.message ?? 'Failed to get latest timestamp')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMessage(messageId: string): Promise<void> {
|
export async function deleteMessage(messageId: string): Promise<void> {
|
||||||
await performTransaction(
|
await performTransaction(STORES.MESSAGES, 'readwrite', (store) =>
|
||||||
STORES.MESSAGES,
|
store.delete(messageId)
|
||||||
"readwrite",
|
|
||||||
(store) => store.delete(messageId)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMessageId(oldId: string, newId: string): Promise<void> {
|
export async function updateMessageId(
|
||||||
|
oldId: string,
|
||||||
|
newId: string
|
||||||
|
): Promise<void> {
|
||||||
const message = await getDecryptedMessage(oldId)
|
const message = await getDecryptedMessage(oldId)
|
||||||
if (message) {
|
if (message) {
|
||||||
await deleteMessage(oldId)
|
await deleteMessage(oldId)
|
||||||
|
|
@ -163,9 +180,9 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.MESSAGES, "readwrite")
|
const transaction = database.transaction(STORES.MESSAGES, 'readwrite')
|
||||||
const store = transaction.objectStore(STORES.MESSAGES)
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
const index = store.index("room_id")
|
const index = store.index('room_id')
|
||||||
const request = index.openCursor(IDBKeyRange.only(roomId))
|
const request = index.openCursor(IDBKeyRange.only(roomId))
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
|
@ -177,29 +194,29 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction.oncomplete = () => resolve()
|
transaction.oncomplete = () => resolve()
|
||||||
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to clear room messages"))
|
transaction.onerror = () =>
|
||||||
|
reject(
|
||||||
|
new Error(transaction.error?.message ?? 'Failed to clear room messages')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAllMessages(): Promise<void> {
|
export async function clearAllMessages(): Promise<void> {
|
||||||
await performTransaction(
|
await performTransaction(STORES.MESSAGES, 'readwrite', (store) => store.clear())
|
||||||
STORES.MESSAGES,
|
|
||||||
"readwrite",
|
|
||||||
(store) => store.clear()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMessageCount(roomId: string): Promise<number> {
|
export async function getMessageCount(roomId: string): Promise<number> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
const transaction = database.transaction(STORES.MESSAGES, 'readonly')
|
||||||
const store = transaction.objectStore(STORES.MESSAGES)
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
const index = store.index("room_id")
|
const index = store.index('room_id')
|
||||||
const request = index.count(IDBKeyRange.only(roomId))
|
const request = index.count(IDBKeyRange.only(roomId))
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result)
|
request.onsuccess = () => resolve(request.result)
|
||||||
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to count messages"))
|
request.onerror = () =>
|
||||||
|
reject(new Error(request.error?.message ?? 'Failed to count messages'))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,23 @@
|
||||||
// primitives.ts
|
// primitives.ts
|
||||||
// ===================
|
// ===================
|
||||||
import {
|
import {
|
||||||
X25519_KEY_SIZE,
|
|
||||||
AES_GCM_KEY_SIZE,
|
AES_GCM_KEY_SIZE,
|
||||||
AES_GCM_NONCE_SIZE,
|
AES_GCM_NONCE_SIZE,
|
||||||
HKDF_OUTPUT_SIZE,
|
HKDF_OUTPUT_SIZE,
|
||||||
} from "../types"
|
X25519_KEY_SIZE,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
const crypto = globalThis.crypto
|
const crypto = globalThis.crypto
|
||||||
const subtle = crypto.subtle
|
const subtle = crypto.subtle
|
||||||
|
|
||||||
export async function generateX25519KeyPair(): Promise<CryptoKeyPair> {
|
export async function generateX25519KeyPair(): Promise<CryptoKeyPair> {
|
||||||
const keyPair = await subtle.generateKey(
|
const keyPair = (await subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "X25519",
|
name: 'X25519',
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
["deriveBits"]
|
['deriveBits']
|
||||||
) as CryptoKeyPair
|
)) as CryptoKeyPair
|
||||||
return keyPair
|
return keyPair
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ export async function x25519DeriveSharedSecret(
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const sharedBits = await subtle.deriveBits(
|
const sharedBits = await subtle.deriveBits(
|
||||||
{
|
{
|
||||||
name: "X25519",
|
name: 'X25519',
|
||||||
public: publicKey,
|
public: publicKey,
|
||||||
},
|
},
|
||||||
privateKey,
|
privateKey,
|
||||||
|
|
@ -38,61 +38,65 @@ export async function x25519DeriveSharedSecret(
|
||||||
return new Uint8Array(sharedBits)
|
return new Uint8Array(sharedBits)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importX25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importX25519PublicKey(
|
||||||
|
keyBytes: Uint8Array
|
||||||
|
): Promise<CryptoKey> {
|
||||||
if (keyBytes.length === 32) {
|
if (keyBytes.length === 32) {
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: "X25519" },
|
{ name: 'X25519' },
|
||||||
true,
|
true,
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"spki",
|
'spki',
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: "X25519" },
|
{ name: 'X25519' },
|
||||||
true,
|
true,
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importX25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importX25519PrivateKey(
|
||||||
|
keyBytes: Uint8Array
|
||||||
|
): Promise<CryptoKey> {
|
||||||
if (keyBytes.length === 32) {
|
if (keyBytes.length === 32) {
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: "X25519" },
|
{ name: 'X25519' },
|
||||||
true,
|
true,
|
||||||
["deriveBits"]
|
['deriveBits']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"pkcs8",
|
'pkcs8',
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: "X25519" },
|
{ name: 'X25519' },
|
||||||
true,
|
true,
|
||||||
["deriveBits"]
|
['deriveBits']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPublicKey(key: CryptoKey): Promise<Uint8Array> {
|
export async function exportPublicKey(key: CryptoKey): Promise<Uint8Array> {
|
||||||
const exported = await subtle.exportKey("spki", key)
|
const exported = await subtle.exportKey('spki', key)
|
||||||
return new Uint8Array(exported)
|
return new Uint8Array(exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPrivateKey(key: CryptoKey): Promise<Uint8Array> {
|
export async function exportPrivateKey(key: CryptoKey): Promise<Uint8Array> {
|
||||||
const exported = await subtle.exportKey("pkcs8", key)
|
const exported = await subtle.exportKey('pkcs8', key)
|
||||||
return new Uint8Array(exported)
|
return new Uint8Array(exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateEd25519KeyPair(): Promise<CryptoKeyPair> {
|
export async function generateEd25519KeyPair(): Promise<CryptoKeyPair> {
|
||||||
return await subtle.generateKey(
|
return await subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: "Ed25519",
|
name: 'Ed25519',
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
["sign", "verify"]
|
['sign', 'verify']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,7 +106,7 @@ export async function ed25519Sign(
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const signature = await subtle.sign(
|
const signature = await subtle.sign(
|
||||||
{
|
{
|
||||||
name: "Ed25519",
|
name: 'Ed25519',
|
||||||
},
|
},
|
||||||
privateKey,
|
privateKey,
|
||||||
data.buffer as ArrayBuffer
|
data.buffer as ArrayBuffer
|
||||||
|
|
@ -117,7 +121,7 @@ export async function ed25519Verify(
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return await subtle.verify(
|
return await subtle.verify(
|
||||||
{
|
{
|
||||||
name: "Ed25519",
|
name: 'Ed25519',
|
||||||
},
|
},
|
||||||
publicKey,
|
publicKey,
|
||||||
signature.buffer as ArrayBuffer,
|
signature.buffer as ArrayBuffer,
|
||||||
|
|
@ -125,41 +129,45 @@ export async function ed25519Verify(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importEd25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importEd25519PublicKey(
|
||||||
|
keyBytes: Uint8Array
|
||||||
|
): Promise<CryptoKey> {
|
||||||
if (keyBytes.length === 32) {
|
if (keyBytes.length === 32) {
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: "Ed25519" },
|
{ name: 'Ed25519' },
|
||||||
true,
|
true,
|
||||||
["verify"]
|
['verify']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"spki",
|
'spki',
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: "Ed25519" },
|
{ name: 'Ed25519' },
|
||||||
true,
|
true,
|
||||||
["verify"]
|
['verify']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importEd25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importEd25519PrivateKey(
|
||||||
|
keyBytes: Uint8Array
|
||||||
|
): Promise<CryptoKey> {
|
||||||
if (keyBytes.length === 32) {
|
if (keyBytes.length === 32) {
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: "Ed25519" },
|
{ name: 'Ed25519' },
|
||||||
true,
|
true,
|
||||||
["sign"]
|
['sign']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"pkcs8",
|
'pkcs8',
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{ name: "Ed25519" },
|
{ name: 'Ed25519' },
|
||||||
true,
|
true,
|
||||||
["sign"]
|
['sign']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,17 +178,17 @@ export async function hkdfDerive(
|
||||||
outputLength: number = HKDF_OUTPUT_SIZE
|
outputLength: number = HKDF_OUTPUT_SIZE
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const baseKey = await subtle.importKey(
|
const baseKey = await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
inputKeyMaterial.buffer as ArrayBuffer,
|
inputKeyMaterial.buffer as ArrayBuffer,
|
||||||
{ name: "HKDF" },
|
{ name: 'HKDF' },
|
||||||
false,
|
false,
|
||||||
["deriveBits"]
|
['deriveBits']
|
||||||
)
|
)
|
||||||
|
|
||||||
const derivedBits = await subtle.deriveBits(
|
const derivedBits = await subtle.deriveBits(
|
||||||
{
|
{
|
||||||
name: "HKDF",
|
name: 'HKDF',
|
||||||
hash: "SHA-256",
|
hash: 'SHA-256',
|
||||||
salt: salt.buffer as ArrayBuffer,
|
salt: salt.buffer as ArrayBuffer,
|
||||||
info: info.buffer as ArrayBuffer,
|
info: info.buffer as ArrayBuffer,
|
||||||
},
|
},
|
||||||
|
|
@ -197,27 +205,27 @@ export async function hkdfDeriveKey(
|
||||||
info: Uint8Array
|
info: Uint8Array
|
||||||
): Promise<CryptoKey> {
|
): Promise<CryptoKey> {
|
||||||
const keyMaterial = await subtle.importKey(
|
const keyMaterial = await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
inputKeyMaterial.buffer as ArrayBuffer,
|
inputKeyMaterial.buffer as ArrayBuffer,
|
||||||
{ name: "HKDF" },
|
{ name: 'HKDF' },
|
||||||
false,
|
false,
|
||||||
["deriveKey"]
|
['deriveKey']
|
||||||
)
|
)
|
||||||
|
|
||||||
return await subtle.deriveKey(
|
return await subtle.deriveKey(
|
||||||
{
|
{
|
||||||
name: "HKDF",
|
name: 'HKDF',
|
||||||
hash: "SHA-256",
|
hash: 'SHA-256',
|
||||||
salt: salt.buffer as ArrayBuffer,
|
salt: salt.buffer as ArrayBuffer,
|
||||||
info: info.buffer as ArrayBuffer,
|
info: info.buffer as ArrayBuffer,
|
||||||
},
|
},
|
||||||
keyMaterial,
|
keyMaterial,
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: 'AES-GCM',
|
||||||
length: AES_GCM_KEY_SIZE * 8,
|
length: AES_GCM_KEY_SIZE * 8,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
["encrypt", "decrypt"]
|
['encrypt', 'decrypt']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,11 +238,11 @@ export async function aesGcmEncrypt(
|
||||||
|
|
||||||
if (key instanceof Uint8Array) {
|
if (key instanceof Uint8Array) {
|
||||||
cryptoKey = await subtle.importKey(
|
cryptoKey = await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
key.buffer as ArrayBuffer,
|
key.buffer as ArrayBuffer,
|
||||||
{ name: "AES-GCM", length: AES_GCM_KEY_SIZE * 8 },
|
{ name: 'AES-GCM', length: AES_GCM_KEY_SIZE * 8 },
|
||||||
false,
|
false,
|
||||||
["encrypt"]
|
['encrypt']
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
cryptoKey = key
|
cryptoKey = key
|
||||||
|
|
@ -244,7 +252,7 @@ export async function aesGcmEncrypt(
|
||||||
|
|
||||||
const ciphertext = await subtle.encrypt(
|
const ciphertext = await subtle.encrypt(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: 'AES-GCM',
|
||||||
iv: nonce.buffer as ArrayBuffer,
|
iv: nonce.buffer as ArrayBuffer,
|
||||||
additionalData: associatedData?.buffer as ArrayBuffer | undefined,
|
additionalData: associatedData?.buffer as ArrayBuffer | undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -268,11 +276,11 @@ export async function aesGcmDecrypt(
|
||||||
|
|
||||||
if (key instanceof Uint8Array) {
|
if (key instanceof Uint8Array) {
|
||||||
cryptoKey = await subtle.importKey(
|
cryptoKey = await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
key.buffer as ArrayBuffer,
|
key.buffer as ArrayBuffer,
|
||||||
{ name: "AES-GCM", length: AES_GCM_KEY_SIZE * 8 },
|
{ name: 'AES-GCM', length: AES_GCM_KEY_SIZE * 8 },
|
||||||
false,
|
false,
|
||||||
["decrypt"]
|
['decrypt']
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
cryptoKey = key
|
cryptoKey = key
|
||||||
|
|
@ -280,7 +288,7 @@ export async function aesGcmDecrypt(
|
||||||
|
|
||||||
const plaintext = await subtle.decrypt(
|
const plaintext = await subtle.decrypt(
|
||||||
{
|
{
|
||||||
name: "AES-GCM",
|
name: 'AES-GCM',
|
||||||
iv: nonce.buffer as ArrayBuffer,
|
iv: nonce.buffer as ArrayBuffer,
|
||||||
additionalData: associatedData?.buffer as ArrayBuffer | undefined,
|
additionalData: associatedData?.buffer as ArrayBuffer | undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -298,12 +306,12 @@ export function generateRandomBytes(length: number): Uint8Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||||
const hash = await subtle.digest("SHA-256", data.buffer as ArrayBuffer)
|
const hash = await subtle.digest('SHA-256', data.buffer as ArrayBuffer)
|
||||||
return new Uint8Array(hash)
|
return new Uint8Array(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sha512(data: Uint8Array): Promise<Uint8Array> {
|
export async function sha512(data: Uint8Array): Promise<Uint8Array> {
|
||||||
const hash = await subtle.digest("SHA-512", data.buffer as ArrayBuffer)
|
const hash = await subtle.digest('SHA-512', data.buffer as ArrayBuffer)
|
||||||
return new Uint8Array(hash)
|
return new Uint8Array(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,17 +320,21 @@ export async function hmacSha256(
|
||||||
data: Uint8Array
|
data: Uint8Array
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const cryptoKey = await subtle.importKey(
|
const cryptoKey = await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
key.buffer as ArrayBuffer,
|
key.buffer as ArrayBuffer,
|
||||||
{
|
{
|
||||||
name: "HMAC",
|
name: 'HMAC',
|
||||||
hash: "SHA-256",
|
hash: 'SHA-256',
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
["sign"]
|
['sign']
|
||||||
)
|
)
|
||||||
|
|
||||||
const signature = await subtle.sign("HMAC", cryptoKey, data.buffer as ArrayBuffer)
|
const signature = await subtle.sign(
|
||||||
|
'HMAC',
|
||||||
|
cryptoKey,
|
||||||
|
data.buffer as ArrayBuffer
|
||||||
|
)
|
||||||
return new Uint8Array(signature)
|
return new Uint8Array(signature)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,17 +344,22 @@ export async function hmacSha256Verify(
|
||||||
data: Uint8Array
|
data: Uint8Array
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const cryptoKey = await subtle.importKey(
|
const cryptoKey = await subtle.importKey(
|
||||||
"raw",
|
'raw',
|
||||||
key.buffer as ArrayBuffer,
|
key.buffer as ArrayBuffer,
|
||||||
{
|
{
|
||||||
name: "HMAC",
|
name: 'HMAC',
|
||||||
hash: "SHA-256",
|
hash: 'SHA-256',
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
["verify"]
|
['verify']
|
||||||
)
|
)
|
||||||
|
|
||||||
return await subtle.verify("HMAC", cryptoKey, signature.buffer as ArrayBuffer, data.buffer as ArrayBuffer)
|
return await subtle.verify(
|
||||||
|
'HMAC',
|
||||||
|
cryptoKey,
|
||||||
|
signature.buffer as ArrayBuffer,
|
||||||
|
data.buffer as ArrayBuffer
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function concatBytes(...arrays: Uint8Array[]): Uint8Array {
|
export function concatBytes(...arrays: Uint8Array[]): Uint8Array {
|
||||||
|
|
@ -373,8 +390,8 @@ export function base64ToBytes(base64: string): Uint8Array {
|
||||||
|
|
||||||
export function bytesToHex(bytes: Uint8Array): string {
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
return Array.from(bytes)
|
return Array.from(bytes)
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join("")
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hexToBytes(hex: string): Uint8Array {
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,34 @@
|
||||||
// x3dh.ts
|
// x3dh.ts
|
||||||
// ===================
|
// ===================
|
||||||
import type {
|
import type {
|
||||||
PreKeyBundle,
|
|
||||||
X3DHResult,
|
|
||||||
X3DHHeader,
|
|
||||||
IdentityKeyPair,
|
IdentityKeyPair,
|
||||||
SignedPreKey,
|
|
||||||
OneTimePreKey,
|
OneTimePreKey,
|
||||||
} from "../types"
|
PreKeyBundle,
|
||||||
|
SignedPreKey,
|
||||||
|
X3DHHeader,
|
||||||
|
X3DHResult,
|
||||||
|
} from '../types'
|
||||||
|
import { DEFAULT_ONE_TIME_PREKEY_COUNT, HKDF_OUTPUT_SIZE } from '../types'
|
||||||
import {
|
import {
|
||||||
generateX25519KeyPair,
|
base64ToBytes,
|
||||||
x25519DeriveSharedSecret,
|
bytesToBase64,
|
||||||
importX25519PublicKey,
|
concatBytes,
|
||||||
importX25519PrivateKey,
|
|
||||||
exportPublicKey,
|
|
||||||
exportPrivateKey,
|
|
||||||
generateEd25519KeyPair,
|
|
||||||
ed25519Sign,
|
ed25519Sign,
|
||||||
ed25519Verify,
|
ed25519Verify,
|
||||||
importEd25519PublicKey,
|
exportPrivateKey,
|
||||||
importEd25519PrivateKey,
|
exportPublicKey,
|
||||||
hkdfDerive,
|
generateEd25519KeyPair,
|
||||||
concatBytes,
|
|
||||||
bytesToBase64,
|
|
||||||
base64ToBytes,
|
|
||||||
generateRandomBytes,
|
generateRandomBytes,
|
||||||
} from "./primitives"
|
generateX25519KeyPair,
|
||||||
import { HKDF_OUTPUT_SIZE, DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
|
hkdfDerive,
|
||||||
|
importEd25519PrivateKey,
|
||||||
|
importEd25519PublicKey,
|
||||||
|
importX25519PrivateKey,
|
||||||
|
importX25519PublicKey,
|
||||||
|
x25519DeriveSharedSecret,
|
||||||
|
} from './primitives'
|
||||||
|
|
||||||
const X3DH_INFO = new TextEncoder().encode("X3DH")
|
const X3DH_INFO = new TextEncoder().encode('X3DH')
|
||||||
const EMPTY_SALT = new Uint8Array(HKDF_OUTPUT_SIZE)
|
const EMPTY_SALT = new Uint8Array(HKDF_OUTPUT_SIZE)
|
||||||
|
|
||||||
export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
|
export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
|
||||||
|
|
@ -57,7 +57,9 @@ export async function generateSignedPreKey(
|
||||||
const publicKey = await exportPublicKey(keyPair.publicKey)
|
const publicKey = await exportPublicKey(keyPair.publicKey)
|
||||||
const privateKey = await exportPrivateKey(keyPair.privateKey)
|
const privateKey = await exportPrivateKey(keyPair.privateKey)
|
||||||
|
|
||||||
const signingKey = await importEd25519PrivateKey(base64ToBytes(identityPrivateKey))
|
const signingKey = await importEd25519PrivateKey(
|
||||||
|
base64ToBytes(identityPrivateKey)
|
||||||
|
)
|
||||||
const signature = await ed25519Sign(signingKey, publicKey)
|
const signature = await ed25519Sign(signingKey, publicKey)
|
||||||
|
|
||||||
const id = bytesToBase64(generateRandomBytes(16))
|
const id = bytesToBase64(generateRandomBytes(16))
|
||||||
|
|
@ -102,7 +104,9 @@ export async function verifySignedPreKey(
|
||||||
signature: string
|
signature: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const verifyKey = await importEd25519PublicKey(base64ToBytes(identityPublicKey))
|
const verifyKey = await importEd25519PublicKey(
|
||||||
|
base64ToBytes(identityPublicKey)
|
||||||
|
)
|
||||||
const publicKeyBytes = base64ToBytes(signedPreKeyPublic)
|
const publicKeyBytes = base64ToBytes(signedPreKeyPublic)
|
||||||
const signatureBytes = base64ToBytes(signature)
|
const signatureBytes = base64ToBytes(signature)
|
||||||
|
|
||||||
|
|
@ -123,7 +127,7 @@ export async function initiateX3DH(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!signatureValid) {
|
if (!signatureValid) {
|
||||||
throw new Error("Invalid signed prekey signature")
|
throw new Error('Invalid signed prekey signature')
|
||||||
}
|
}
|
||||||
|
|
||||||
const ephemeralKeyPair = await generateX25519KeyPair()
|
const ephemeralKeyPair = await generateX25519KeyPair()
|
||||||
|
|
@ -139,9 +143,18 @@ export async function initiateX3DH(
|
||||||
base64ToBytes(recipientBundle.signed_prekey)
|
base64ToBytes(recipientBundle.signed_prekey)
|
||||||
)
|
)
|
||||||
|
|
||||||
const dh1 = await x25519DeriveSharedSecret(senderIdentityPrivate, recipientSignedPreKeyPublic)
|
const dh1 = await x25519DeriveSharedSecret(
|
||||||
const dh2 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientIdentityPublic)
|
senderIdentityPrivate,
|
||||||
const dh3 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientSignedPreKeyPublic)
|
recipientSignedPreKeyPublic
|
||||||
|
)
|
||||||
|
const dh2 = await x25519DeriveSharedSecret(
|
||||||
|
ephemeralKeyPair.privateKey,
|
||||||
|
recipientIdentityPublic
|
||||||
|
)
|
||||||
|
const dh3 = await x25519DeriveSharedSecret(
|
||||||
|
ephemeralKeyPair.privateKey,
|
||||||
|
recipientSignedPreKeyPublic
|
||||||
|
)
|
||||||
|
|
||||||
let dhResults: Uint8Array[]
|
let dhResults: Uint8Array[]
|
||||||
let usedOneTimePreKey = false
|
let usedOneTimePreKey = false
|
||||||
|
|
@ -150,7 +163,10 @@ export async function initiateX3DH(
|
||||||
const recipientOneTimePreKeyPublic = await importX25519PublicKey(
|
const recipientOneTimePreKeyPublic = await importX25519PublicKey(
|
||||||
base64ToBytes(recipientBundle.one_time_prekey)
|
base64ToBytes(recipientBundle.one_time_prekey)
|
||||||
)
|
)
|
||||||
const dh4 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientOneTimePreKeyPublic)
|
const dh4 = await x25519DeriveSharedSecret(
|
||||||
|
ephemeralKeyPair.privateKey,
|
||||||
|
recipientOneTimePreKeyPublic
|
||||||
|
)
|
||||||
dhResults = [dh1, dh2, dh3, dh4]
|
dhResults = [dh1, dh2, dh3, dh4]
|
||||||
usedOneTimePreKey = true
|
usedOneTimePreKey = true
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -192,9 +208,18 @@ export async function receiveX3DH(
|
||||||
base64ToBytes(senderEphemeralKey)
|
base64ToBytes(senderEphemeralKey)
|
||||||
)
|
)
|
||||||
|
|
||||||
const dh1 = await x25519DeriveSharedSecret(recipientSignedPreKeyPrivate, senderIdentityPublic)
|
const dh1 = await x25519DeriveSharedSecret(
|
||||||
const dh2 = await x25519DeriveSharedSecret(recipientIdentityPrivate, senderEphemeralPublic)
|
recipientSignedPreKeyPrivate,
|
||||||
const dh3 = await x25519DeriveSharedSecret(recipientSignedPreKeyPrivate, senderEphemeralPublic)
|
senderIdentityPublic
|
||||||
|
)
|
||||||
|
const dh2 = await x25519DeriveSharedSecret(
|
||||||
|
recipientIdentityPrivate,
|
||||||
|
senderEphemeralPublic
|
||||||
|
)
|
||||||
|
const dh3 = await x25519DeriveSharedSecret(
|
||||||
|
recipientSignedPreKeyPrivate,
|
||||||
|
senderEphemeralPublic
|
||||||
|
)
|
||||||
|
|
||||||
let dhResults: Uint8Array[]
|
let dhResults: Uint8Array[]
|
||||||
|
|
||||||
|
|
@ -202,7 +227,10 @@ export async function receiveX3DH(
|
||||||
const recipientOneTimePreKeyPrivate = await importX25519PrivateKey(
|
const recipientOneTimePreKeyPrivate = await importX25519PrivateKey(
|
||||||
base64ToBytes(oneTimePreKey.private_key)
|
base64ToBytes(oneTimePreKey.private_key)
|
||||||
)
|
)
|
||||||
const dh4 = await x25519DeriveSharedSecret(recipientOneTimePreKeyPrivate, senderEphemeralPublic)
|
const dh4 = await x25519DeriveSharedSecret(
|
||||||
|
recipientOneTimePreKeyPrivate,
|
||||||
|
senderEphemeralPublic
|
||||||
|
)
|
||||||
dhResults = [dh1, dh2, dh3, dh4]
|
dhResults = [dh1, dh2, dh3, dh4]
|
||||||
} else {
|
} else {
|
||||||
dhResults = [dh1, dh2, dh3]
|
dhResults = [dh1, dh2, dh3]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { render } from "solid-js/web";
|
import { Router } from '@solidjs/router'
|
||||||
import { Router } from "@solidjs/router";
|
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
import { render } from 'solid-js/web'
|
||||||
import App from "./App";
|
import App from './App'
|
||||||
import { ToastContainer } from "./components/UI/Toast";
|
import { ToastContainer } from './components/UI/Toast'
|
||||||
import "./index.css";
|
import './index.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -13,12 +13,12 @@ const queryClient = new QueryClient({
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById('root')
|
||||||
|
|
||||||
if (root === null) {
|
if (root === null) {
|
||||||
throw new Error("Root element not found");
|
throw new Error('Root element not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|
@ -31,4 +31,4 @@ render(
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
root
|
root
|
||||||
);
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,33 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// Chat.tsx
|
// Chat.tsx
|
||||||
// ===================
|
// ===================
|
||||||
import { Show, onMount, onCleanup, createEffect } from "solid-js"
|
|
||||||
import type { JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import type { Participant } from "../types"
|
import type { JSX } from 'solid-js'
|
||||||
import { useStore } from "@nanostores/solid"
|
import { createEffect, onCleanup, onMount, Show } from 'solid-js'
|
||||||
import { AppShell, ProtectedRoute } from "../components/Layout"
|
|
||||||
import {
|
import {
|
||||||
MessageList,
|
|
||||||
ChatHeader,
|
ChatHeader,
|
||||||
ChatInput,
|
ChatInput,
|
||||||
|
MessageList,
|
||||||
NewConversation,
|
NewConversation,
|
||||||
} from "../components/Chat"
|
} from '../components/Chat'
|
||||||
|
import { AppShell, ProtectedRoute } from '../components/Layout'
|
||||||
|
import { cryptoService, saveDecryptedMessage } from '../crypto'
|
||||||
|
import { roomService } from '../services'
|
||||||
import {
|
import {
|
||||||
|
$activeModal,
|
||||||
$activeRoom,
|
$activeRoom,
|
||||||
$activeRoomId,
|
$activeRoomId,
|
||||||
$activeModal,
|
|
||||||
$userId,
|
|
||||||
$currentUser,
|
$currentUser,
|
||||||
showToast,
|
$userId,
|
||||||
setActiveRoom,
|
|
||||||
openModal,
|
|
||||||
closeModal,
|
|
||||||
addMessage,
|
addMessage,
|
||||||
} from "../stores"
|
closeModal,
|
||||||
import {
|
openModal,
|
||||||
connectWebSocket,
|
setActiveRoom,
|
||||||
disconnectWebSocket,
|
showToast,
|
||||||
wsManager,
|
} from '../stores'
|
||||||
} from "../websocket"
|
import type { Participant } from '../types'
|
||||||
import { roomService } from "../services"
|
import { connectWebSocket, disconnectWebSocket, wsManager } from '../websocket'
|
||||||
import { cryptoService, saveDecryptedMessage } from "../crypto"
|
|
||||||
|
|
||||||
export default function Chat(): JSX.Element {
|
export default function Chat(): JSX.Element {
|
||||||
const activeRoom = useStore($activeRoom)
|
const activeRoom = useStore($activeRoom)
|
||||||
|
|
@ -51,8 +48,7 @@ export default function Chat(): JSX.Element {
|
||||||
if (currentUserId) {
|
if (currentUserId) {
|
||||||
try {
|
try {
|
||||||
await cryptoService.initialize(currentUserId)
|
await cryptoService.initialize(currentUserId)
|
||||||
} catch {
|
} catch {}
|
||||||
}
|
|
||||||
connectWebSocket()
|
connectWebSocket()
|
||||||
await roomService.loadRooms(currentUserId)
|
await roomService.loadRooms(currentUserId)
|
||||||
}
|
}
|
||||||
|
|
@ -69,19 +65,21 @@ export default function Chat(): JSX.Element {
|
||||||
const user = $currentUser.get()
|
const user = $currentUser.get()
|
||||||
|
|
||||||
if (roomId === null || room === null) {
|
if (roomId === null || room === null) {
|
||||||
showToast("error", "SEND FAILED", "NO ACTIVE ROOM")
|
showToast('error', 'SEND FAILED', 'NO ACTIVE ROOM')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUserId === null) {
|
if (currentUserId === null) {
|
||||||
showToast("error", "SEND FAILED", "NOT AUTHENTICATED")
|
showToast('error', 'SEND FAILED', 'NOT AUTHENTICATED')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipientId = room.participants.find((p: Participant) => p.user_id !== currentUserId)?.user_id
|
const recipientId = room.participants.find(
|
||||||
|
(p: Participant) => p.user_id !== currentUserId
|
||||||
|
)?.user_id
|
||||||
|
|
||||||
if (recipientId === undefined) {
|
if (recipientId === undefined) {
|
||||||
showToast("error", "SEND FAILED", "NO RECIPIENT FOUND")
|
showToast('error', 'SEND FAILED', 'NO RECIPIENT FOUND')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,9 +90,9 @@ export default function Chat(): JSX.Element {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
sender_id: currentUserId,
|
sender_id: currentUserId,
|
||||||
sender_username: user?.username ?? "me",
|
sender_username: user?.username ?? 'me',
|
||||||
content,
|
content,
|
||||||
status: "sending" as const,
|
status: 'sending' as const,
|
||||||
is_encrypted: true,
|
is_encrypted: true,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
|
@ -114,11 +112,10 @@ export default function Chat(): JSX.Element {
|
||||||
if (sent) {
|
if (sent) {
|
||||||
void saveDecryptedMessage(messageToSend)
|
void saveDecryptedMessage(messageToSend)
|
||||||
} else {
|
} else {
|
||||||
showToast("error", "SEND FAILED", "NOT CONNECTED")
|
showToast('error', 'SEND FAILED', 'NOT CONNECTED')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error("[Chat] Encryption failed:", error)
|
showToast('error', 'SEND FAILED', 'ENCRYPTION ERROR')
|
||||||
showToast("error", "SEND FAILED", "ENCRYPTION ERROR")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +123,7 @@ export default function Chat(): JSX.Element {
|
||||||
const currentUserId = userId()
|
const currentUserId = userId()
|
||||||
|
|
||||||
if (currentUserId === null) {
|
if (currentUserId === null) {
|
||||||
showToast("error", "FAILED", "NOT AUTHENTICATED")
|
showToast('error', 'FAILED', 'NOT AUTHENTICATED')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,12 +133,12 @@ export default function Chat(): JSX.Element {
|
||||||
setActiveRoom(room.id)
|
setActiveRoom(room.id)
|
||||||
closeModal()
|
closeModal()
|
||||||
} else {
|
} else {
|
||||||
showToast("error", "FAILED", "COULD NOT CREATE CONVERSATION")
|
showToast('error', 'FAILED', 'COULD NOT CREATE CONVERSATION')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewChat = (): void => {
|
const handleNewChat = (): void => {
|
||||||
openModal("new-conversation")
|
openModal('new-conversation')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -155,15 +152,15 @@ export default function Chat(): JSX.Element {
|
||||||
>
|
>
|
||||||
{(roomId) => (
|
{(roomId) => (
|
||||||
<>
|
<>
|
||||||
<ChatHeader
|
<ChatHeader room={activeRoom()} />
|
||||||
room={activeRoom()}
|
<MessageList roomId={roomId} />
|
||||||
/>
|
|
||||||
<MessageList
|
|
||||||
roomId={roomId}
|
|
||||||
/>
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
recipientId={activeRoom()?.participants.find((p: Participant) => p.user_id !== userId())?.user_id ?? ""}
|
recipientId={
|
||||||
|
activeRoom()?.participants.find(
|
||||||
|
(p: Participant) => p.user_id !== userId()
|
||||||
|
)?.user_id ?? ''
|
||||||
|
}
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
@ -172,7 +169,7 @@ export default function Chat(): JSX.Element {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewConversation
|
<NewConversation
|
||||||
isOpen={activeModal() === "new-conversation"}
|
isOpen={activeModal() === 'new-conversation'}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
onCreateRoom={handleCreateRoom}
|
onCreateRoom={handleCreateRoom}
|
||||||
/>
|
/>
|
||||||
|
|
@ -210,7 +207,14 @@ function EmptyState(props: EmptyStateProps): JSX.Element {
|
||||||
|
|
||||||
function ChatIcon(): JSX.Element {
|
function ChatIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="currentColor" class="text-orange mx-auto">
|
<svg
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange mx-auto"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="8" y="8" width="32" height="4" />
|
<rect x="8" y="8" width="32" height="4" />
|
||||||
<rect x="4" y="12" width="4" height="24" />
|
<rect x="4" y="12" width="4" height="24" />
|
||||||
<rect x="40" y="12" width="4" height="24" />
|
<rect x="40" y="12" width="4" height="24" />
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
* Home page - landing or redirect to chat
|
* Home page - landing or redirect to chat
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import { useStore } from '@nanostores/solid'
|
||||||
import { Show } from "solid-js"
|
import { A } from '@solidjs/router'
|
||||||
import { A } from "@solidjs/router"
|
import type { JSX } from 'solid-js'
|
||||||
import { useStore } from "@nanostores/solid"
|
import { Show } from 'solid-js'
|
||||||
import { $isAuthenticated } from "../stores"
|
import { Button } from '../components/UI'
|
||||||
import { Button } from "../components/UI"
|
import { $isAuthenticated } from '../stores'
|
||||||
|
|
||||||
export default function Home(): JSX.Element {
|
export default function Home(): JSX.Element {
|
||||||
const isAuthenticated = useStore($isAuthenticated)
|
const isAuthenticated = useStore($isAuthenticated)
|
||||||
|
|
@ -19,13 +19,11 @@ export default function Home(): JSX.Element {
|
||||||
<LockIcon />
|
<LockIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="font-pixel text-2xl text-orange mb-4">
|
<h1 class="font-pixel text-2xl text-orange mb-4">ENCRYPTED CHAT</h1>
|
||||||
ENCRYPTED CHAT
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p class="font-pixel text-[10px] text-gray mb-8 leading-relaxed">
|
<p class="font-pixel text-[10px] text-gray mb-8 leading-relaxed">
|
||||||
END-TO-END ENCRYPTED MESSAGING WITH DOUBLE RATCHET PROTOCOL.
|
END-TO-END ENCRYPTED MESSAGING WITH DOUBLE RATCHET PROTOCOL. YOUR
|
||||||
YOUR MESSAGES ARE SECURE AND PRIVATE.
|
MESSAGES ARE SECURE AND PRIVATE.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 items-center">
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
|
@ -80,20 +78,75 @@ function FeatureItem(props: FeatureItemProps): JSX.Element {
|
||||||
|
|
||||||
function LockIcon(): JSX.Element {
|
function LockIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" class="mx-auto">
|
<svg
|
||||||
<rect x="20" y="12" width="24" height="4" fill="currentColor" class="text-orange" />
|
width="64"
|
||||||
<rect x="16" y="16" width="4" height="16" fill="currentColor" class="text-orange" />
|
height="64"
|
||||||
<rect x="44" y="16" width="4" height="16" fill="currentColor" class="text-orange" />
|
viewBox="0 0 64 64"
|
||||||
<rect x="12" y="32" width="40" height="4" fill="currentColor" class="text-orange" />
|
fill="none"
|
||||||
<rect x="12" y="36" width="40" height="20" fill="currentColor" class="text-orange" />
|
class="mx-auto"
|
||||||
<rect x="30" y="42" width="4" height="8" fill="currentColor" class="text-black" />
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="20"
|
||||||
|
y="12"
|
||||||
|
width="24"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="16"
|
||||||
|
y="16"
|
||||||
|
width="4"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="44"
|
||||||
|
y="16"
|
||||||
|
width="4"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="12"
|
||||||
|
y="32"
|
||||||
|
width="40"
|
||||||
|
height="4"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="12"
|
||||||
|
y="36"
|
||||||
|
width="40"
|
||||||
|
height="20"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-orange"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="30"
|
||||||
|
y="42"
|
||||||
|
width="4"
|
||||||
|
height="8"
|
||||||
|
fill="currentColor"
|
||||||
|
class="text-black"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EncryptIcon(): JSX.Element {
|
function EncryptIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="10" y="2" width="4" height="4" />
|
<rect x="10" y="2" width="4" height="4" />
|
||||||
<rect x="6" y="6" width="4" height="4" />
|
<rect x="6" y="6" width="4" height="4" />
|
||||||
<rect x="14" y="6" width="4" height="4" />
|
<rect x="14" y="6" width="4" height="4" />
|
||||||
|
|
@ -108,7 +161,13 @@ function EncryptIcon(): JSX.Element {
|
||||||
|
|
||||||
function KeyIcon(): JSX.Element {
|
function KeyIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="4" y="8" width="4" height="4" />
|
<rect x="4" y="8" width="4" height="4" />
|
||||||
<rect x="8" y="4" width="4" height="4" />
|
<rect x="8" y="4" width="4" height="4" />
|
||||||
<rect x="8" y="12" width="4" height="4" />
|
<rect x="8" y="12" width="4" height="4" />
|
||||||
|
|
@ -121,7 +180,13 @@ function KeyIcon(): JSX.Element {
|
||||||
|
|
||||||
function ShieldIcon(): JSX.Element {
|
function ShieldIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<rect x="10" y="2" width="4" height="2" />
|
<rect x="10" y="2" width="4" height="2" />
|
||||||
<rect x="6" y="4" width="4" height="2" />
|
<rect x="6" y="4" width="4" height="2" />
|
||||||
<rect x="14" y="4" width="4" height="2" />
|
<rect x="14" y="4" width="4" height="2" />
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
* Login page with WebAuthn passkey authentication
|
* Login page with WebAuthn passkey authentication
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import { AuthForm } from "../components/Auth"
|
import { AuthForm } from '../components/Auth'
|
||||||
import { GuestRoute } from "../components/Layout"
|
import { GuestRoute } from '../components/Layout'
|
||||||
|
|
||||||
export default function Login(): JSX.Element {
|
export default function Login(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,17 @@
|
||||||
* 404 Not Found page
|
* 404 Not Found page
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import { A } from '@solidjs/router'
|
||||||
import { A } from "@solidjs/router"
|
import type { JSX } from 'solid-js'
|
||||||
import { Button } from "../components/UI"
|
import { Button } from '../components/UI'
|
||||||
|
|
||||||
export default function NotFound(): JSX.Element {
|
export default function NotFound(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen flex flex-col items-center justify-center bg-black p-4">
|
<div class="min-h-screen flex flex-col items-center justify-center bg-black p-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h1 class="font-pixel text-6xl text-orange mb-4">
|
<h1 class="font-pixel text-6xl text-orange mb-4">404</h1>
|
||||||
404
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p class="font-pixel text-sm text-white mb-2">
|
<p class="font-pixel text-sm text-white mb-2">PAGE NOT FOUND</p>
|
||||||
PAGE NOT FOUND
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="font-pixel text-[10px] text-gray mb-8">
|
<p class="font-pixel text-[10px] text-gray mb-8">
|
||||||
THE PAGE YOU ARE LOOKING FOR DOES NOT EXIST
|
THE PAGE YOU ARE LOOKING FOR DOES NOT EXIST
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
// Registration page with WebAuthn passkey creation
|
// Registration page with WebAuthn passkey creation
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import { AuthForm } from "../components/Auth"
|
import { AuthForm } from '../components/Auth'
|
||||||
import { GuestRoute } from "../components/Layout"
|
import { GuestRoute } from '../components/Layout'
|
||||||
|
|
||||||
export default function Register(): JSX.Element {
|
export default function Register(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,17 @@
|
||||||
* Handles passkey registration and authentication flows
|
* Handles passkey registration and authentication flows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from "../lib/api-client"
|
import { cryptoService } from '../crypto'
|
||||||
import {
|
import { api } from '../lib/api-client'
|
||||||
base64UrlEncode,
|
import { base64UrlDecode, base64UrlEncode } from '../lib/base64'
|
||||||
base64UrlDecode,
|
import { setCurrentUser, logout as storeLogout } from '../stores'
|
||||||
} from "../lib/base64"
|
|
||||||
import type {
|
import type {
|
||||||
User,
|
|
||||||
RegistrationBeginRequest,
|
|
||||||
AuthenticationBeginRequest,
|
AuthenticationBeginRequest,
|
||||||
PublicKeyCredentialJSON,
|
PublicKeyCredentialJSON,
|
||||||
} from "../types"
|
RegistrationBeginRequest,
|
||||||
import { isPublicKeyCredential } from "../types/guards"
|
User,
|
||||||
import { setCurrentUser, logout as storeLogout } from "../stores"
|
} from '../types'
|
||||||
import { cryptoService } from "../crypto"
|
import { isPublicKeyCredential } from '../types/guards'
|
||||||
|
|
||||||
interface PublicKeyCredentialStatic {
|
interface PublicKeyCredentialStatic {
|
||||||
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
|
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
|
||||||
|
|
@ -31,22 +28,32 @@ function publicKeyCredentialToJSON(
|
||||||
const json: PublicKeyCredentialJSON = {
|
const json: PublicKeyCredentialJSON = {
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
rawId: base64UrlEncode(credential.rawId),
|
rawId: base64UrlEncode(credential.rawId),
|
||||||
type: "public-key",
|
type: 'public-key',
|
||||||
response: {
|
response: {
|
||||||
clientDataJSON: base64UrlEncode(response.clientDataJSON),
|
clientDataJSON: base64UrlEncode(response.clientDataJSON),
|
||||||
},
|
},
|
||||||
authenticatorAttachment: credential.authenticatorAttachment as "platform" | "cross-platform" | undefined,
|
authenticatorAttachment: credential.authenticatorAttachment as
|
||||||
clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>,
|
| 'platform'
|
||||||
|
| 'cross-platform'
|
||||||
|
| undefined,
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults() as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("attestationObject" in response) {
|
if ('attestationObject' in response) {
|
||||||
const attestationResponse = response as AuthenticatorAttestationResponse
|
const attestationResponse = response as AuthenticatorAttestationResponse
|
||||||
json.response.attestationObject = base64UrlEncode(attestationResponse.attestationObject)
|
json.response.attestationObject = base64UrlEncode(
|
||||||
|
attestationResponse.attestationObject
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("authenticatorData" in response) {
|
if ('authenticatorData' in response) {
|
||||||
const assertionResponse = response as AuthenticatorAssertionResponse
|
const assertionResponse = response as AuthenticatorAssertionResponse
|
||||||
json.response.authenticatorData = base64UrlEncode(assertionResponse.authenticatorData)
|
json.response.authenticatorData = base64UrlEncode(
|
||||||
|
assertionResponse.authenticatorData
|
||||||
|
)
|
||||||
json.response.signature = base64UrlEncode(assertionResponse.signature)
|
json.response.signature = base64UrlEncode(assertionResponse.signature)
|
||||||
if (assertionResponse.userHandle !== null) {
|
if (assertionResponse.userHandle !== null) {
|
||||||
json.response.userHandle = base64UrlEncode(assertionResponse.userHandle)
|
json.response.userHandle = base64UrlEncode(assertionResponse.userHandle)
|
||||||
|
|
@ -57,7 +64,10 @@ function publicKeyCredentialToJSON(
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBufferSource(data: Uint8Array): ArrayBuffer {
|
function toBufferSource(data: Uint8Array): ArrayBuffer {
|
||||||
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
|
return data.buffer.slice(
|
||||||
|
data.byteOffset,
|
||||||
|
data.byteOffset + data.byteLength
|
||||||
|
) as ArrayBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
function preparePublicKeyOptions(
|
function preparePublicKeyOptions(
|
||||||
|
|
@ -65,34 +75,48 @@ function preparePublicKeyOptions(
|
||||||
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
|
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
|
||||||
const prepared = { ...options }
|
const prepared = { ...options }
|
||||||
|
|
||||||
if ("challenge" in prepared && typeof prepared.challenge === "string") {
|
if ('challenge' in prepared && typeof prepared.challenge === 'string') {
|
||||||
prepared.challenge = toBufferSource(base64UrlDecode(prepared.challenge as unknown as string))
|
prepared.challenge = toBufferSource(
|
||||||
|
base64UrlDecode(prepared.challenge as unknown as string)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("user" in prepared) {
|
if ('user' in prepared) {
|
||||||
const creationOptions = prepared as PublicKeyCredentialCreationOptions
|
const creationOptions = prepared as PublicKeyCredentialCreationOptions
|
||||||
if (typeof creationOptions.user.id === "string") {
|
if (typeof creationOptions.user.id === 'string') {
|
||||||
creationOptions.user.id = toBufferSource(base64UrlDecode(creationOptions.user.id as unknown as string))
|
creationOptions.user.id = toBufferSource(
|
||||||
|
base64UrlDecode(creationOptions.user.id as unknown as string)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("excludeCredentials" in prepared) {
|
if ('excludeCredentials' in prepared) {
|
||||||
const creationOptions = prepared as PublicKeyCredentialCreationOptions
|
const creationOptions = prepared as PublicKeyCredentialCreationOptions
|
||||||
if (creationOptions.excludeCredentials !== undefined) {
|
if (creationOptions.excludeCredentials !== undefined) {
|
||||||
creationOptions.excludeCredentials = creationOptions.excludeCredentials.map((cred) => ({
|
creationOptions.excludeCredentials = creationOptions.excludeCredentials.map(
|
||||||
...cred,
|
(cred) => ({
|
||||||
id: typeof cred.id === "string" ? toBufferSource(base64UrlDecode(cred.id as unknown as string)) : cred.id,
|
...cred,
|
||||||
}))
|
id:
|
||||||
|
typeof cred.id === 'string'
|
||||||
|
? toBufferSource(base64UrlDecode(cred.id as unknown as string))
|
||||||
|
: cred.id,
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("allowCredentials" in prepared) {
|
if ('allowCredentials' in prepared) {
|
||||||
const requestOptions = prepared as PublicKeyCredentialRequestOptions
|
const requestOptions = prepared as PublicKeyCredentialRequestOptions
|
||||||
if (requestOptions.allowCredentials !== undefined) {
|
if (requestOptions.allowCredentials !== undefined) {
|
||||||
requestOptions.allowCredentials = requestOptions.allowCredentials.map((cred) => ({
|
requestOptions.allowCredentials = requestOptions.allowCredentials.map(
|
||||||
...cred,
|
(cred) => ({
|
||||||
id: typeof cred.id === "string" ? toBufferSource(base64UrlDecode(cred.id as unknown as string)) : cred.id,
|
...cred,
|
||||||
}))
|
id:
|
||||||
|
typeof cred.id === 'string'
|
||||||
|
? toBufferSource(base64UrlDecode(cred.id as unknown as string))
|
||||||
|
: cred.id,
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +144,7 @@ export async function register(
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isPublicKeyCredential(credential)) {
|
if (!isPublicKeyCredential(credential)) {
|
||||||
throw new Error("Failed to create credential")
|
throw new Error('Failed to create credential')
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentialJSON = publicKeyCredentialToJSON(credential)
|
const credentialJSON = publicKeyCredentialToJSON(credential)
|
||||||
|
|
@ -153,7 +177,7 @@ export async function login(username?: string): Promise<User> {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isPublicKeyCredential(credential)) {
|
if (!isPublicKeyCredential(credential)) {
|
||||||
throw new Error("Failed to get credential")
|
throw new Error('Failed to get credential')
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentialJSON = publicKeyCredentialToJSON(credential)
|
const credentialJSON = publicKeyCredentialToJSON(credential)
|
||||||
|
|
@ -174,9 +198,9 @@ export function logout(): void {
|
||||||
|
|
||||||
export function isWebAuthnSupported(): boolean {
|
export function isWebAuthnSupported(): boolean {
|
||||||
return (
|
return (
|
||||||
typeof window !== "undefined" &&
|
typeof window !== 'undefined' &&
|
||||||
typeof window.PublicKeyCredential !== "undefined" &&
|
typeof window.PublicKeyCredential !== 'undefined' &&
|
||||||
typeof navigator.credentials !== "undefined"
|
typeof navigator.credentials !== 'undefined'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,7 +223,7 @@ export async function isConditionalUIAvailable(): Promise<boolean> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pkc = PublicKeyCredential as unknown as PublicKeyCredentialStatic
|
const pkc = PublicKeyCredential as unknown as PublicKeyCredentialStatic
|
||||||
if (typeof pkc.isConditionalMediationAvailable === "function") {
|
if (typeof pkc.isConditionalMediationAvailable === 'function') {
|
||||||
return await pkc.isConditionalMediationAvailable()
|
return await pkc.isConditionalMediationAvailable()
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export * from "./auth.service"
|
export * from './auth.service'
|
||||||
export * from "./room.service"
|
export * from './room.service'
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,21 @@
|
||||||
* room.service.ts
|
* room.service.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from "../lib/api-client"
|
|
||||||
import type { Message, Room } from "../types"
|
|
||||||
import {
|
import {
|
||||||
setRooms,
|
|
||||||
addRoom,
|
|
||||||
setRoomMessages,
|
|
||||||
setHasMore,
|
|
||||||
$userId,
|
|
||||||
} from "../stores"
|
|
||||||
import {
|
|
||||||
getDecryptedMessages,
|
|
||||||
getDecryptedMessage,
|
|
||||||
saveDecryptedMessage,
|
|
||||||
cryptoService,
|
cryptoService,
|
||||||
} from "../crypto"
|
getDecryptedMessage,
|
||||||
|
getDecryptedMessages,
|
||||||
|
saveDecryptedMessage,
|
||||||
|
} from '../crypto'
|
||||||
|
import { api } from '../lib/api-client'
|
||||||
|
import {
|
||||||
|
$userId,
|
||||||
|
addRoom,
|
||||||
|
setHasMore,
|
||||||
|
setRoomMessages,
|
||||||
|
setRooms,
|
||||||
|
} from '../stores'
|
||||||
|
import type { Message, Room } from '../types'
|
||||||
|
|
||||||
export async function loadRooms(userId: string): Promise<Room[]> {
|
export async function loadRooms(userId: string): Promise<Room[]> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -32,7 +32,7 @@ export async function loadRooms(userId: string): Promise<Room[]> {
|
||||||
export async function createRoom(
|
export async function createRoom(
|
||||||
creatorId: string,
|
creatorId: string,
|
||||||
participantId: string,
|
participantId: string,
|
||||||
roomType: "direct" | "group" | "ephemeral" = "direct"
|
roomType: 'direct' | 'group' | 'ephemeral' = 'direct'
|
||||||
): Promise<Room | null> {
|
): Promise<Room | null> {
|
||||||
try {
|
try {
|
||||||
const room = await api.rooms.create({
|
const room = await api.rooms.create({
|
||||||
|
|
@ -42,8 +42,7 @@ export async function createRoom(
|
||||||
})
|
})
|
||||||
addRoom(room)
|
addRoom(room)
|
||||||
return room
|
return room
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.error("[RoomService] Failed to create room:", err)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +72,7 @@ export async function loadMessages(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = "[Encrypted - from another session]"
|
let content = '[Encrypted - from another session]'
|
||||||
const isOwnMessage = msg.sender_id === currentUserId
|
const isOwnMessage = msg.sender_id === currentUserId
|
||||||
|
|
||||||
if (isOwnMessage) {
|
if (isOwnMessage) {
|
||||||
|
|
@ -81,7 +80,7 @@ export async function loadMessages(
|
||||||
if (localCopy) {
|
if (localCopy) {
|
||||||
content = localCopy.content
|
content = localCopy.content
|
||||||
} else {
|
} else {
|
||||||
content = "[Your message - not stored locally]"
|
content = '[Your message - not stored locally]'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
|
@ -92,7 +91,7 @@ export async function loadMessages(
|
||||||
msg.header
|
msg.header
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
content = "[Encrypted - from another session]"
|
content = '[Encrypted - from another session]'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,7 +101,7 @@ export async function loadMessages(
|
||||||
sender_id: msg.sender_id,
|
sender_id: msg.sender_id,
|
||||||
sender_username: msg.sender_username,
|
sender_username: msg.sender_username,
|
||||||
content,
|
content,
|
||||||
status: "delivered" as const,
|
status: 'delivered' as const,
|
||||||
is_encrypted: true,
|
is_encrypted: true,
|
||||||
encrypted_content: msg.ciphertext,
|
encrypted_content: msg.ciphertext,
|
||||||
nonce: msg.nonce,
|
nonce: msg.nonce,
|
||||||
|
|
@ -111,7 +110,10 @@ export async function loadMessages(
|
||||||
updated_at: msg.created_at,
|
updated_at: msg.created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content.startsWith("[Encrypted") && !content.startsWith("[Your message")) {
|
if (
|
||||||
|
!content.startsWith('[Encrypted') &&
|
||||||
|
!content.startsWith('[Your message')
|
||||||
|
) {
|
||||||
void saveDecryptedMessage(decryptedMessage)
|
void saveDecryptedMessage(decryptedMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,13 +123,15 @@ export async function loadMessages(
|
||||||
const allMessages = [...localMessages, ...newMessages]
|
const allMessages = [...localMessages, ...newMessages]
|
||||||
const uniqueMessages = Array.from(
|
const uniqueMessages = Array.from(
|
||||||
new Map(allMessages.map((m) => [m.id, m])).values()
|
new Map(allMessages.map((m) => [m.id, m])).values()
|
||||||
).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
).sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
setRoomMessages(roomId, uniqueMessages)
|
setRoomMessages(roomId, uniqueMessages)
|
||||||
setHasMore(roomId, response.has_more)
|
setHasMore(roomId, response.has_more)
|
||||||
return uniqueMessages
|
return uniqueMessages
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.error("[RoomService] Failed to load messages:", err)
|
|
||||||
const localMessages = await getDecryptedMessages(roomId, limit)
|
const localMessages = await getDecryptedMessages(roomId, limit)
|
||||||
if (localMessages.length > 0) {
|
if (localMessages.length > 0) {
|
||||||
setRoomMessages(roomId, localMessages)
|
setRoomMessages(roomId, localMessages)
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,18 @@
|
||||||
* Manages current user and authentication status
|
* Manages current user and authentication status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { atom, computed } from "nanostores"
|
import { persistentAtom } from '@nanostores/persistent'
|
||||||
import { persistentAtom } from "@nanostores/persistent"
|
import { atom, computed } from 'nanostores'
|
||||||
import type { User, Session } from "../types"
|
import type { Session, User } from '../types'
|
||||||
|
|
||||||
export const $currentUser = atom<User | null>(null)
|
export const $currentUser = atom<User | null>(null)
|
||||||
|
|
||||||
const $userIdRaw = persistentAtom<string>("chat:user_id", "", {
|
const $userIdRaw = persistentAtom<string>('chat:user_id', '', {
|
||||||
encode: (value: string) => value,
|
encode: (value: string) => value,
|
||||||
decode: (value: string) => value,
|
decode: (value: string) => value,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const $userId = computed($userIdRaw, (id) => id !== "" ? id : null)
|
export const $userId = computed($userIdRaw, (id) => (id !== '' ? id : null))
|
||||||
|
|
||||||
export const $isAuthenticated = computed($userId, (id) => id !== null)
|
export const $isAuthenticated = computed($userId, (id) => id !== null)
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ export const $session = atom<Session | null>(null)
|
||||||
|
|
||||||
export function setCurrentUser(user: User | null): void {
|
export function setCurrentUser(user: User | null): void {
|
||||||
$currentUser.set(user)
|
$currentUser.set(user)
|
||||||
$userIdRaw.set(user?.id ?? "")
|
$userIdRaw.set(user?.id ?? '')
|
||||||
|
|
||||||
if (user !== null) {
|
if (user !== null) {
|
||||||
$session.set({
|
$session.set({
|
||||||
|
|
@ -39,7 +39,7 @@ export function setCurrentUser(user: User | null): void {
|
||||||
|
|
||||||
export function logout(): void {
|
export function logout(): void {
|
||||||
$currentUser.set(null)
|
$currentUser.set(null)
|
||||||
$userIdRaw.set("")
|
$userIdRaw.set('')
|
||||||
$session.set(null)
|
$session.set(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export * from "./auth.store"
|
export * from './auth.store'
|
||||||
export * from "./session.store"
|
export * from './messages.store'
|
||||||
export * from "./settings.store"
|
export * from './presence.store'
|
||||||
export * from "./ui.store"
|
export * from './rooms.store'
|
||||||
export * from "./rooms.store"
|
export * from './session.store'
|
||||||
export * from "./messages.store"
|
export * from './settings.store'
|
||||||
export * from "./presence.store"
|
export * from './typing.store'
|
||||||
export * from "./typing.store"
|
export * from './ui.store'
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
* Manages message cache per room
|
* Manages message cache per room
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { map, computed } from "nanostores"
|
import { computed, map } from 'nanostores'
|
||||||
import type { Message, MessageStatus } from "../types"
|
import type { Message, MessageStatus } from '../types'
|
||||||
import { $activeRoomId } from "./rooms.store"
|
import { $activeRoomId } from './rooms.store'
|
||||||
|
|
||||||
export const $messagesByRoom = map<Record<string, Message[]>>({})
|
export const $messagesByRoom = map<Record<string, Message[]>>({})
|
||||||
|
|
||||||
|
|
@ -17,12 +17,12 @@ export const $oldestMessageIdByRoom = map<Record<string, string | undefined>>({}
|
||||||
|
|
||||||
export const $activeRoomMessages = computed(
|
export const $activeRoomMessages = computed(
|
||||||
[$messagesByRoom, $activeRoomId],
|
[$messagesByRoom, $activeRoomId],
|
||||||
(messages, roomId) => (roomId ? messages[roomId] ?? [] : [])
|
(messages, roomId) => (roomId ? (messages[roomId] ?? []) : [])
|
||||||
)
|
)
|
||||||
|
|
||||||
export const $activeRoomPendingMessages = computed(
|
export const $activeRoomPendingMessages = computed(
|
||||||
[$pendingMessages, $activeRoomId],
|
[$pendingMessages, $activeRoomId],
|
||||||
(pending, roomId) => (roomId ? pending[roomId] ?? [] : [])
|
(pending, roomId) => (roomId ? (pending[roomId] ?? []) : [])
|
||||||
)
|
)
|
||||||
|
|
||||||
export function addMessage(roomId: string, message: Message): void {
|
export function addMessage(roomId: string, message: Message): void {
|
||||||
|
|
@ -142,7 +142,10 @@ export function clearAllMessages(): void {
|
||||||
$oldestMessageIdByRoom.set({})
|
$oldestMessageIdByRoom.set({})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessageById(roomId: string, messageId: string): Message | null {
|
export function getMessageById(
|
||||||
|
roomId: string,
|
||||||
|
messageId: string
|
||||||
|
): Message | null {
|
||||||
const messages = $messagesByRoom.get()[roomId]
|
const messages = $messagesByRoom.get()[roomId]
|
||||||
return messages?.find((m) => m.id === messageId) ?? null
|
return messages?.find((m) => m.id === messageId) ?? null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
* Manages user online/offline status
|
* Manages user online/offline status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { map } from "nanostores"
|
import { map } from 'nanostores'
|
||||||
import type { PresenceStatus, UserPresence } from "../types"
|
import type { PresenceStatus, UserPresence } from '../types'
|
||||||
|
|
||||||
export const $presenceByUser = map<Record<string, UserPresence>>({})
|
export const $presenceByUser = map<Record<string, UserPresence>>({})
|
||||||
|
|
||||||
|
|
@ -38,18 +38,18 @@ export function getUserPresence(userId: string): UserPresence | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserStatus(userId: string): PresenceStatus {
|
export function getUserStatus(userId: string): PresenceStatus {
|
||||||
return $presenceByUser.get()[userId]?.status ?? "offline"
|
return $presenceByUser.get()[userId]?.status ?? 'offline'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUserOnline(userId: string): boolean {
|
export function isUserOnline(userId: string): boolean {
|
||||||
const status = getUserStatus(userId)
|
const status = getUserStatus(userId)
|
||||||
return status === "online"
|
return status === 'online'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOnlineUserIds(): string[] {
|
export function getOnlineUserIds(): string[] {
|
||||||
const presences = $presenceByUser.get()
|
const presences = $presenceByUser.get()
|
||||||
return Object.entries(presences)
|
return Object.entries(presences)
|
||||||
.filter(([_, p]) => p.status === "online")
|
.filter(([_, p]) => p.status === 'online')
|
||||||
.map(([id]) => id)
|
.map(([id]) => id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,15 @@
|
||||||
* Manages chat rooms and active conversation state
|
* Manages chat rooms and active conversation state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { atom, computed, map } from "nanostores"
|
import { atom, computed, map } from 'nanostores'
|
||||||
import type { Room } from "../types"
|
import type { Room } from '../types'
|
||||||
|
|
||||||
export const $rooms = map<Record<string, Room>>({})
|
export const $rooms = map<Record<string, Room>>({})
|
||||||
|
|
||||||
export const $activeRoomId = atom<string | null>(null)
|
export const $activeRoomId = atom<string | null>(null)
|
||||||
|
|
||||||
export const $activeRoom = computed(
|
export const $activeRoom = computed([$rooms, $activeRoomId], (rooms, activeId) =>
|
||||||
[$rooms, $activeRoomId],
|
activeId ? (rooms[activeId] ?? null) : null
|
||||||
(rooms, activeId) => (activeId ? rooms[activeId] ?? null : null)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const $roomList = computed($rooms, (rooms) =>
|
export const $roomList = computed($rooms, (rooms) =>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Stores authentication tokens in localStorage
|
* Stores authentication tokens in localStorage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { persistentMap, persistentAtom } from "@nanostores/persistent"
|
import { persistentAtom, persistentMap } from '@nanostores/persistent'
|
||||||
|
|
||||||
interface SessionTokens {
|
interface SessionTokens {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
|
|
@ -12,13 +12,13 @@ interface SessionTokens {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TOKENS: SessionTokens = {
|
const DEFAULT_TOKENS: SessionTokens = {
|
||||||
accessToken: "",
|
accessToken: '',
|
||||||
refreshToken: "",
|
refreshToken: '',
|
||||||
expiresAt: 0,
|
expiresAt: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const $sessionTokens = persistentMap<SessionTokens>(
|
export const $sessionTokens = persistentMap<SessionTokens>(
|
||||||
"chat:session:",
|
'chat:session:',
|
||||||
DEFAULT_TOKENS,
|
DEFAULT_TOKENS,
|
||||||
{
|
{
|
||||||
encode: JSON.stringify,
|
encode: JSON.stringify,
|
||||||
|
|
@ -26,14 +26,10 @@ export const $sessionTokens = persistentMap<SessionTokens>(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const $lastActivity = persistentAtom<string>(
|
export const $lastActivity = persistentAtom<string>('chat:last_activity', '', {
|
||||||
"chat:last_activity",
|
encode: String,
|
||||||
"",
|
decode: String,
|
||||||
{
|
})
|
||||||
encode: String,
|
|
||||||
decode: String,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export function setSessionTokens(
|
export function setSessionTokens(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
|
@ -55,7 +51,7 @@ export function clearSessionTokens(): void {
|
||||||
|
|
||||||
export function isSessionValid(): boolean {
|
export function isSessionValid(): boolean {
|
||||||
const tokens = $sessionTokens.get()
|
const tokens = $sessionTokens.get()
|
||||||
if (tokens.accessToken === "") {
|
if (tokens.accessToken === '') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return tokens.expiresAt > Date.now()
|
return tokens.expiresAt > Date.now()
|
||||||
|
|
@ -70,7 +66,7 @@ export function getAccessToken(): string | null {
|
||||||
|
|
||||||
export function getRefreshToken(): string | null {
|
export function getRefreshToken(): string | null {
|
||||||
const tokens = $sessionTokens.get()
|
const tokens = $sessionTokens.get()
|
||||||
return tokens.refreshToken !== "" ? tokens.refreshToken : null
|
return tokens.refreshToken !== '' ? tokens.refreshToken : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLastActivity(): void {
|
export function updateLastActivity(): void {
|
||||||
|
|
@ -79,5 +75,5 @@ export function updateLastActivity(): void {
|
||||||
|
|
||||||
export function getLastActivity(): Date | null {
|
export function getLastActivity(): Date | null {
|
||||||
const activity = $lastActivity.get()
|
const activity = $lastActivity.get()
|
||||||
return activity !== "" ? new Date(activity) : null
|
return activity !== '' ? new Date(activity) : null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
* Stores user preferences in localStorage
|
* Stores user preferences in localStorage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { persistentMap } from "@nanostores/persistent"
|
import { persistentMap } from '@nanostores/persistent'
|
||||||
import { computed } from "nanostores"
|
import { computed } from 'nanostores'
|
||||||
|
|
||||||
type Theme = "dark" | "light"
|
type Theme = 'dark' | 'light'
|
||||||
type FontSize = "small" | "medium" | "large"
|
type FontSize = 'small' | 'medium' | 'large'
|
||||||
type NotificationSound = "retro" | "subtle" | "none"
|
type NotificationSound = 'retro' | 'subtle' | 'none'
|
||||||
|
|
||||||
interface UserSettings {
|
interface UserSettings {
|
||||||
theme: Theme
|
theme: Theme
|
||||||
|
|
@ -24,10 +24,10 @@ interface UserSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
theme: "dark",
|
theme: 'dark',
|
||||||
fontSize: "medium",
|
fontSize: 'medium',
|
||||||
soundEnabled: true,
|
soundEnabled: true,
|
||||||
notificationSound: "retro",
|
notificationSound: 'retro',
|
||||||
desktopNotifications: true,
|
desktopNotifications: true,
|
||||||
showTypingIndicators: true,
|
showTypingIndicators: true,
|
||||||
showReadReceipts: true,
|
showReadReceipts: true,
|
||||||
|
|
@ -37,7 +37,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const $settings = persistentMap<UserSettings>(
|
export const $settings = persistentMap<UserSettings>(
|
||||||
"chat:settings:",
|
'chat:settings:',
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
{
|
{
|
||||||
encode: JSON.stringify,
|
encode: JSON.stringify,
|
||||||
|
|
@ -50,43 +50,43 @@ export const $theme = computed($settings, (settings) => settings.theme)
|
||||||
export const $fontSize = computed($settings, (settings) => settings.fontSize)
|
export const $fontSize = computed($settings, (settings) => settings.fontSize)
|
||||||
|
|
||||||
export function setTheme(theme: Theme): void {
|
export function setTheme(theme: Theme): void {
|
||||||
$settings.setKey("theme", theme)
|
$settings.setKey('theme', theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setFontSize(fontSize: FontSize): void {
|
export function setFontSize(fontSize: FontSize): void {
|
||||||
$settings.setKey("fontSize", fontSize)
|
$settings.setKey('fontSize', fontSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSoundEnabled(enabled: boolean): void {
|
export function setSoundEnabled(enabled: boolean): void {
|
||||||
$settings.setKey("soundEnabled", enabled)
|
$settings.setKey('soundEnabled', enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setNotificationSound(sound: NotificationSound): void {
|
export function setNotificationSound(sound: NotificationSound): void {
|
||||||
$settings.setKey("notificationSound", sound)
|
$settings.setKey('notificationSound', sound)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setDesktopNotifications(enabled: boolean): void {
|
export function setDesktopNotifications(enabled: boolean): void {
|
||||||
$settings.setKey("desktopNotifications", enabled)
|
$settings.setKey('desktopNotifications', enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setShowTypingIndicators(show: boolean): void {
|
export function setShowTypingIndicators(show: boolean): void {
|
||||||
$settings.setKey("showTypingIndicators", show)
|
$settings.setKey('showTypingIndicators', show)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setShowReadReceipts(show: boolean): void {
|
export function setShowReadReceipts(show: boolean): void {
|
||||||
$settings.setKey("showReadReceipts", show)
|
$settings.setKey('showReadReceipts', show)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setShowOnlineStatus(show: boolean): void {
|
export function setShowOnlineStatus(show: boolean): void {
|
||||||
$settings.setKey("showOnlineStatus", show)
|
$settings.setKey('showOnlineStatus', show)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setEnterToSend(enabled: boolean): void {
|
export function setEnterToSend(enabled: boolean): void {
|
||||||
$settings.setKey("enterToSend", enabled)
|
$settings.setKey('enterToSend', enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCompactMode(enabled: boolean): void {
|
export function setCompactMode(enabled: boolean): void {
|
||||||
$settings.setKey("compactMode", enabled)
|
$settings.setKey('compactMode', enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetSettings(): void {
|
export function resetSettings(): void {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
* Manages typing status per room
|
* Manages typing status per room
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { map, computed } from "nanostores"
|
import { computed, map } from 'nanostores'
|
||||||
import type { TypingState } from "../types"
|
import type { TypingState } from '../types'
|
||||||
import { $activeRoomId } from "./rooms.store"
|
import { $activeRoomId } from './rooms.store'
|
||||||
|
|
||||||
const TYPING_TIMEOUT_MS = 5000
|
const TYPING_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ const typingTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
export const $activeRoomTyping = computed(
|
export const $activeRoomTyping = computed(
|
||||||
[$typingByRoom, $activeRoomId],
|
[$typingByRoom, $activeRoomId],
|
||||||
(typing, roomId) => (roomId ? typing[roomId] ?? [] : [])
|
(typing, roomId) => (roomId ? (typing[roomId] ?? []) : [])
|
||||||
)
|
)
|
||||||
|
|
||||||
export const $activeRoomTypingUsernames = computed(
|
export const $activeRoomTypingUsernames = computed(
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,20 @@
|
||||||
* Manages sidebar, modals, and other UI state
|
* Manages sidebar, modals, and other UI state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { atom, computed } from "nanostores"
|
import { atom, computed } from 'nanostores'
|
||||||
|
|
||||||
type ModalType =
|
type ModalType =
|
||||||
| "new-conversation"
|
| 'new-conversation'
|
||||||
| "user-search"
|
| 'user-search'
|
||||||
| "settings"
|
| 'settings'
|
||||||
| "profile"
|
| 'profile'
|
||||||
| "encryption-info"
|
| 'encryption-info'
|
||||||
| "confirm-logout"
|
| 'confirm-logout'
|
||||||
| null
|
| null
|
||||||
|
|
||||||
interface ToastNotification {
|
interface ToastNotification {
|
||||||
id: string
|
id: string
|
||||||
variant: "info" | "success" | "warning" | "error"
|
variant: 'info' | 'success' | 'warning' | 'error'
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
duration?: number
|
duration?: number
|
||||||
|
|
@ -34,16 +34,13 @@ export const $toasts = atom<ToastNotification[]>([])
|
||||||
|
|
||||||
export const $isLoading = atom<boolean>(false)
|
export const $isLoading = atom<boolean>(false)
|
||||||
|
|
||||||
export const $loadingMessage = atom<string>("")
|
export const $loadingMessage = atom<string>('')
|
||||||
|
|
||||||
export const $isMobile = atom<boolean>(false)
|
export const $isMobile = atom<boolean>(false)
|
||||||
|
|
||||||
export const $searchQuery = atom<string>("")
|
export const $searchQuery = atom<string>('')
|
||||||
|
|
||||||
export const $hasActiveModal = computed(
|
export const $hasActiveModal = computed($activeModal, (modal) => modal !== null)
|
||||||
$activeModal,
|
|
||||||
(modal) => modal !== null
|
|
||||||
)
|
|
||||||
|
|
||||||
let toastIdCounter = 0
|
let toastIdCounter = 0
|
||||||
|
|
||||||
|
|
@ -74,14 +71,15 @@ export function closeModal(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(
|
export function showToast(
|
||||||
variant: ToastNotification["variant"],
|
variant: ToastNotification['variant'],
|
||||||
title: string,
|
title: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
duration?: number
|
duration?: number
|
||||||
): string {
|
): string {
|
||||||
const existingToasts = $toasts.get()
|
const existingToasts = $toasts.get()
|
||||||
const duplicate = existingToasts.find(
|
const duplicate = existingToasts.find(
|
||||||
(t) => t.title === title && t.description === description && t.variant === variant
|
(t) =>
|
||||||
|
t.title === title && t.description === description && t.variant === variant
|
||||||
)
|
)
|
||||||
|
|
||||||
if (duplicate !== undefined) {
|
if (duplicate !== undefined) {
|
||||||
|
|
@ -119,7 +117,7 @@ export function clearAllToasts(): void {
|
||||||
|
|
||||||
export function setLoading(loading: boolean, message?: string): void {
|
export function setLoading(loading: boolean, message?: string): void {
|
||||||
$isLoading.set(loading)
|
$isLoading.set(loading)
|
||||||
$loadingMessage.set(message ?? "")
|
$loadingMessage.set(message ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setIsMobile(isMobile: boolean): void {
|
export function setIsMobile(isMobile: boolean): void {
|
||||||
|
|
@ -131,7 +129,7 @@ export function setSearchQuery(query: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSearchQuery(): void {
|
export function clearSearchQuery(): void {
|
||||||
$searchQuery.set("")
|
$searchQuery.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ModalType, ToastNotification }
|
export type { ModalType, ToastNotification }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export class ApiError extends Error {
|
||||||
message?: string
|
message?: string
|
||||||
) {
|
) {
|
||||||
super(message ?? `API Error: ${status}`)
|
super(message ?? `API Error: ${status}`)
|
||||||
this.name = "ApiError"
|
this.name = 'ApiError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,15 @@ export interface AuthenticationCompleteRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebAuthnOptions {
|
export interface WebAuthnOptions {
|
||||||
publicKey: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions
|
publicKey:
|
||||||
|
| PublicKeyCredentialCreationOptions
|
||||||
|
| PublicKeyCredentialRequestOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicKeyCredentialJSON {
|
export interface PublicKeyCredentialJSON {
|
||||||
id: string
|
id: string
|
||||||
rawId: string
|
rawId: string
|
||||||
type: "public-key"
|
type: 'public-key'
|
||||||
response: {
|
response: {
|
||||||
clientDataJSON: string
|
clientDataJSON: string
|
||||||
attestationObject?: string
|
attestationObject?: string
|
||||||
|
|
@ -55,7 +57,7 @@ export interface PublicKeyCredentialJSON {
|
||||||
signature?: string
|
signature?: string
|
||||||
userHandle?: string
|
userHandle?: string
|
||||||
}
|
}
|
||||||
authenticatorAttachment?: "platform" | "cross-platform"
|
authenticatorAttachment?: 'platform' | 'cross-platform'
|
||||||
clientExtensionResults: Record<string, unknown>
|
clientExtensionResults: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// chat.ts
|
// chat.ts
|
||||||
// ===================
|
// ===================
|
||||||
export type MessageStatus = "sending" | "sent" | "delivered" | "read" | "failed"
|
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||||
|
|
||||||
export type PresenceStatus = "online" | "away" | "offline"
|
export type PresenceStatus = 'online' | 'away' | 'offline'
|
||||||
|
|
||||||
export type RoomType = "direct" | "group" | "ephemeral"
|
export type RoomType = 'direct' | 'group' | 'ephemeral'
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -41,7 +41,7 @@ export interface Participant {
|
||||||
user_id: string
|
user_id: string
|
||||||
username: string
|
username: string
|
||||||
display_name: string
|
display_name: string
|
||||||
role: "owner" | "admin" | "member"
|
role: 'owner' | 'admin' | 'member'
|
||||||
joined_at: string
|
joined_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// components.ts
|
// components.ts
|
||||||
// ===================
|
// ===================
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from 'solid-js'
|
||||||
import type { MessageStatus, PresenceStatus } from "./chat"
|
import type { MessageStatus, PresenceStatus } from './chat'
|
||||||
|
|
||||||
export type Size = "xs" | "sm" | "md" | "lg" | "xl"
|
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
|
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||||
|
|
||||||
export type BadgeVariant = "default" | "primary" | "success" | "warning" | "error"
|
export type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
export interface ButtonProps {
|
export interface ButtonProps {
|
||||||
variant?: ButtonVariant
|
variant?: ButtonVariant
|
||||||
|
|
@ -19,14 +19,14 @@ export interface ButtonProps {
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
leftIcon?: JSX.Element
|
leftIcon?: JSX.Element
|
||||||
rightIcon?: JSX.Element
|
rightIcon?: JSX.Element
|
||||||
type?: "button" | "submit" | "reset"
|
type?: 'button' | 'submit' | 'reset'
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
class?: string
|
class?: string
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InputProps {
|
export interface InputProps {
|
||||||
type?: "text" | "email" | "password" | "search"
|
type?: 'text' | 'email' | 'password' | 'search'
|
||||||
name?: string
|
name?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
value?: string
|
value?: string
|
||||||
|
|
@ -93,7 +93,7 @@ export interface ModalProps {
|
||||||
|
|
||||||
export interface ToastProps {
|
export interface ToastProps {
|
||||||
id: string
|
id: string
|
||||||
variant: "info" | "success" | "warning" | "error"
|
variant: 'info' | 'success' | 'warning' | 'error'
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
duration?: number
|
duration?: number
|
||||||
|
|
@ -109,7 +109,7 @@ export interface SpinnerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkeletonProps {
|
export interface SkeletonProps {
|
||||||
variant?: "text" | "circular" | "rectangular"
|
variant?: 'text' | 'circular' | 'rectangular'
|
||||||
width?: string
|
width?: string
|
||||||
height?: string
|
height?: string
|
||||||
lines?: number
|
lines?: number
|
||||||
|
|
@ -118,7 +118,7 @@ export interface SkeletonProps {
|
||||||
|
|
||||||
export interface TooltipProps {
|
export interface TooltipProps {
|
||||||
content: string
|
content: string
|
||||||
position?: "top" | "bottom" | "left" | "right"
|
position?: 'top' | 'bottom' | 'left' | 'right'
|
||||||
delay?: number
|
delay?: number
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +135,7 @@ export interface DropdownProps {
|
||||||
items: DropdownItem[]
|
items: DropdownItem[]
|
||||||
onSelect: (value: string) => void
|
onSelect: (value: string) => void
|
||||||
trigger: JSX.Element
|
trigger: JSX.Element
|
||||||
align?: "left" | "right"
|
align?: 'left' | 'right'
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +143,7 @@ export interface IconButtonProps {
|
||||||
icon: JSX.Element
|
icon: JSX.Element
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
size?: Size
|
size?: Size
|
||||||
variant?: "ghost" | "subtle"
|
variant?: 'ghost' | 'subtle'
|
||||||
ariaLabel: string
|
ariaLabel: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
|
|
||||||
|
|
@ -3,40 +3,40 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
User,
|
|
||||||
Message,
|
|
||||||
Room,
|
|
||||||
MessageStatus,
|
|
||||||
PresenceStatus,
|
|
||||||
RoomType,
|
|
||||||
PreKeyBundle,
|
|
||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ValidationError,
|
|
||||||
WSMessage,
|
|
||||||
EncryptedMessageWS,
|
EncryptedMessageWS,
|
||||||
TypingIndicatorWS,
|
ErrorMessageWS,
|
||||||
|
HeartbeatWS,
|
||||||
|
Message,
|
||||||
|
MessageSentWS,
|
||||||
|
MessageStatus,
|
||||||
|
PreKeyBundle,
|
||||||
|
PresenceStatus,
|
||||||
PresenceUpdateWS,
|
PresenceUpdateWS,
|
||||||
ReadReceiptWS,
|
ReadReceiptWS,
|
||||||
HeartbeatWS,
|
Room,
|
||||||
ErrorMessageWS,
|
|
||||||
RoomCreatedWS,
|
RoomCreatedWS,
|
||||||
MessageSentWS,
|
RoomType,
|
||||||
} from "./index"
|
TypingIndicatorWS,
|
||||||
|
User,
|
||||||
|
ValidationError,
|
||||||
|
WSMessage,
|
||||||
|
} from './index'
|
||||||
|
|
||||||
export function isString(value: unknown): value is string {
|
export function isString(value: unknown): value is string {
|
||||||
return typeof value === "string"
|
return typeof value === 'string'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNumber(value: unknown): value is number {
|
export function isNumber(value: unknown): value is number {
|
||||||
return typeof value === "number" && !Number.isNaN(value)
|
return typeof value === 'number' && !Number.isNaN(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBoolean(value: unknown): value is boolean {
|
export function isBoolean(value: unknown): value is boolean {
|
||||||
return typeof value === "boolean"
|
return typeof value === 'boolean'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isArray(value: unknown): value is unknown[] {
|
export function isArray(value: unknown): value is unknown[] {
|
||||||
|
|
@ -62,20 +62,20 @@ export function isUser(value: unknown): value is User {
|
||||||
|
|
||||||
export function isMessageStatus(value: unknown): value is MessageStatus {
|
export function isMessageStatus(value: unknown): value is MessageStatus {
|
||||||
return (
|
return (
|
||||||
value === "sending" ||
|
value === 'sending' ||
|
||||||
value === "sent" ||
|
value === 'sent' ||
|
||||||
value === "delivered" ||
|
value === 'delivered' ||
|
||||||
value === "read" ||
|
value === 'read' ||
|
||||||
value === "failed"
|
value === 'failed'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPresenceStatus(value: unknown): value is PresenceStatus {
|
export function isPresenceStatus(value: unknown): value is PresenceStatus {
|
||||||
return value === "online" || value === "away" || value === "offline"
|
return value === 'online' || value === 'away' || value === 'offline'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRoomType(value: unknown): value is RoomType {
|
export function isRoomType(value: unknown): value is RoomType {
|
||||||
return value === "direct" || value === "group" || value === "ephemeral"
|
return value === 'direct' || value === 'group' || value === 'ephemeral'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMessage(value: unknown): value is Message {
|
export function isMessage(value: unknown): value is Message {
|
||||||
|
|
@ -124,7 +124,9 @@ export function isValidationError(value: unknown): value is ValidationError {
|
||||||
return isNonEmptyString(value.field) && isNonEmptyString(value.message)
|
return isNonEmptyString(value.field) && isNonEmptyString(value.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidationErrorArray(value: unknown): value is ValidationError[] {
|
export function isValidationErrorArray(
|
||||||
|
value: unknown
|
||||||
|
): value is ValidationError[] {
|
||||||
return isArray(value) && value.every(isValidationError)
|
return isArray(value) && value.every(isValidationError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,11 +136,13 @@ export function isApiErrorResponse(value: unknown): value is ApiErrorResponse {
|
||||||
return isString(value.detail) || isValidationErrorArray(value.detail)
|
return isString(value.detail) || isValidationErrorArray(value.detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEncryptedMessageWS(value: unknown): value is EncryptedMessageWS {
|
export function isEncryptedMessageWS(
|
||||||
|
value: unknown
|
||||||
|
): value is EncryptedMessageWS {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
value.type === "encrypted_message" &&
|
value.type === 'encrypted_message' &&
|
||||||
isNonEmptyString(value.message_id) &&
|
isNonEmptyString(value.message_id) &&
|
||||||
isNonEmptyString(value.sender_id) &&
|
isNonEmptyString(value.sender_id) &&
|
||||||
isNonEmptyString(value.recipient_id) &&
|
isNonEmptyString(value.recipient_id) &&
|
||||||
|
|
@ -155,7 +159,7 @@ export function isTypingIndicatorWS(value: unknown): value is TypingIndicatorWS
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
value.type === "typing" &&
|
value.type === 'typing' &&
|
||||||
isNonEmptyString(value.user_id) &&
|
isNonEmptyString(value.user_id) &&
|
||||||
isNonEmptyString(value.room_id) &&
|
isNonEmptyString(value.room_id) &&
|
||||||
isBoolean(value.is_typing) &&
|
isBoolean(value.is_typing) &&
|
||||||
|
|
@ -167,7 +171,7 @@ export function isPresenceUpdateWS(value: unknown): value is PresenceUpdateWS {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
value.type === "presence" &&
|
value.type === 'presence' &&
|
||||||
isNonEmptyString(value.user_id) &&
|
isNonEmptyString(value.user_id) &&
|
||||||
isPresenceStatus(value.status) &&
|
isPresenceStatus(value.status) &&
|
||||||
isNonEmptyString(value.last_seen)
|
isNonEmptyString(value.last_seen)
|
||||||
|
|
@ -178,7 +182,7 @@ export function isReadReceiptWS(value: unknown): value is ReadReceiptWS {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
value.type === "receipt" &&
|
value.type === 'receipt' &&
|
||||||
isNonEmptyString(value.message_id) &&
|
isNonEmptyString(value.message_id) &&
|
||||||
isNonEmptyString(value.room_id) &&
|
isNonEmptyString(value.room_id) &&
|
||||||
isNonEmptyString(value.user_id) &&
|
isNonEmptyString(value.user_id) &&
|
||||||
|
|
@ -189,14 +193,14 @@ export function isReadReceiptWS(value: unknown): value is ReadReceiptWS {
|
||||||
export function isHeartbeatWS(value: unknown): value is HeartbeatWS {
|
export function isHeartbeatWS(value: unknown): value is HeartbeatWS {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
|
|
||||||
return value.type === "heartbeat"
|
return value.type === 'heartbeat'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isErrorMessageWS(value: unknown): value is ErrorMessageWS {
|
export function isErrorMessageWS(value: unknown): value is ErrorMessageWS {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
value.type === "error" &&
|
value.type === 'error' &&
|
||||||
isNonEmptyString(value.error_code) &&
|
isNonEmptyString(value.error_code) &&
|
||||||
isNonEmptyString(value.error_message)
|
isNonEmptyString(value.error_message)
|
||||||
)
|
)
|
||||||
|
|
@ -206,7 +210,7 @@ export function isRoomCreatedWS(value: unknown): value is RoomCreatedWS {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
value.type === "room_created" &&
|
value.type === 'room_created' &&
|
||||||
isNonEmptyString(value.room_id) &&
|
isNonEmptyString(value.room_id) &&
|
||||||
isNonEmptyString(value.room_type) &&
|
isNonEmptyString(value.room_type) &&
|
||||||
isArray(value.participants) &&
|
isArray(value.participants) &&
|
||||||
|
|
@ -220,7 +224,7 @@ export function isMessageSentWS(value: unknown): value is MessageSentWS {
|
||||||
if (!isObject(value)) return false
|
if (!isObject(value)) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
value.type === "message_sent" &&
|
value.type === 'message_sent' &&
|
||||||
isNonEmptyString(value.temp_id) &&
|
isNonEmptyString(value.temp_id) &&
|
||||||
isNonEmptyString(value.message_id) &&
|
isNonEmptyString(value.message_id) &&
|
||||||
isNonEmptyString(value.room_id) &&
|
isNonEmptyString(value.room_id) &&
|
||||||
|
|
@ -250,6 +254,8 @@ export function isDefined<T>(value: T | null | undefined): value is T {
|
||||||
return value !== null && value !== undefined
|
return value !== null && value !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPublicKeyCredential(value: Credential | null): value is PublicKeyCredential {
|
export function isPublicKeyCredential(
|
||||||
return value !== null && value.type === "public-key"
|
value: Credential | null
|
||||||
|
): value is PublicKeyCredential {
|
||||||
|
return value !== null && value.type === 'public-key'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export * from "./api"
|
export * from './api'
|
||||||
export * from "./auth"
|
export * from './auth'
|
||||||
export * from "./chat"
|
export * from './chat'
|
||||||
export * from "./websocket"
|
export * from './components'
|
||||||
export * from "./encryption"
|
export * from './encryption'
|
||||||
export * from "./components"
|
export * from './guards'
|
||||||
export * from "./guards"
|
export * from './websocket'
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,17 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// websockets.ts
|
// websockets.ts
|
||||||
// ===================
|
// ===================
|
||||||
import type { PresenceStatus } from "./chat"
|
import type { PresenceStatus } from './chat'
|
||||||
|
|
||||||
export type WSMessageType =
|
export type WSMessageType =
|
||||||
| "encrypted_message"
|
| 'encrypted_message'
|
||||||
| "typing"
|
| 'typing'
|
||||||
| "presence"
|
| 'presence'
|
||||||
| "receipt"
|
| 'receipt'
|
||||||
| "heartbeat"
|
| 'heartbeat'
|
||||||
| "error"
|
| 'error'
|
||||||
| "room_created"
|
| 'room_created'
|
||||||
| "message_sent"
|
| 'message_sent'
|
||||||
|
|
||||||
export interface BaseWSMessage {
|
export interface BaseWSMessage {
|
||||||
type: WSMessageType
|
type: WSMessageType
|
||||||
|
|
@ -20,7 +20,7 @@ export interface BaseWSMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EncryptedMessageWS extends BaseWSMessage {
|
export interface EncryptedMessageWS extends BaseWSMessage {
|
||||||
type: "encrypted_message"
|
type: 'encrypted_message'
|
||||||
message_id: string
|
message_id: string
|
||||||
sender_id: string
|
sender_id: string
|
||||||
recipient_id: string
|
recipient_id: string
|
||||||
|
|
@ -33,7 +33,7 @@ export interface EncryptedMessageWS extends BaseWSMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TypingIndicatorWS extends BaseWSMessage {
|
export interface TypingIndicatorWS extends BaseWSMessage {
|
||||||
type: "typing"
|
type: 'typing'
|
||||||
user_id: string
|
user_id: string
|
||||||
room_id: string
|
room_id: string
|
||||||
is_typing: boolean
|
is_typing: boolean
|
||||||
|
|
@ -41,14 +41,14 @@ export interface TypingIndicatorWS extends BaseWSMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PresenceUpdateWS extends BaseWSMessage {
|
export interface PresenceUpdateWS extends BaseWSMessage {
|
||||||
type: "presence"
|
type: 'presence'
|
||||||
user_id: string
|
user_id: string
|
||||||
status: PresenceStatus
|
status: PresenceStatus
|
||||||
last_seen: string
|
last_seen: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadReceiptWS extends BaseWSMessage {
|
export interface ReadReceiptWS extends BaseWSMessage {
|
||||||
type: "receipt"
|
type: 'receipt'
|
||||||
message_id: string
|
message_id: string
|
||||||
room_id: string
|
room_id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
|
|
@ -56,18 +56,18 @@ export interface ReadReceiptWS extends BaseWSMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeartbeatWS extends BaseWSMessage {
|
export interface HeartbeatWS extends BaseWSMessage {
|
||||||
type: "heartbeat"
|
type: 'heartbeat'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorMessageWS extends BaseWSMessage {
|
export interface ErrorMessageWS extends BaseWSMessage {
|
||||||
type: "error"
|
type: 'error'
|
||||||
error_code: string
|
error_code: string
|
||||||
error_message: string
|
error_message: string
|
||||||
details?: Record<string, unknown>
|
details?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomCreatedWS extends BaseWSMessage {
|
export interface RoomCreatedWS extends BaseWSMessage {
|
||||||
type: "room_created"
|
type: 'room_created'
|
||||||
room_id: string
|
room_id: string
|
||||||
room_type: string
|
room_type: string
|
||||||
name: string | null
|
name: string | null
|
||||||
|
|
@ -84,7 +84,7 @@ export interface RoomCreatedWS extends BaseWSMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageSentWS extends BaseWSMessage {
|
export interface MessageSentWS extends BaseWSMessage {
|
||||||
type: "message_sent"
|
type: 'message_sent'
|
||||||
temp_id: string
|
temp_id: string
|
||||||
message_id: string
|
message_id: string
|
||||||
room_id: string
|
room_id: string
|
||||||
|
|
@ -103,7 +103,7 @@ export type WSMessage =
|
||||||
| MessageSentWS
|
| MessageSentWS
|
||||||
|
|
||||||
export interface WSOutgoingEncryptedMessage {
|
export interface WSOutgoingEncryptedMessage {
|
||||||
type: "encrypted_message"
|
type: 'encrypted_message'
|
||||||
recipient_id: string
|
recipient_id: string
|
||||||
room_id: string
|
room_id: string
|
||||||
ciphertext: string
|
ciphertext: string
|
||||||
|
|
@ -113,24 +113,24 @@ export interface WSOutgoingEncryptedMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSOutgoingTyping {
|
export interface WSOutgoingTyping {
|
||||||
type: "typing"
|
type: 'typing'
|
||||||
room_id: string
|
room_id: string
|
||||||
is_typing: boolean
|
is_typing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSOutgoingPresence {
|
export interface WSOutgoingPresence {
|
||||||
type: "presence"
|
type: 'presence'
|
||||||
status: PresenceStatus
|
status: PresenceStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSOutgoingReceipt {
|
export interface WSOutgoingReceipt {
|
||||||
type: "receipt"
|
type: 'receipt'
|
||||||
message_id: string
|
message_id: string
|
||||||
room_id: string
|
room_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSOutgoingHeartbeat {
|
export interface WSOutgoingHeartbeat {
|
||||||
type: "heartbeat"
|
type: 'heartbeat'
|
||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
export * from "./websocket-manager"
|
|
||||||
export * from "./message-handlers"
|
export * from './message-handlers'
|
||||||
|
export * from './websocket-manager'
|
||||||
|
|
|
||||||
|
|
@ -2,47 +2,44 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// message-handlers.ts
|
// message-handlers.ts
|
||||||
// ===================
|
// ===================
|
||||||
import type {
|
|
||||||
WSMessage,
|
import { cryptoService, saveDecryptedMessage, updateMessageId } from '../crypto'
|
||||||
EncryptedMessageWS,
|
|
||||||
TypingIndicatorWS,
|
|
||||||
PresenceUpdateWS,
|
|
||||||
ReadReceiptWS,
|
|
||||||
ErrorMessageWS,
|
|
||||||
RoomCreatedWS,
|
|
||||||
MessageSentWS,
|
|
||||||
Message,
|
|
||||||
Room,
|
|
||||||
} from "../types"
|
|
||||||
import {
|
|
||||||
isEncryptedMessageWS,
|
|
||||||
isTypingIndicatorWS,
|
|
||||||
isPresenceUpdateWS,
|
|
||||||
isReadReceiptWS,
|
|
||||||
isErrorMessageWS,
|
|
||||||
isRoomCreatedWS,
|
|
||||||
isMessageSentWS,
|
|
||||||
} from "../types/guards"
|
|
||||||
import {
|
import {
|
||||||
addMessage,
|
addMessage,
|
||||||
updateMessageStatus,
|
|
||||||
confirmPendingMessage,
|
confirmPendingMessage,
|
||||||
} from "../stores/messages.store"
|
updateMessageStatus,
|
||||||
|
} from '../stores/messages.store'
|
||||||
|
import { setUserPresence } from '../stores/presence.store'
|
||||||
|
import { addRoom } from '../stores/rooms.store'
|
||||||
|
import { setUserTyping } from '../stores/typing.store'
|
||||||
|
import { showToast } from '../stores/ui.store'
|
||||||
|
import type {
|
||||||
|
EncryptedMessageWS,
|
||||||
|
ErrorMessageWS,
|
||||||
|
Message,
|
||||||
|
MessageSentWS,
|
||||||
|
PresenceUpdateWS,
|
||||||
|
ReadReceiptWS,
|
||||||
|
Room,
|
||||||
|
RoomCreatedWS,
|
||||||
|
TypingIndicatorWS,
|
||||||
|
WSMessage,
|
||||||
|
} from '../types'
|
||||||
import {
|
import {
|
||||||
addRoom,
|
isEncryptedMessageWS,
|
||||||
} from "../stores/rooms.store"
|
isErrorMessageWS,
|
||||||
import {
|
isMessageSentWS,
|
||||||
setUserPresence,
|
isPresenceUpdateWS,
|
||||||
} from "../stores/presence.store"
|
isReadReceiptWS,
|
||||||
import {
|
isRoomCreatedWS,
|
||||||
setUserTyping,
|
isTypingIndicatorWS,
|
||||||
} from "../stores/typing.store"
|
} from '../types/guards'
|
||||||
import { showToast } from "../stores/ui.store"
|
|
||||||
import { cryptoService, saveDecryptedMessage, updateMessageId } from "../crypto"
|
|
||||||
|
|
||||||
type MessageHandler<T extends WSMessage> = (message: T) => void
|
type MessageHandler<T extends WSMessage> = (message: T) => void
|
||||||
|
|
||||||
async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<void> {
|
async function encryptedMessageHandler(
|
||||||
|
message: EncryptedMessageWS
|
||||||
|
): Promise<void> {
|
||||||
let decryptedContent: string
|
let decryptedContent: string
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -53,7 +50,7 @@ async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<voi
|
||||||
message.header
|
message.header
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
decryptedContent = "[Encrypted message - decryption failed]"
|
decryptedContent = '[Encrypted message - decryption failed]'
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatMessage: Message = {
|
const chatMessage: Message = {
|
||||||
|
|
@ -62,7 +59,7 @@ async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<voi
|
||||||
sender_id: message.sender_id,
|
sender_id: message.sender_id,
|
||||||
sender_username: message.sender_username,
|
sender_username: message.sender_username,
|
||||||
content: decryptedContent,
|
content: decryptedContent,
|
||||||
status: "delivered",
|
status: 'delivered',
|
||||||
is_encrypted: true,
|
is_encrypted: true,
|
||||||
encrypted_content: message.ciphertext,
|
encrypted_content: message.ciphertext,
|
||||||
nonce: message.nonce,
|
nonce: message.nonce,
|
||||||
|
|
@ -85,25 +82,21 @@ const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const presenceUpdateHandler: MessageHandler<PresenceUpdateWS> = (message) => {
|
const presenceUpdateHandler: MessageHandler<PresenceUpdateWS> = (message) => {
|
||||||
setUserPresence(
|
setUserPresence(message.user_id, message.status, message.last_seen)
|
||||||
message.user_id,
|
|
||||||
message.status,
|
|
||||||
message.last_seen
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const readReceiptHandler: MessageHandler<ReadReceiptWS> = (message) => {
|
const readReceiptHandler: MessageHandler<ReadReceiptWS> = (message) => {
|
||||||
updateMessageStatus(message.room_id, message.message_id, "read")
|
updateMessageStatus(message.room_id, message.message_id, 'read')
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessageHandler: MessageHandler<ErrorMessageWS> = (message) => {
|
const errorMessageHandler: MessageHandler<ErrorMessageWS> = (message) => {
|
||||||
showToast("error", "CONNECTION ERROR", message.error_message.toUpperCase())
|
showToast('error', 'CONNECTION ERROR', message.error_message.toUpperCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
|
const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
id: message.room_id,
|
id: message.room_id,
|
||||||
type: message.room_type as "direct" | "group" | "ephemeral",
|
type: message.room_type as 'direct' | 'group' | 'ephemeral',
|
||||||
name: message.name,
|
name: message.name,
|
||||||
participants: message.participants,
|
participants: message.participants,
|
||||||
unread_count: 0,
|
unread_count: 0,
|
||||||
|
|
@ -113,17 +106,17 @@ const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
addRoom(room)
|
addRoom(room)
|
||||||
showToast("info", "NEW CHAT", `NEW CONVERSATION STARTED`)
|
showToast('info', 'NEW CHAT', `NEW CONVERSATION STARTED`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
|
const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
|
||||||
const confirmedMessage: Message = {
|
const confirmedMessage: Message = {
|
||||||
id: message.message_id,
|
id: message.message_id,
|
||||||
room_id: message.room_id,
|
room_id: message.room_id,
|
||||||
sender_id: "",
|
sender_id: '',
|
||||||
sender_username: "",
|
sender_username: '',
|
||||||
content: "",
|
content: '',
|
||||||
status: "sent",
|
status: 'sent',
|
||||||
is_encrypted: true,
|
is_encrypted: true,
|
||||||
created_at: message.created_at,
|
created_at: message.created_at,
|
||||||
updated_at: message.created_at,
|
updated_at: message.created_at,
|
||||||
|
|
@ -151,7 +144,9 @@ export function handleWSMessage(message: WSMessage): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleEncryptedMessage(message: EncryptedMessageWS): Promise<void> {
|
export async function handleEncryptedMessage(
|
||||||
|
message: EncryptedMessageWS
|
||||||
|
): Promise<void> {
|
||||||
await encryptedMessageHandler(message)
|
await encryptedMessageHandler(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,49 +2,54 @@
|
||||||
// ©AngelaMos | 2025
|
// ©AngelaMos | 2025
|
||||||
// websocket-manager.ts
|
// websocket-manager.ts
|
||||||
// ===================
|
// ===================
|
||||||
import { atom, computed } from "nanostores"
|
import { atom, computed } from 'nanostores'
|
||||||
import {
|
import { WS_HEARTBEAT_INTERVAL, WS_RECONNECT_DELAY, WS_URL } from '../config'
|
||||||
WS_URL,
|
import { $userId } from '../stores'
|
||||||
WS_HEARTBEAT_INTERVAL,
|
|
||||||
WS_RECONNECT_DELAY,
|
|
||||||
} from "../config"
|
|
||||||
import { $userId } from "../stores"
|
|
||||||
import type {
|
import type {
|
||||||
WSMessage,
|
WSMessage,
|
||||||
WSOutgoingMessage,
|
WSOutgoingEncryptedMessage,
|
||||||
WSOutgoingHeartbeat,
|
WSOutgoingHeartbeat,
|
||||||
WSOutgoingTyping,
|
WSOutgoingMessage,
|
||||||
WSOutgoingPresence,
|
WSOutgoingPresence,
|
||||||
WSOutgoingReceipt,
|
WSOutgoingReceipt,
|
||||||
WSOutgoingEncryptedMessage,
|
WSOutgoingTyping,
|
||||||
} from "../types"
|
} from '../types'
|
||||||
import { handleWSMessage } from "./message-handlers"
|
|
||||||
import {
|
import {
|
||||||
isWSMessage,
|
|
||||||
isEncryptedMessageWS,
|
isEncryptedMessageWS,
|
||||||
isTypingIndicatorWS,
|
isErrorMessageWS,
|
||||||
|
isHeartbeatWS,
|
||||||
|
isMessageSentWS,
|
||||||
isPresenceUpdateWS,
|
isPresenceUpdateWS,
|
||||||
isReadReceiptWS,
|
isReadReceiptWS,
|
||||||
isHeartbeatWS,
|
|
||||||
isErrorMessageWS,
|
|
||||||
isRoomCreatedWS,
|
isRoomCreatedWS,
|
||||||
isMessageSentWS,
|
isTypingIndicatorWS,
|
||||||
} from "../types/guards"
|
isWSMessage,
|
||||||
|
} from '../types/guards'
|
||||||
|
import { handleWSMessage } from './message-handlers'
|
||||||
|
|
||||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "reconnecting"
|
export type ConnectionStatus =
|
||||||
|
| 'disconnected'
|
||||||
|
| 'connecting'
|
||||||
|
| 'connected'
|
||||||
|
| 'reconnecting'
|
||||||
|
|
||||||
export const $connectionStatus = atom<ConnectionStatus>("disconnected")
|
export const $connectionStatus = atom<ConnectionStatus>('disconnected')
|
||||||
export const $reconnectAttempts = atom<number>(0)
|
export const $reconnectAttempts = atom<number>(0)
|
||||||
export const $lastError = atom<string | null>(null)
|
export const $lastError = atom<string | null>(null)
|
||||||
|
|
||||||
export const $isConnected = computed(
|
export const $isConnected = computed(
|
||||||
$connectionStatus,
|
$connectionStatus,
|
||||||
(status) => status === "connected"
|
(status) => status === 'connected'
|
||||||
)
|
)
|
||||||
|
|
||||||
const MAX_RECONNECT_ATTEMPTS = 10
|
const MAX_RECONNECT_ATTEMPTS = 10
|
||||||
const MAX_RECONNECT_DELAY = 30000
|
const MAX_RECONNECT_DELAY = 30000
|
||||||
const FATAL_ERROR_CODES = ["max_connections", "unauthorized", "invalid_user", "database_error"]
|
const FATAL_ERROR_CODES = [
|
||||||
|
'max_connections',
|
||||||
|
'unauthorized',
|
||||||
|
'invalid_user',
|
||||||
|
'database_error',
|
||||||
|
]
|
||||||
|
|
||||||
class WebSocketManager {
|
class WebSocketManager {
|
||||||
private ws: WebSocket | null = null
|
private ws: WebSocket | null = null
|
||||||
|
|
@ -57,7 +62,7 @@ class WebSocketManager {
|
||||||
connect(): void {
|
connect(): void {
|
||||||
const userId = $userId.get()
|
const userId = $userId.get()
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
$lastError.set("Cannot connect: User not authenticated")
|
$lastError.set('Cannot connect: User not authenticated')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +75,7 @@ class WebSocketManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.intentionalClose = false
|
this.intentionalClose = false
|
||||||
$connectionStatus.set("connecting")
|
$connectionStatus.set('connecting')
|
||||||
$lastError.set(null)
|
$lastError.set(null)
|
||||||
|
|
||||||
const wsUrl = `${WS_URL}/ws?user_id=${userId}`
|
const wsUrl = `${WS_URL}/ws?user_id=${userId}`
|
||||||
|
|
@ -86,7 +91,7 @@ class WebSocketManager {
|
||||||
this.intentionalClose = true
|
this.intentionalClose = true
|
||||||
this.fatalError = false
|
this.fatalError = false
|
||||||
this.cleanup()
|
this.cleanup()
|
||||||
$connectionStatus.set("disconnected")
|
$connectionStatus.set('disconnected')
|
||||||
$reconnectAttempts.set(0)
|
$reconnectAttempts.set(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +117,7 @@ class WebSocketManager {
|
||||||
tempId: string
|
tempId: string
|
||||||
): boolean {
|
): boolean {
|
||||||
const message: WSOutgoingEncryptedMessage = {
|
const message: WSOutgoingEncryptedMessage = {
|
||||||
type: "encrypted_message",
|
type: 'encrypted_message',
|
||||||
recipient_id: recipientId,
|
recipient_id: recipientId,
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
ciphertext: encrypted.ciphertext,
|
ciphertext: encrypted.ciphertext,
|
||||||
|
|
@ -125,16 +130,16 @@ class WebSocketManager {
|
||||||
|
|
||||||
sendTypingIndicator(roomId: string, isTyping: boolean): boolean {
|
sendTypingIndicator(roomId: string, isTyping: boolean): boolean {
|
||||||
const message: WSOutgoingTyping = {
|
const message: WSOutgoingTyping = {
|
||||||
type: "typing",
|
type: 'typing',
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
is_typing: isTyping,
|
is_typing: isTyping,
|
||||||
}
|
}
|
||||||
return this.send(message)
|
return this.send(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendPresenceUpdate(status: "online" | "away" | "offline"): boolean {
|
sendPresenceUpdate(status: 'online' | 'away' | 'offline'): boolean {
|
||||||
const message: WSOutgoingPresence = {
|
const message: WSOutgoingPresence = {
|
||||||
type: "presence",
|
type: 'presence',
|
||||||
status,
|
status,
|
||||||
}
|
}
|
||||||
return this.send(message)
|
return this.send(message)
|
||||||
|
|
@ -142,7 +147,7 @@ class WebSocketManager {
|
||||||
|
|
||||||
sendReadReceipt(messageId: string, roomId: string): boolean {
|
sendReadReceipt(messageId: string, roomId: string): boolean {
|
||||||
const message: WSOutgoingReceipt = {
|
const message: WSOutgoingReceipt = {
|
||||||
type: "receipt",
|
type: 'receipt',
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
}
|
}
|
||||||
|
|
@ -154,12 +159,12 @@ class WebSocketManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOpen(): void {
|
private handleOpen(): void {
|
||||||
$connectionStatus.set("connected")
|
$connectionStatus.set('connected')
|
||||||
$reconnectAttempts.set(0)
|
$reconnectAttempts.set(0)
|
||||||
$lastError.set(null)
|
$lastError.set(null)
|
||||||
|
|
||||||
this.startHeartbeat()
|
this.startHeartbeat()
|
||||||
this.sendPresenceUpdate("online")
|
this.sendPresenceUpdate('online')
|
||||||
this.flushMessageQueue()
|
this.flushMessageQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,21 +210,23 @@ class WebSocketManager {
|
||||||
this.cleanup()
|
this.cleanup()
|
||||||
|
|
||||||
if (this.intentionalClose) {
|
if (this.intentionalClose) {
|
||||||
$connectionStatus.set("disconnected")
|
$connectionStatus.set('disconnected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.fatalError) {
|
if (this.fatalError) {
|
||||||
$connectionStatus.set("disconnected")
|
$connectionStatus.set('disconnected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const attempts = $reconnectAttempts.get()
|
const attempts = $reconnectAttempts.get()
|
||||||
|
|
||||||
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
$connectionStatus.set("disconnected")
|
$connectionStatus.set('disconnected')
|
||||||
const reason = event.reason !== "" ? event.reason : "Unknown reason"
|
const reason = event.reason !== '' ? event.reason : 'Unknown reason'
|
||||||
$lastError.set(`Connection failed after maximum retry attempts (code: ${event.code}, reason: ${reason})`)
|
$lastError.set(
|
||||||
|
`Connection failed after maximum retry attempts (code: ${event.code}, reason: ${reason})`
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,11 +234,11 @@ class WebSocketManager {
|
||||||
$lastError.set(`Connection closed unexpectedly (code: ${event.code})`)
|
$lastError.set(`Connection closed unexpectedly (code: ${event.code})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
$connectionStatus.set("reconnecting")
|
$connectionStatus.set('reconnecting')
|
||||||
$reconnectAttempts.set(attempts + 1)
|
$reconnectAttempts.set(attempts + 1)
|
||||||
|
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
WS_RECONNECT_DELAY * Math.pow(2, attempts),
|
WS_RECONNECT_DELAY * 2 ** attempts,
|
||||||
MAX_RECONNECT_DELAY
|
MAX_RECONNECT_DELAY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -241,7 +248,7 @@ class WebSocketManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleError(_event: Event): void {
|
private handleError(_event: Event): void {
|
||||||
$lastError.set("WebSocket connection error")
|
$lastError.set('WebSocket connection error')
|
||||||
}
|
}
|
||||||
|
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
|
|
@ -250,7 +257,7 @@ class WebSocketManager {
|
||||||
this.heartbeatInterval = setInterval(() => {
|
this.heartbeatInterval = setInterval(() => {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
const heartbeat: WSOutgoingHeartbeat = {
|
const heartbeat: WSOutgoingHeartbeat = {
|
||||||
type: "heartbeat",
|
type: 'heartbeat',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
this.ws.send(JSON.stringify(heartbeat))
|
this.ws.send(JSON.stringify(heartbeat))
|
||||||
|
|
@ -266,7 +273,10 @@ class WebSocketManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private flushMessageQueue(): void {
|
private flushMessageQueue(): void {
|
||||||
while (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
|
while (
|
||||||
|
this.messageQueue.length > 0 &&
|
||||||
|
this.ws?.readyState === WebSocket.OPEN
|
||||||
|
) {
|
||||||
const message = this.messageQueue.shift()
|
const message = this.messageQueue.shift()
|
||||||
if (message !== undefined) {
|
if (message !== undefined) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -293,7 +303,10 @@ class WebSocketManager {
|
||||||
this.ws.onclose = null
|
this.ws.onclose = null
|
||||||
this.ws.onerror = null
|
this.ws.onerror = null
|
||||||
|
|
||||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
if (
|
||||||
|
this.ws.readyState === WebSocket.OPEN ||
|
||||||
|
this.ws.readyState === WebSocket.CONNECTING
|
||||||
|
) {
|
||||||
this.ws.close()
|
this.ws.close()
|
||||||
}
|
}
|
||||||
this.ws = null
|
this.ws = null
|
||||||
|
|
|
||||||
|
|
@ -2,45 +2,46 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// vite.config.ts
|
// vite.config.ts
|
||||||
// ===================
|
// ===================
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import solid from "vite-plugin-solid";
|
import path from 'node:path'
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from "path";
|
import { defineConfig } from 'vite'
|
||||||
|
import solid from 'vite-plugin-solid'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [solid(), tailwindcss()],
|
plugins: [solid(), tailwindcss()],
|
||||||
envDir: "../",
|
envDir: '../',
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"~": path.resolve(__dirname, "./src"),
|
'~': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: true,
|
host: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
'/api': {
|
||||||
target: "http://backend:8000",
|
target: 'http://backend:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
"/ws": {
|
'/ws': {
|
||||||
target: "ws://backend:8000",
|
target: 'ws://backend:8000',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: "esnext",
|
target: 'esnext',
|
||||||
outDir: "dist",
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
"solid-js": ["solid-js"],
|
'solid-js': ['solid-js'],
|
||||||
"solid-router": ["@solidjs/router"],
|
'solid-router': ['@solidjs/router'],
|
||||||
"solid-query": ["@tanstack/solid-query"],
|
'solid-query': ['@tanstack/solid-query'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.15",
|
"@biomejs/biome": "^2.4.2",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@ importers:
|
||||||
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)
|
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: ^2.3.15
|
specifier: ^2.4.2
|
||||||
version: 2.3.15
|
version: 2.4.2
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.2.3
|
specifier: ^25.2.3
|
||||||
version: 25.2.3
|
version: 25.2.3
|
||||||
|
|
@ -162,59 +162,59 @@ packages:
|
||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@biomejs/biome@2.3.15':
|
'@biomejs/biome@2.4.2':
|
||||||
resolution: {integrity: sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==}
|
resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.3.15':
|
'@biomejs/cli-darwin-arm64@2.4.2':
|
||||||
resolution: {integrity: sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==}
|
resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.3.15':
|
'@biomejs/cli-darwin-x64@2.4.2':
|
||||||
resolution: {integrity: sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==}
|
resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.3.15':
|
'@biomejs/cli-linux-arm64-musl@2.4.2':
|
||||||
resolution: {integrity: sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==}
|
resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.3.15':
|
'@biomejs/cli-linux-arm64@2.4.2':
|
||||||
resolution: {integrity: sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==}
|
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.3.15':
|
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||||
resolution: {integrity: sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==}
|
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.3.15':
|
'@biomejs/cli-linux-x64@2.4.2':
|
||||||
resolution: {integrity: sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==}
|
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.3.15':
|
'@biomejs/cli-win32-arm64@2.4.2':
|
||||||
resolution: {integrity: sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==}
|
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.3.15':
|
'@biomejs/cli-win32-x64@2.4.2':
|
||||||
resolution: {integrity: sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==}
|
resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
@ -1423,39 +1423,39 @@ snapshots:
|
||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@biomejs/biome@2.3.15':
|
'@biomejs/biome@2.4.2':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@biomejs/cli-darwin-arm64': 2.3.15
|
'@biomejs/cli-darwin-arm64': 2.4.2
|
||||||
'@biomejs/cli-darwin-x64': 2.3.15
|
'@biomejs/cli-darwin-x64': 2.4.2
|
||||||
'@biomejs/cli-linux-arm64': 2.3.15
|
'@biomejs/cli-linux-arm64': 2.4.2
|
||||||
'@biomejs/cli-linux-arm64-musl': 2.3.15
|
'@biomejs/cli-linux-arm64-musl': 2.4.2
|
||||||
'@biomejs/cli-linux-x64': 2.3.15
|
'@biomejs/cli-linux-x64': 2.4.2
|
||||||
'@biomejs/cli-linux-x64-musl': 2.3.15
|
'@biomejs/cli-linux-x64-musl': 2.4.2
|
||||||
'@biomejs/cli-win32-arm64': 2.3.15
|
'@biomejs/cli-win32-arm64': 2.4.2
|
||||||
'@biomejs/cli-win32-x64': 2.3.15
|
'@biomejs/cli-win32-x64': 2.4.2
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@2.3.15':
|
'@biomejs/cli-darwin-arm64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@2.3.15':
|
'@biomejs/cli-darwin-x64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@2.3.15':
|
'@biomejs/cli-linux-arm64-musl@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@2.3.15':
|
'@biomejs/cli-linux-arm64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@2.3.15':
|
'@biomejs/cli-linux-x64-musl@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.3.15':
|
'@biomejs/cli-linux-x64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.3.15':
|
'@biomejs/cli-win32-arm64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@2.3.15':
|
'@biomejs/cli-win32-x64@2.4.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@cacheable/memory@2.0.7':
|
'@cacheable/memory@2.0.7':
|
||||||
|
|
|
||||||
|
|
@ -469,3 +469,19 @@ def main() -> None:
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⢸⠉⣹⠋⠉⢉⡟⢩⢋⠋⣽⡻⠭⢽⢉⠯⠭⠭⠭⢽⡍⢹⡍⠙⣯⠉⠉⠉⠉⠉⣿⢫⠉⠉⠉⢉⡟⠉⢿⢹⠉⢉⣉⢿⡝⡉⢩⢿⣻⢍⠉⠉⠩⢹⣟⡏⠉⠹⡉⢻⡍⡇
|
||||||
|
⢸⢠⢹⠀⠀⢸⠁⣼⠀⣼⡝⠀⠀⢸⠘⠀⠀⠀⠀⠈⢿⠀⡟⡄⠹⣣⠀⠀⠐⠀⢸⡘⡄⣤⠀⡼⠁⠀⢺⡘⠉⠀⠀⠀⠫⣪⣌⡌⢳⡻⣦⠀⠀⢃⡽⡼⡀⠀⢣⢸⠸⡇
|
||||||
|
⢸⡸⢸⠀⠀⣿⠀⣇⢠⡿⠀⠀⠀⠸⡇⠀⠀⠀⠀⠀⠘⢇⠸⠘⡀⠻⣇⠀⠀⠄⠀⡇⢣⢛⠀⡇⠀⠀⣸⠇⠀⠀⠀⠀⠀⠘⠄⢻⡀⠻⣻⣧⠀⠀⠃⢧⡇⠀⢸⢸⡇⡇
|
||||||
|
⢸⡇⢸⣠⠀⣿⢠⣿⡾⠁⠀⢀⡀⠤⢇⣀⣐⣀⠀⠤⢀⠈⠢⡡⡈⢦⡙⣷⡀⠀⠀⢿⠈⢻⣡⠁⠀⢀⠏⠀⠀⠀⢀⠀⠄⣀⣐⣀⣙⠢⡌⣻⣷⡀⢹⢸⡅⠀⢸⠸⡇⡇
|
||||||
|
⢸⡇⢸⣟⠀⢿⢸⡿⠀⣀⣶⣷⣾⡿⠿⣿⣿⣿⣿⣿⣶⣬⡀⠐⠰⣄⠙⠪⣻⣦⡀⠘⣧⠀⠙⠄⠀⠀⠀⠀⠀⣨⣴⣾⣿⠿⣿⣿⣿⣿⣿⣶⣯⣿⣼⢼⡇⠀⢸⡇⡇⠇
|
||||||
|
⢸⢧⠀⣿⡅⢸⣼⡷⣾⣿⡟⠋⣿⠓⢲⣿⣿⣿⡟⠙⣿⠛⢯⡳⡀⠈⠓⠄⡈⠚⠿⣧⣌⢧⠀⠀⠀⠀⠀⣠⣺⠟⢫⡿⠓⢺⣿⣿⣿⠏⠙⣏⠛⣿⣿⣾⡇⢀⡿⢠⠀⡇
|
||||||
|
⢸⢸⠀⢹⣷⡀⢿⡁⠀⠻⣇⠀⣇⠀⠘⣿⣿⡿⠁⠐⣉⡀⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠉⠓⠳⠄⠀⠀⠀⠀⠋⠀⠘⡇⠀⠸⣿⣿⠟⠀⢈⣉⢠⡿⠁⣼⠁⣼⠃⣼⠀⡇
|
||||||
|
⢸⠸⣀⠈⣯⢳⡘⣇⠀⠀⠈⡂⣜⣆⡀⠀⠀⢀⣀⡴⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢽⣆⣀⠀⠀⠀⣀⣜⠕⡊⠀⣸⠇⣼⡟⢠⠏⠀⡇
|
||||||
|
⢸⠀⡟⠀⢸⡆⢹⡜⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠋⣾⡏⡇⡎⡇⠀⡇
|
||||||
|
⢸⠀⢃⡆⠀⢿⡄⠑⢽⣄⠀⠀⠀⢀⠂⠠⢁⠈⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠄⡐⢀⠂⠀⠀⣠⣮⡟⢹⣯⣸⣱⠁⠀⡇
|
||||||
|
⠈⠉⠉⠉⠉⠉⠉⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁
|
||||||
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": [
|
"extends": ["stylelint-config-standard-scss", "stylelint-config-prettier-scss"],
|
||||||
"stylelint-config-standard-scss",
|
|
||||||
"stylelint-config-prettier-scss"
|
|
||||||
],
|
|
||||||
"rules": {
|
"rules": {
|
||||||
"scss/at-rule-no-unknown": null,
|
"scss/at-rule-no-unknown": null,
|
||||||
"selector-class-pattern": null
|
"selector-class-pattern": null
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 82,
|
||||||
|
"lineEnding": "lf"
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"semicolons": "asNeeded",
|
||||||
|
"trailingCommas": "es5",
|
||||||
|
"arrowParentheses": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"complexity": {
|
||||||
|
"noExcessiveCognitiveComplexity": {
|
||||||
|
"level": "error",
|
||||||
|
"options": { "maxAllowedComplexity": 25 }
|
||||||
|
},
|
||||||
|
"noForEach": "off",
|
||||||
|
"useLiteralKeys": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "error",
|
||||||
|
"noUnusedImports": "error",
|
||||||
|
"useExhaustiveDependencies": "warn",
|
||||||
|
"useHookAtTopLevel": "error",
|
||||||
|
"noUndeclaredVariables": "error"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"useImportType": "error",
|
||||||
|
"useConst": "error",
|
||||||
|
"useTemplate": "error",
|
||||||
|
"useSelfClosingElements": "error",
|
||||||
|
"useFragmentSyntax": "error",
|
||||||
|
"noNonNullAssertion": "error",
|
||||||
|
"useConsistentArrayType": {
|
||||||
|
"level": "error",
|
||||||
|
"options": { "syntax": "shorthand" }
|
||||||
|
},
|
||||||
|
"useNamingConvention": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "error",
|
||||||
|
"noDebugger": "error",
|
||||||
|
"noConsole": "warn",
|
||||||
|
"noArrayIndexKey": "warn",
|
||||||
|
"noAssignInExpressions": "error",
|
||||||
|
"noDoubleEquals": "error",
|
||||||
|
"noRedeclare": "error",
|
||||||
|
"noVar": "error"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"noDangerouslySetInnerHtml": "error"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"useAltText": "error",
|
||||||
|
"useAnchorContent": "error",
|
||||||
|
"useKeyWithClickEvents": "error",
|
||||||
|
"noStaticElementInteractions": "error",
|
||||||
|
"useButtonType": "error",
|
||||||
|
"useValidAnchor": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["src/main.tsx"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
// © AngelaMos | 2025
|
// © AngelaMos | 2025
|
||||||
// eslint.config.js
|
// eslint.config.js
|
||||||
// ===================
|
// ===================
|
||||||
import js from '@eslint/js';
|
import js from '@eslint/js'
|
||||||
import tseslint from 'typescript-eslint';
|
import prettierConfig from 'eslint-config-prettier'
|
||||||
import react from 'eslint-plugin-react';
|
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import react from 'eslint-plugin-react'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import prettierConfig from 'eslint-config-prettier';
|
import globals from 'globals'
|
||||||
import globals from 'globals';
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
|
|
@ -47,15 +47,28 @@ export default tseslint.config(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', fixStyle: 'inline-type-imports' }],
|
'error',
|
||||||
'@typescript-eslint/explicit-function-return-type': ['error', {
|
{
|
||||||
allowExpressions: true,
|
argsIgnorePattern: '^_',
|
||||||
allowTypedFunctionExpressions: true,
|
varsIgnorePattern: '^_',
|
||||||
allowHigherOrderFunctions: true,
|
caughtErrorsIgnorePattern: '^_',
|
||||||
allowDirectConstAssertionInArrowFunctions: true,
|
},
|
||||||
allowedNames: ['Component']
|
],
|
||||||
}],
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'error',
|
||||||
|
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/explicit-function-return-type': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
allowExpressions: true,
|
||||||
|
allowTypedFunctionExpressions: true,
|
||||||
|
allowHigherOrderFunctions: true,
|
||||||
|
allowDirectConstAssertionInArrowFunctions: true,
|
||||||
|
allowedNames: ['Component'],
|
||||||
|
},
|
||||||
|
],
|
||||||
'@typescript-eslint/naming-convention': 'off',
|
'@typescript-eslint/naming-convention': 'off',
|
||||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||||
|
|
@ -63,13 +76,16 @@ export default tseslint.config(
|
||||||
'@typescript-eslint/no-confusing-void-expression': 'off',
|
'@typescript-eslint/no-confusing-void-expression': 'off',
|
||||||
'@typescript-eslint/no-unnecessary-condition': 'off',
|
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'error',
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
'@typescript-eslint/strict-boolean-expressions': ['error', {
|
'@typescript-eslint/strict-boolean-expressions': [
|
||||||
allowString: false,
|
'error',
|
||||||
allowNumber: false,
|
{
|
||||||
allowNullableObject: false,
|
allowString: false,
|
||||||
allowNullableString: true,
|
allowNumber: false,
|
||||||
allowAny: true
|
allowNullableObject: false,
|
||||||
}],
|
allowNullableString: true,
|
||||||
|
allowAny: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
'@typescript-eslint/prefer-as-const': 'error',
|
'@typescript-eslint/prefer-as-const': 'error',
|
||||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||||
|
|
@ -78,7 +94,14 @@ export default tseslint.config(
|
||||||
'react/jsx-uses-react': 'off',
|
'react/jsx-uses-react': 'off',
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
'react/jsx-no-leaked-render': ['error', { validStrategies: ['ternary'] }],
|
'react/jsx-no-leaked-render': ['error', { validStrategies: ['ternary'] }],
|
||||||
'react/jsx-key': ['error', { checkFragmentShorthand: true, checkKeyMustBeforeSpread: true, warnOnDuplicates: true }],
|
'react/jsx-key': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
checkFragmentShorthand: true,
|
||||||
|
checkKeyMustBeforeSpread: true,
|
||||||
|
warnOnDuplicates: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: false }],
|
'react/jsx-no-useless-fragment': ['error', { allowExpressions: false }],
|
||||||
'react/jsx-pascal-case': ['error', { allowAllCaps: false }],
|
'react/jsx-pascal-case': ['error', { allowAllCaps: false }],
|
||||||
'react/no-array-index-key': 'warn',
|
'react/no-array-index-key': 'warn',
|
||||||
|
|
@ -106,8 +129,11 @@ export default tseslint.config(
|
||||||
'object-shorthand': 'error',
|
'object-shorthand': 'error',
|
||||||
'no-nested-ternary': 'error',
|
'no-nested-ternary': 'error',
|
||||||
'max-depth': ['error', 6],
|
'max-depth': ['error', 6],
|
||||||
'max-lines': ['error', { max: 2000, skipBlankLines: true, skipComments: true }],
|
'max-lines': [
|
||||||
'complexity': ['error', 55],
|
'error',
|
||||||
|
{ max: 2000, skipBlankLines: true, skipComments: true },
|
||||||
|
],
|
||||||
|
complexity: ['error', 55],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -118,5 +144,5 @@ export default tseslint.config(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
prettierConfig,
|
prettierConfig
|
||||||
);
|
)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,62 +4,66 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,scss}\"",
|
"build": "tsc -b && vite build",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,scss}\"",
|
"preview": "vite preview",
|
||||||
"lint": "npm run lint:eslint && npm run lint:scss && npm run lint:types",
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --write .",
|
||||||
"lint:eslint": "eslint . --ext .ts,.tsx --max-warnings 0",
|
"lint:eslint": "eslint . --ext .ts,.tsx --max-warnings 0",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"format:prettier": "prettier --write \"**/*.{ts,tsx,scss}\"",
|
||||||
|
"format:check": "prettier --check \"**/*.{ts,tsx,scss}\"",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
"lint:scss": "stylelint \"src/**/*.css\" --max-warnings 0",
|
"lint:scss": "stylelint \"src/**/*.css\" --max-warnings 0",
|
||||||
"lint:scss:fix": "stylelint \"src/**/*.css\" --fix",
|
"lint:scss:fix": "stylelint \"src/**/*.css\" --fix"
|
||||||
"lint:types": "tsc --project tsconfig.app.json --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.19",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-query-devtools": "^5.91.2",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.13.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.4",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.2",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^17.1.0",
|
"globals": "^17.3.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"stylelint": "^17.0.0",
|
"stylelint": "^17.3.0",
|
||||||
"stylelint-config-prettier-scss": "^1.0.0",
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
"stylelint-config-standard-scss": "^17.0.0",
|
"stylelint-config-standard-scss": "^17.0.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.56.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-tsconfig-paths": "^6.0.4"
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,41 +3,34 @@
|
||||||
* Main application component
|
* Main application component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { useEffect } from 'react'
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom'
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner'
|
||||||
import { queryClient } from '@/lib/queryClient';
|
import { queryClient } from '@/lib/queryClient'
|
||||||
import { router } from '@/router';
|
import { router } from '@/router'
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
|
||||||
const AuthInitializer = (): null => {
|
const AuthInitializer = (): null => {
|
||||||
const loadUserFromStorage = useAuthStore(
|
const loadUserFromStorage = useAuthStore((state) => state.loadUserFromStorage)
|
||||||
(state) => state.loadUserFromStorage,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUserFromStorage();
|
loadUserFromStorage()
|
||||||
}, [loadUserFromStorage]);
|
}, [loadUserFromStorage])
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
function App(): React.ReactElement {
|
function App(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthInitializer />
|
<AuthInitializer />
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
<Toaster
|
<Toaster position="top-right" closeButton duration={4500} theme="dark" />
|
||||||
position="top-right"
|
|
||||||
closeButton
|
|
||||||
duration={4500}
|
|
||||||
theme="dark"
|
|
||||||
/>
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
|
|
|
||||||
|
|
@ -3,133 +3,125 @@
|
||||||
// ©AngelaMos | 2025
|
// ©AngelaMos | 2025
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { isAxiosError } from 'axios'
|
||||||
import { Link } from 'react-router-dom';
|
import { useEffect, useState } from 'react'
|
||||||
import { isAxiosError } from 'axios';
|
import { Link } from 'react-router-dom'
|
||||||
import { Button } from '@/components/common/Button';
|
import { Button } from '@/components/common/Button'
|
||||||
import { Input } from '@/components/common/Input';
|
import { Input } from '@/components/common/Input'
|
||||||
import { useLogin } from '@/hooks/useAuth';
|
import { useLogin } from '@/hooks/useAuth'
|
||||||
import { useUIStore } from '@/store/uiStore';
|
import { loginSchema } from '@/lib/validation'
|
||||||
import { loginSchema } from '@/lib/validation';
|
import { useUIStore } from '@/store/uiStore'
|
||||||
import './AuthForm.css';
|
import './AuthForm.css'
|
||||||
|
|
||||||
export const LoginForm = (): React.ReactElement => {
|
export const LoginForm = (): React.ReactElement => {
|
||||||
const loginFormState = useUIStore((state) => state.loginForm);
|
const loginFormState = useUIStore((state) => state.loginForm)
|
||||||
const setLoginFormField = useUIStore((state) => state.setLoginFormField);
|
const setLoginFormField = useUIStore((state) => state.setLoginFormField)
|
||||||
const clearLoginForm = useUIStore((state) => state.clearLoginForm);
|
const clearLoginForm = useUIStore((state) => state.clearLoginForm)
|
||||||
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
|
const clearExpiredData = useUIStore((state) => state.clearExpiredData)
|
||||||
|
|
||||||
const [email, setEmail] = useState<string>('');
|
const [email, setEmail] = useState<string>('')
|
||||||
const [password, setPassword] = useState<string>('');
|
const [password, setPassword] = useState<string>('')
|
||||||
const [errors, setErrors] = useState<{
|
const [errors, setErrors] = useState<{
|
||||||
email?: string;
|
email?: string
|
||||||
password?: string;
|
password?: string
|
||||||
}>({});
|
}>({})
|
||||||
|
|
||||||
const { mutate: login, isPending, error } = useLogin();
|
const { mutate: login, isPending, error } = useLogin()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearExpiredData();
|
clearExpiredData()
|
||||||
setEmail(loginFormState.email);
|
setEmail(loginFormState.email)
|
||||||
setPassword(loginFormState.password);
|
setPassword(loginFormState.password)
|
||||||
}, [clearExpiredData, loginFormState.email, loginFormState.password]);
|
}, [clearExpiredData, loginFormState.email, loginFormState.password])
|
||||||
|
|
||||||
const handleEmailChange = (value: string): void => {
|
const handleEmailChange = (value: string): void => {
|
||||||
setEmail(value);
|
setEmail(value)
|
||||||
setLoginFormField('email', value);
|
setLoginFormField('email', value)
|
||||||
if (errors.email !== null && errors.email !== undefined) {
|
if (errors.email !== null && errors.email !== undefined) {
|
||||||
validateField('email', value);
|
validateField('email', value)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handlePasswordChange = (value: string): void => {
|
const handlePasswordChange = (value: string): void => {
|
||||||
setPassword(value);
|
setPassword(value)
|
||||||
setLoginFormField('password', value);
|
setLoginFormField('password', value)
|
||||||
if (errors.password !== null && errors.password !== undefined) {
|
if (errors.password !== null && errors.password !== undefined) {
|
||||||
validateField('password', value);
|
validateField('password', value)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const validateField = (
|
const validateField = (field: 'email' | 'password', value: string): void => {
|
||||||
field: 'email' | 'password',
|
|
||||||
value: string,
|
|
||||||
): void => {
|
|
||||||
const result = loginSchema.safeParse({
|
const result = loginSchema.safeParse({
|
||||||
email: field === 'email' ? value : email,
|
email: field === 'email' ? value : email,
|
||||||
password: field === 'password' ? value : password,
|
password: field === 'password' ? value : password,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const fieldError = result.error.issues.find(
|
const fieldError = result.error.issues.find((err) => err.path[0] === field)
|
||||||
(err) => err.path[0] === field,
|
|
||||||
);
|
|
||||||
if (fieldError !== null && fieldError !== undefined) {
|
if (fieldError !== null && fieldError !== undefined) {
|
||||||
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
|
setErrors((prev) => ({ ...prev, [field]: fieldError.message }))
|
||||||
} else {
|
} else {
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
const { [field]: _, ...rest } = prev;
|
const { [field]: _, ...rest } = prev
|
||||||
return rest;
|
return rest
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
const { [field]: _, ...rest } = prev;
|
const { [field]: _, ...rest } = prev
|
||||||
return rest;
|
return rest
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleBlur = (field: 'email' | 'password'): void => {
|
const handleBlur = (field: 'email' | 'password'): void => {
|
||||||
const value = field === 'email' ? email : password;
|
const value = field === 'email' ? email : password
|
||||||
validateField(field, value);
|
validateField(field, value)
|
||||||
};
|
}
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const result = loginSchema.safeParse({
|
const result = loginSchema.safeParse({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const newErrors: { email?: string; password?: string } = {};
|
const newErrors: { email?: string; password?: string } = {}
|
||||||
|
|
||||||
result.error.issues.forEach((err) => {
|
result.error.issues.forEach((err) => {
|
||||||
const field = err.path[0] as keyof typeof newErrors;
|
const field = err.path[0] as keyof typeof newErrors
|
||||||
if (field !== null && field !== undefined) {
|
if (field !== null && field !== undefined) {
|
||||||
newErrors[field] = err.message;
|
newErrors[field] = err.message
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors)
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors({});
|
setErrors({})
|
||||||
return true;
|
return true
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
login(
|
login(
|
||||||
{ email, password },
|
{ email, password },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
clearLoginForm();
|
clearLoginForm()
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
className="auth-form"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
<div className="auth-form__header">
|
<div className="auth-form__header">
|
||||||
<h1 className="auth-form__title">Welcome Back</h1>
|
<h1 className="auth-form__title">Welcome Back</h1>
|
||||||
<p className="auth-form__subtitle">Sign in to your account</p>
|
<p className="auth-form__subtitle">Sign in to your account</p>
|
||||||
|
|
@ -162,32 +154,22 @@ export const LoginForm = (): React.ReactElement => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error !== null && error !== undefined && isAxiosError(error) ? (
|
{error !== null && error !== undefined && isAxiosError(error) ? (
|
||||||
<div
|
<div className="auth-form__error-message" role="alert">
|
||||||
className="auth-form__error-message"
|
{(error.response?.data as { detail?: string } | undefined)?.detail ??
|
||||||
role="alert"
|
'Login failed. Please try again.'}
|
||||||
>
|
|
||||||
{(error.response?.data as { detail?: string } | undefined)
|
|
||||||
?.detail ?? 'Login failed. Please try again.'}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button type="submit" isLoading={isPending} disabled={isPending}>
|
||||||
type="submit"
|
|
||||||
isLoading={isPending}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="auth-form__link">
|
<p className="auth-form__link">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link
|
<Link to="/register" className="auth-form__link-text">
|
||||||
to="/register"
|
|
||||||
className="auth-form__link-text"
|
|
||||||
>
|
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,169 +3,157 @@
|
||||||
// ©AngelaMos | 2025
|
// ©AngelaMos | 2025
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { isAxiosError } from 'axios'
|
||||||
import { Link } from 'react-router-dom';
|
import { useEffect, useState } from 'react'
|
||||||
import { isAxiosError } from 'axios';
|
import { Link } from 'react-router-dom'
|
||||||
import { Button } from '@/components/common/Button';
|
import { Button } from '@/components/common/Button'
|
||||||
import { Input } from '@/components/common/Input';
|
import { Input } from '@/components/common/Input'
|
||||||
import { useRegister } from '@/hooks/useAuth';
|
import { useRegister } from '@/hooks/useAuth'
|
||||||
import { useUIStore } from '@/store/uiStore';
|
import { registerSchema } from '@/lib/validation'
|
||||||
import { registerSchema } from '@/lib/validation';
|
import { useUIStore } from '@/store/uiStore'
|
||||||
import './AuthForm.css';
|
import './AuthForm.css'
|
||||||
|
|
||||||
export const RegisterForm = (): React.ReactElement => {
|
export const RegisterForm = (): React.ReactElement => {
|
||||||
const registerFormState = useUIStore((state) => state.registerForm);
|
const registerFormState = useUIStore((state) => state.registerForm)
|
||||||
const setRegisterFormField = useUIStore(
|
const setRegisterFormField = useUIStore((state) => state.setRegisterFormField)
|
||||||
(state) => state.setRegisterFormField,
|
const clearRegisterForm = useUIStore((state) => state.clearRegisterForm)
|
||||||
);
|
const clearExpiredData = useUIStore((state) => state.clearExpiredData)
|
||||||
const clearRegisterForm = useUIStore((state) => state.clearRegisterForm);
|
|
||||||
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
|
|
||||||
|
|
||||||
const [email, setEmail] = useState<string>('');
|
const [email, setEmail] = useState<string>('')
|
||||||
const [password, setPassword] = useState<string>('');
|
const [password, setPassword] = useState<string>('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>('');
|
const [confirmPassword, setConfirmPassword] = useState<string>('')
|
||||||
const [errors, setErrors] = useState<{
|
const [errors, setErrors] = useState<{
|
||||||
email?: string;
|
email?: string
|
||||||
password?: string;
|
password?: string
|
||||||
confirmPassword?: string;
|
confirmPassword?: string
|
||||||
}>({});
|
}>({})
|
||||||
|
|
||||||
const { mutate: register, isPending, error } = useRegister();
|
const { mutate: register, isPending, error } = useRegister()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearExpiredData();
|
clearExpiredData()
|
||||||
setEmail(registerFormState.email);
|
setEmail(registerFormState.email)
|
||||||
setPassword(registerFormState.password);
|
setPassword(registerFormState.password)
|
||||||
setConfirmPassword(registerFormState.confirmPassword);
|
setConfirmPassword(registerFormState.confirmPassword)
|
||||||
}, [
|
}, [
|
||||||
clearExpiredData,
|
clearExpiredData,
|
||||||
registerFormState.email,
|
registerFormState.email,
|
||||||
registerFormState.password,
|
registerFormState.password,
|
||||||
registerFormState.confirmPassword,
|
registerFormState.confirmPassword,
|
||||||
]);
|
])
|
||||||
|
|
||||||
const handleEmailChange = (value: string): void => {
|
const handleEmailChange = (value: string): void => {
|
||||||
setEmail(value);
|
setEmail(value)
|
||||||
setRegisterFormField('email', value);
|
setRegisterFormField('email', value)
|
||||||
if (errors.email !== null && errors.email !== undefined) {
|
if (errors.email !== null && errors.email !== undefined) {
|
||||||
validateField('email', value);
|
validateField('email', value)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handlePasswordChange = (value: string): void => {
|
const handlePasswordChange = (value: string): void => {
|
||||||
setPassword(value);
|
setPassword(value)
|
||||||
setRegisterFormField('password', value);
|
setRegisterFormField('password', value)
|
||||||
if (errors.password !== null && errors.password !== undefined) {
|
if (errors.password !== null && errors.password !== undefined) {
|
||||||
validateField('password', value);
|
validateField('password', value)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleConfirmPasswordChange = (value: string): void => {
|
const handleConfirmPasswordChange = (value: string): void => {
|
||||||
setConfirmPassword(value);
|
setConfirmPassword(value)
|
||||||
setRegisterFormField('confirmPassword', value);
|
setRegisterFormField('confirmPassword', value)
|
||||||
if (
|
if (errors.confirmPassword !== null && errors.confirmPassword !== undefined) {
|
||||||
errors.confirmPassword !== null &&
|
validateField('confirmPassword', value)
|
||||||
errors.confirmPassword !== undefined
|
|
||||||
) {
|
|
||||||
validateField('confirmPassword', value);
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const validateField = (
|
const validateField = (
|
||||||
field: 'email' | 'password' | 'confirmPassword',
|
field: 'email' | 'password' | 'confirmPassword',
|
||||||
value: string,
|
value: string
|
||||||
): void => {
|
): void => {
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
email: field === 'email' ? value : email,
|
email: field === 'email' ? value : email,
|
||||||
password: field === 'password' ? value : password,
|
password: field === 'password' ? value : password,
|
||||||
confirmPassword: field === 'confirmPassword' ? value : confirmPassword,
|
confirmPassword: field === 'confirmPassword' ? value : confirmPassword,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const fieldError = result.error.issues.find(
|
const fieldError = result.error.issues.find((err) => err.path[0] === field)
|
||||||
(err) => err.path[0] === field,
|
|
||||||
);
|
|
||||||
if (fieldError !== null && fieldError !== undefined) {
|
if (fieldError !== null && fieldError !== undefined) {
|
||||||
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
|
setErrors((prev) => ({ ...prev, [field]: fieldError.message }))
|
||||||
} else {
|
} else {
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
const { [field]: _, ...rest } = prev;
|
const { [field]: _, ...rest } = prev
|
||||||
return rest;
|
return rest
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
const { [field]: _, ...rest } = prev;
|
const { [field]: _, ...rest } = prev
|
||||||
return rest;
|
return rest
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleBlur = (
|
const handleBlur = (field: 'email' | 'password' | 'confirmPassword'): void => {
|
||||||
field: 'email' | 'password' | 'confirmPassword',
|
let value: string
|
||||||
): void => {
|
|
||||||
let value: string;
|
|
||||||
if (field === 'email') {
|
if (field === 'email') {
|
||||||
value = email;
|
value = email
|
||||||
} else if (field === 'password') {
|
} else if (field === 'password') {
|
||||||
value = password;
|
value = password
|
||||||
} else {
|
} else {
|
||||||
value = confirmPassword;
|
value = confirmPassword
|
||||||
}
|
}
|
||||||
validateField(field, value);
|
validateField(field, value)
|
||||||
};
|
}
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
confirmPassword,
|
confirmPassword,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const newErrors: {
|
const newErrors: {
|
||||||
email?: string;
|
email?: string
|
||||||
password?: string;
|
password?: string
|
||||||
confirmPassword?: string;
|
confirmPassword?: string
|
||||||
} = {};
|
} = {}
|
||||||
|
|
||||||
result.error.issues.forEach((err) => {
|
result.error.issues.forEach((err) => {
|
||||||
const field = err.path[0] as keyof typeof newErrors;
|
const field = err.path[0] as keyof typeof newErrors
|
||||||
if (field !== null && field !== undefined) {
|
if (field !== null && field !== undefined) {
|
||||||
newErrors[field] = err.message;
|
newErrors[field] = err.message
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors)
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors({});
|
setErrors({})
|
||||||
return true;
|
return true
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
register(
|
register(
|
||||||
{ email, password },
|
{ email, password },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
clearRegisterForm();
|
clearRegisterForm()
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
className="auth-form"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
<div className="auth-form__header">
|
<div className="auth-form__header">
|
||||||
<h1 className="auth-form__title">Create Account</h1>
|
<h1 className="auth-form__title">Create Account</h1>
|
||||||
<p className="auth-form__subtitle">
|
<p className="auth-form__subtitle">
|
||||||
|
|
@ -212,32 +200,22 @@ export const RegisterForm = (): React.ReactElement => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error !== null && error !== undefined && isAxiosError(error) ? (
|
{error !== null && error !== undefined && isAxiosError(error) ? (
|
||||||
<div
|
<div className="auth-form__error-message" role="alert">
|
||||||
className="auth-form__error-message"
|
{(error.response?.data as { detail?: string } | undefined)?.detail ??
|
||||||
role="alert"
|
'Registration failed. Please try again.'}
|
||||||
>
|
|
||||||
{(error.response?.data as { detail?: string } | undefined)
|
|
||||||
?.detail ?? 'Registration failed. Please try again.'}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button type="submit" isLoading={isPending} disabled={isPending}>
|
||||||
type="submit"
|
|
||||||
isLoading={isPending}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
Create Account
|
Create Account
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="auth-form__link">
|
<p className="auth-form__link">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link
|
<Link to="/login" className="auth-form__link-text">
|
||||||
to="/login"
|
|
||||||
className="auth-form__link-text"
|
|
||||||
>
|
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
// ©AngelaMos | 2025
|
// ©AngelaMos | 2025
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { type ButtonHTMLAttributes } from 'react';
|
import type { ButtonHTMLAttributes } from 'react'
|
||||||
import './Button.css';
|
import './Button.css'
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'ghost';
|
variant?: 'primary' | 'secondary' | 'ghost'
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg'
|
||||||
isLoading?: boolean;
|
isLoading?: boolean
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button = ({
|
export const Button = ({
|
||||||
|
|
@ -30,5 +30,5 @@ export const Button = ({
|
||||||
>
|
>
|
||||||
{isLoading ? 'Loading...' : children}
|
{isLoading ? 'Loading...' : children}
|
||||||
</button>
|
</button>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,50 +3,42 @@
|
||||||
// ©AngelaMos | 2025
|
// ©AngelaMos | 2025
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { type InputHTMLAttributes, forwardRef } from 'react';
|
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||||
import './Input.css';
|
import './Input.css'
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label: string;
|
label: string
|
||||||
error?: string | undefined;
|
error?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ label, error, id, ...props }, ref) => {
|
({ label, error, id, ...props }, ref) => {
|
||||||
const inputId =
|
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`
|
||||||
id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
const errorId = `${inputId}-error`
|
||||||
const errorId = `${inputId}-error`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="input-wrapper">
|
<div className="input-wrapper">
|
||||||
<label
|
<label htmlFor={inputId} className="input-label">
|
||||||
htmlFor={inputId}
|
|
||||||
className="input-label"
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className={`input ${error !== null && error !== undefined ? 'input--error' : ''}`}
|
className={`input ${error !== null && error !== undefined ? 'input--error' : ''}`}
|
||||||
aria-invalid={error !== null && error !== undefined ? true : false}
|
aria-invalid={!!(error !== null && error !== undefined)}
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
error !== null && error !== undefined ? errorId : undefined
|
error !== null && error !== undefined ? errorId : undefined
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error !== null && error !== undefined ? (
|
{error !== null && error !== undefined ? (
|
||||||
<p
|
<p id={errorId} className="input-error" role="alert">
|
||||||
id={errorId}
|
|
||||||
className="input-error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
Input.displayName = 'Input';
|
Input.displayName = 'Input'
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,21 @@
|
||||||
// ©AngelaMos | 2025
|
// ©AngelaMos | 2025
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { TEST_TYPE_LABELS, type ScanTestType } from '@/config/constants';
|
import { type ScanTestType, TEST_TYPE_LABELS } from '@/config/constants'
|
||||||
import './LoadingOverlay.css';
|
import './LoadingOverlay.css'
|
||||||
|
|
||||||
interface LoadingOverlayProps {
|
interface LoadingOverlayProps {
|
||||||
tests: ScanTestType[];
|
tests: ScanTestType[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoadingOverlay = ({
|
export const LoadingOverlay = ({
|
||||||
tests,
|
tests,
|
||||||
}: LoadingOverlayProps): React.ReactElement => {
|
}: LoadingOverlayProps): React.ReactElement => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="loading-overlay" role="dialog" aria-label="Scan in progress">
|
||||||
className="loading-overlay"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Scan in progress"
|
|
||||||
>
|
|
||||||
<div className="loading-overlay__content">
|
<div className="loading-overlay__content">
|
||||||
<div className="loading-overlay__spinner">
|
<div className="loading-overlay__spinner">
|
||||||
<div className="spinner"></div>
|
<div className="spinner" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="loading-overlay__title">Running Security Scan</h2>
|
<h2 className="loading-overlay__title">Running Security Scan</h2>
|
||||||
<p className="loading-overlay__subtitle">
|
<p className="loading-overlay__subtitle">
|
||||||
|
|
@ -30,15 +26,12 @@ export const LoadingOverlay = ({
|
||||||
</p>
|
</p>
|
||||||
<div className="loading-overlay__tests">
|
<div className="loading-overlay__tests">
|
||||||
{tests.map((test) => (
|
{tests.map((test) => (
|
||||||
<div
|
<div key={test} className="loading-overlay__test">
|
||||||
key={test}
|
|
||||||
className="loading-overlay__test"
|
|
||||||
>
|
|
||||||
{TEST_TYPE_LABELS[test]}
|
{TEST_TYPE_LABELS[test]}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue