This commit is contained in:
CarterPerez-dev 2026-02-18 18:53:15 -05:00
parent 7c11ee50da
commit 95cc222302
131 changed files with 13823 additions and 17855 deletions

137
.github/workflows/lint.yml vendored Executable file → Normal file
View File

@ -23,19 +23,34 @@ jobs:
include:
- name: api-rate-limiter
type: python
path: PROJECTS/api-rate-limiter
path: PROJECTS/advanced/api-rate-limiter
- name: dns-lookup
type: python
path: PROJECTS/dns-lookup
path: PROJECTS/beginner/dns-lookup
- name: keylogger
type: python
path: PROJECTS/keylogger
- name: api-security-scanner
type: typescript
path: PROJECTS/api-security-scanner/frontend
path: PROJECTS/beginner/keylogger
- name: docker-security-audit
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:
run:
@ -68,18 +83,32 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"
# TypeScript Setup
# Biome Setup
- name: Setup Node.js
if: matrix.type == 'typescript'
if: matrix.type == 'biome'
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ matrix.path }}/package-lock.json
- name: Install TypeScript dependencies
if: matrix.type == 'typescript'
run: npm install
- name: Setup pnpm
if: matrix.type == 'biome'
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
- name: Setup Go
@ -135,35 +164,20 @@ jobs:
cat mypy-output.txt
continue-on-error: true
# TypeScript Linting
- name: Run ESLint
if: matrix.type == 'typescript'
id: eslint
# Biome Linting
- name: Run Biome
if: matrix.type == 'biome'
id: biome
run: |
echo "Running ESLint..."
if npm run lint:eslint > eslint-output.txt 2>&1; then
echo "ESLINT_PASSED=true" >> $GITHUB_ENV
echo "No ESLint errors found!"
echo "Running Biome check..."
if npx @biomejs/biome check . > biome-output.txt 2>&1; then
echo "BIOME_PASSED=true" >> $GITHUB_ENV
echo "No Biome errors found!"
else
echo "ESLINT_PASSED=false" >> $GITHUB_ENV
echo "ESLint found issues!"
echo "BIOME_PASSED=false" >> $GITHUB_ENV
echo "Biome found issues!"
fi
cat eslint-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
cat biome-output.txt
continue-on-error: true
# Go Linting
@ -247,46 +261,31 @@ jobs:
echo "<!-- lint-check-${{ matrix.name }}-marker -->"
} > lint-report.md
# Create Summary for TypeScript
- name: Create TypeScript Lint Summary
if: matrix.type == 'typescript' && github.event_name == 'pull_request'
# Create Summary for Biome
- name: Create Biome Lint Summary
if: matrix.type == 'biome' && github.event_name == 'pull_request'
run: |
{
echo "## Lint Results: ${{ matrix.name }}"
echo ''
# ESLint Status
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]]; then
echo '### ESLint: **Passed**'
echo 'No ESLint issues found.'
# Biome Status
if [[ "${{ env.BIOME_PASSED }}" == "true" ]]; then
echo '### Biome: **Passed**'
echo 'No Biome issues found.'
else
echo '### ESLint: **Issues Found**'
echo '<details><summary>View ESLint output</summary>'
echo '### Biome: **Issues Found**'
echo '<details><summary>View Biome output</summary>'
echo ''
echo '```'
head -100 eslint-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
head -100 biome-output.txt
echo '```'
echo '</details>'
fi
echo ''
# Overall Summary
if [[ "${{ env.ESLINT_PASSED }}" == "true" ]] && [[ "${{ env.TSC_PASSED }}" == "true" ]]; then
if [[ "${{ env.BIOME_PASSED }}" == "true" ]]; then
echo '---'
echo '### All checks passed!'
else
@ -350,9 +349,9 @@ jobs:
echo "Python lint checks failed"
exit 1
fi
elif [[ "${{ matrix.type }}" == "typescript" ]]; then
if [[ "${{ env.ESLINT_PASSED }}" == "false" ]] || [[ "${{ env.TSC_PASSED }}" == "false" ]]; then
echo "TypeScript lint checks failed"
elif [[ "${{ matrix.type }}" == "biome" ]]; then
if [[ "${{ env.BIOME_PASSED }}" == "false" ]]; then
echo "Biome lint checks failed"
exit 1
fi
elif [[ "${{ matrix.type }}" == "go" ]]; then

View File

@ -16,8 +16,50 @@ repos:
# 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
rev: v6.0.0

View File

@ -23,10 +23,10 @@ classifiers = [
]
dependencies = [
"fastapi[standard]>=0.128.7",
"fastapi[standard]>=0.129.0",
"pydantic>=2.12.5,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"redis>=7.1.1",
"pydantic-settings>=2.13.0",
"redis>=7.2.0",
"pyjwt>=2.11.0",
]
@ -36,12 +36,12 @@ dev = [
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
"httpx>=0.28.1",
"fakeredis>=2.33.0",
"fakeredis>=2.34.0",
"time-machine>=3.2.0",
"asgi-lifespan>=2.1.0",
"mypy>=1.19.1",
"types-redis>=4.6.0.20241004",
"ruff>=0.15.0",
"ruff>=0.15.1",
"pylint>=4.0.4",
"pylint-pydantic>=0.4.1",
"pylint-per-file-ignores>=3.2.0",

View File

@ -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": {
"enabled": true,
"clientKind": "git",

View File

@ -10,7 +10,7 @@
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit",
"typecheck": "tsc -b",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix"
},
@ -28,7 +28,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@biomejs/biome": "^2.4.2",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",

View File

@ -46,8 +46,8 @@ importers:
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
devDependencies:
'@biomejs/biome':
specifier: ^2.3.8
version: 2.3.11
specifier: ^2.4.2
version: 2.4.2
'@tanstack/react-query-devtools':
specifier: ^5.91.1
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==}
engines: {node: '>=6.9.0'}
'@biomejs/biome@2.3.11':
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
'@biomejs/biome@2.4.2':
resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.3.11':
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==}
'@biomejs/cli-darwin-arm64@2.4.2':
resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.3.11':
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==}
'@biomejs/cli-darwin-x64@2.4.2':
resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.3.11':
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==}
'@biomejs/cli-linux-arm64-musl@2.4.2':
resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.3.11':
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
'@biomejs/cli-linux-arm64@2.4.2':
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.3.11':
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
'@biomejs/cli-linux-x64-musl@2.4.2':
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.3.11':
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
'@biomejs/cli-linux-x64@2.4.2':
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.3.11':
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
'@biomejs/cli-win32-arm64@2.4.2':
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.3.11':
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==}
'@biomejs/cli-win32-x64@2.4.2':
resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@ -344,36 +348,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@ -432,24 +442,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
@ -954,24 +968,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
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-validator-identifier': 7.28.5
'@biomejs/biome@2.3.11':
'@biomejs/biome@2.4.2':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.11
'@biomejs/cli-darwin-x64': 2.3.11
'@biomejs/cli-linux-arm64': 2.3.11
'@biomejs/cli-linux-arm64-musl': 2.3.11
'@biomejs/cli-linux-x64': 2.3.11
'@biomejs/cli-linux-x64-musl': 2.3.11
'@biomejs/cli-win32-arm64': 2.3.11
'@biomejs/cli-win32-x64': 2.3.11
'@biomejs/cli-darwin-arm64': 2.4.2
'@biomejs/cli-darwin-x64': 2.4.2
'@biomejs/cli-linux-arm64': 2.4.2
'@biomejs/cli-linux-arm64-musl': 2.4.2
'@biomejs/cli-linux-x64': 2.4.2
'@biomejs/cli-linux-x64-musl': 2.4.2
'@biomejs/cli-win32-arm64': 2.4.2
'@biomejs/cli-win32-x64': 2.4.2
'@biomejs/cli-darwin-arm64@2.3.11':
'@biomejs/cli-darwin-arm64@2.4.2':
optional: true
'@biomejs/cli-darwin-x64@2.3.11':
'@biomejs/cli-darwin-x64@2.4.2':
optional: true
'@biomejs/cli-linux-arm64-musl@2.3.11':
'@biomejs/cli-linux-arm64-musl@2.4.2':
optional: true
'@biomejs/cli-linux-arm64@2.3.11':
'@biomejs/cli-linux-arm64@2.4.2':
optional: true
'@biomejs/cli-linux-x64-musl@2.3.11':
'@biomejs/cli-linux-x64-musl@2.4.2':
optional: true
'@biomejs/cli-linux-x64@2.3.11':
'@biomejs/cli-linux-x64@2.4.2':
optional: true
'@biomejs/cli-win32-arm64@2.3.11':
'@biomejs/cli-win32-arm64@2.4.2':
optional: true
'@biomejs/cli-win32-x64@2.3.11':
'@biomejs/cli-win32-x64@2.4.2':
optional: true
'@cacheable/memory@2.0.7':

View File

@ -165,11 +165,13 @@ export const REPORT_STATUS_COLORS: Record<ReportStatus, string> = {
}
export const isOpenStatus = (status: ReportStatus): boolean => {
return ([
ReportStatus.NEW,
ReportStatus.TRIAGING,
ReportStatus.NEEDS_MORE_INFO,
] as ReportStatus[]).includes(status)
return (
[
ReportStatus.NEW,
ReportStatus.TRIAGING,
ReportStatus.NEEDS_MORE_INFO,
] as ReportStatus[]
).includes(status)
}
export const isClosedStatus = (status: ReportStatus): boolean => {

View File

@ -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"
}
}
}
}
]
}

View File

@ -2,22 +2,16 @@
// © AngelaMos | 2025
// eslint.config.js
// ===================
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import solid from "eslint-plugin-solid/configs/typescript";
import jsxA11y from "eslint-plugin-jsx-a11y";
import prettierConfig from "eslint-config-prettier";
import globals from "globals";
import js from '@eslint/js'
import prettierConfig from 'eslint-config-prettier'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import solid from 'eslint-plugin-solid/configs/typescript'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
ignores: [
"dist",
"node_modules",
"*.config.js",
"*.config.ts",
"*.min.js",
],
ignores: ['dist', 'node_modules', '*.config.js', '*.config.ts', '*.min.js'],
},
js.configs.recommended,
@ -26,16 +20,16 @@ export default tseslint.config(
...tseslint.configs.stylisticTypeChecked,
{
files: ["**/*.{ts,tsx}"],
files: ['**/*.{ts,tsx}'],
...solid,
plugins: {
...solid.plugins,
"jsx-a11y": jsxA11y,
'jsx-a11y': jsxA11y,
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: ["./tsconfig.json"],
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: { jsx: true },
},
@ -47,23 +41,23 @@ export default tseslint.config(
rules: {
...solid.rules,
"@typescript-eslint/no-unused-vars": [
"error",
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
"@typescript-eslint/consistent-type-imports": [
"error",
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
"@typescript-eslint/explicit-function-return-type": [
"error",
'@typescript-eslint/explicit-function-return-type': [
'error',
{
allowExpressions: true,
allowTypedFunctionExpressions: true,
@ -71,15 +65,15 @@ export default tseslint.config(
allowDirectConstAssertionInArrowFunctions: true,
},
],
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/array-type": ["error", { default: "array" }],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-confusing-void-expression": "off",
"@typescript-eslint/no-unnecessary-condition": "off",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/strict-boolean-expressions": [
"error",
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/array-type': ['error', { default: 'array' }],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/strict-boolean-expressions': [
'error',
{
allowString: false,
allowNumber: false,
@ -88,11 +82,11 @@ export default tseslint.config(
allowAny: true,
},
],
"@typescript-eslint/prefer-as-const": "error",
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-misused-promises": [
"error",
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
attributes: false,
@ -100,57 +94,57 @@ export default tseslint.config(
},
],
"solid/reactivity": "warn",
"solid/no-destructure": "warn",
"solid/jsx-no-undef": "error",
"solid/no-react-specific-props": "error",
"solid/prefer-for": "warn",
"solid/self-closing-comp": "error",
"solid/style-prop": "error",
"solid/no-innerhtml": "error",
"solid/no-unknown-namespaces": "error",
"solid/event-handlers": [
"error",
'solid/reactivity': 'warn',
'solid/no-destructure': 'warn',
'solid/jsx-no-undef': 'error',
'solid/no-react-specific-props': 'error',
'solid/prefer-for': 'warn',
'solid/self-closing-comp': 'error',
'solid/style-prop': 'error',
'solid/no-innerhtml': 'error',
'solid/no-unknown-namespaces': 'error',
'solid/event-handlers': [
'error',
{
ignoreCase: false,
warnOnSpread: true,
},
],
"solid/imports": "error",
"solid/no-proxy-apis": "off",
'solid/imports': 'error',
'solid/no-proxy-apis': 'off',
"jsx-a11y/alt-text": "error",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/no-noninteractive-element-interactions": "warn",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-role": "error",
"jsx-a11y/role-has-required-aria-props": "error",
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/no-static-element-interactions': 'error',
'jsx-a11y/no-noninteractive-element-interactions': 'warn',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/role-has-required-aria-props': 'error',
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-debugger": "error",
"no-alert": "error",
"no-var": "error",
"prefer-const": "error",
"prefer-template": "error",
"object-shorthand": "error",
"no-nested-ternary": "error",
"max-depth": ["error", 6],
"max-lines": [
"error",
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'error',
'no-alert': 'error',
'no-var': 'error',
'prefer-const': 'error',
'prefer-template': 'error',
'object-shorthand': 'error',
'no-nested-ternary': 'error',
'max-depth': ['error', 6],
'max-lines': [
'error',
{ max: 2000, skipBlankLines: true, skipComments: true },
],
complexity: ["error", 55],
complexity: ['error', 55],
},
},
{
files: ["src/index.tsx"],
files: ['src/index.tsx'],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
prettierConfig
);
)

File diff suppressed because it is too large Load Diff

View File

@ -7,36 +7,38 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"lint:eslint": "eslint .",
"lint:eslint:fix": "eslint . --fix",
"lint:types": "tsc --noEmit",
"lint": "npm run lint:eslint && npm run lint:types",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
"format": "biome format --write .",
"format:prettier": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nanostores/persistent": "^1.2.0",
"@nanostores/persistent": "^1.3.3",
"@nanostores/solid": "^1.1.1",
"@solidjs/router": "^0.15.3",
"@tanstack/solid-query": "^5.90.22",
"@solidjs/router": "^0.15.4",
"@tanstack/solid-query": "^5.90.23",
"nanostores": "^1.1.0",
"solid-js": "^1.9.10"
"solid-js": "^1.9.11"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@eslint/js": "^9.39.2",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.0.10",
"@tailwindcss/vite": "^4.2.0",
"@types/node": "^25.2.3",
"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-solid": "^0.14.5",
"globals": "^17.1.0",
"globals": "^17.3.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.1.17",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"typescript-eslint": "^8.56.0",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}

File diff suppressed because it is too large Load Diff

View File

@ -2,14 +2,14 @@
// © AngelaMos | 2025
// App.tsx
// ===================
import { Route } from "@solidjs/router";
import { type Component, lazy } from "solid-js";
import { Route } from '@solidjs/router'
import { type Component, lazy } from 'solid-js'
const Home = lazy(() => import("./pages/Home"));
const Register = lazy(() => import("./pages/Register"));
const Login = lazy(() => import("./pages/Login"));
const Chat = lazy(() => import("./pages/Chat"));
const NotFound = lazy(() => import("./pages/NotFound"));
const Home = lazy(() => import('./pages/Home'))
const Register = lazy(() => import('./pages/Register'))
const Login = lazy(() => import('./pages/Login'))
const Chat = lazy(() => import('./pages/Chat'))
const NotFound = lazy(() => import('./pages/NotFound'))
const App: Component = () => {
return (
@ -20,7 +20,7 @@ const App: Component = () => {
<Route path="/chat" component={Chat} />
<Route path="*" component={NotFound} />
</>
);
};
)
}
export default App;
export default App

View File

@ -2,8 +2,8 @@
* 8-bit styled auth card container
*/
import type { ParentProps, JSX } from "solid-js"
import { Show } from "solid-js"
import type { JSX, ParentProps } from 'solid-js'
import { Show } from 'solid-js'
interface AuthCardProps extends ParentProps {
title: string
@ -39,9 +39,7 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
</div>
<div class="mt-4 flex justify-center">
<span class="font-pixel text-[8px] text-gray">
END-TO-END ENCRYPTED
</span>
<span class="font-pixel text-[8px] text-gray">END-TO-END ENCRYPTED</span>
</div>
</div>
)
@ -49,13 +47,61 @@ export function AuthCard(props: AuthCardProps): JSX.Element {
function LockIcon(): JSX.Element {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<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
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<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>
)
}

View File

@ -3,41 +3,40 @@
// AuthForm.tsx
// ===================
import { createSignal, Show } from "solid-js"
import type { JSX } from "solid-js"
import { A } from "@solidjs/router"
import { Input } from "../UI/Input"
import { PasskeyButton } from "./PasskeyButton"
import { AuthCard } from "./AuthCard"
import {
validateUsername,
validateDisplayName,
} from "../../lib/validators"
import { authService } from "../../services"
import { showToast } from "../../stores"
import { ApiError } from "../../types"
import { A } from '@solidjs/router'
import type { JSX } from 'solid-js'
import { createSignal, Show } from 'solid-js'
import { validateDisplayName, validateUsername } from '../../lib/validators'
import { authService } from '../../services'
import { showToast } from '../../stores'
import { ApiError } from '../../types'
import { Input } from '../UI/Input'
import { AuthCard } from './AuthCard'
import { PasskeyButton } from './PasskeyButton'
type AuthMode = "login" | "register"
type AuthMode = 'login' | 'register'
interface AuthFormProps {
mode: AuthMode
}
export function AuthForm(props: AuthFormProps): JSX.Element {
const [username, setUsername] = createSignal("")
const [displayName, setDisplayName] = createSignal("")
const [username, setUsername] = createSignal('')
const [displayName, setDisplayName] = createSignal('')
const [loading, setLoading] = createSignal(false)
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 =>
isRegister()
? "REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION"
: "SIGN IN WITH YOUR PASSKEY TO CONTINUE"
? 'REGISTER WITH A PASSKEY FOR SECURE, PASSWORDLESS AUTHENTICATION'
: 'SIGN IN WITH YOUR PASSKEY TO CONTINUE'
const validateForm = (): boolean => {
let valid = true
@ -73,29 +72,37 @@ export function AuthForm(props: AuthFormProps): JSX.Element {
try {
if (isRegister()) {
await authService.register(username(), displayName())
showToast("success", "REGISTRATION COMPLETE", "YOUR PASSKEY HAS BEEN CREATED")
showToast(
'success',
'REGISTRATION COMPLETE',
'YOUR PASSKEY HAS BEEN CREATED'
)
} else {
const trimmedUsername = username().trim()
const usernameValue = trimmedUsername === "" ? undefined : trimmedUsername
const usernameValue = trimmedUsername === '' ? undefined : trimmedUsername
await authService.login(usernameValue)
showToast("success", "LOGIN SUCCESSFUL", "WELCOME BACK")
showToast('success', 'LOGIN SUCCESSFUL', 'WELCOME BACK')
}
} catch (error) {
let message = "AN UNEXPECTED ERROR OCCURRED"
let message = 'AN UNEXPECTED ERROR OCCURRED'
if (error instanceof ApiError) {
message = error.message.toUpperCase()
} else if (error instanceof Error) {
if (error.name === "NotAllowedError") {
message = "PASSKEY OPERATION CANCELLED"
} else if (error.name === "InvalidStateError") {
message = "PASSKEY ALREADY REGISTERED"
if (error.name === 'NotAllowedError') {
message = 'PASSKEY OPERATION CANCELLED'
} else if (error.name === 'InvalidStateError') {
message = 'PASSKEY ALREADY REGISTERED'
} else {
message = error.message.toUpperCase()
}
}
showToast("error", isRegister() ? "REGISTRATION FAILED" : "LOGIN FAILED", message)
showToast(
'error',
isRegister() ? 'REGISTRATION FAILED' : 'LOGIN FAILED',
message
)
} finally {
setLoading(false)
}
@ -158,14 +165,14 @@ export function AuthForm(props: AuthFormProps): JSX.Element {
when={isRegister()}
fallback={
<>
DON'T HAVE AN ACCOUNT?{" "}
DON'T HAVE AN ACCOUNT?{' '}
<A href="/register" class="text-orange hover:underline">
REGISTER
</A>
</>
}
>
ALREADY HAVE AN ACCOUNT?{" "}
ALREADY HAVE AN ACCOUNT?{' '}
<A href="/login" class="text-orange hover:underline">
SIGN IN
</A>

View File

@ -3,20 +3,16 @@
// PasskeyButton.tsx
// ===================
import type { JSX } from 'solid-js'
import { createSignal, onMount, Show } from 'solid-js'
import {
Show,
createSignal,
onMount,
} from "solid-js"
import type { JSX } from "solid-js"
import { Button } from "../UI/Button"
import {
isWebAuthnSupported,
isPlatformAuthenticatorAvailable,
} from "../../services"
isWebAuthnSupported,
} from '../../services'
import { Button } from '../UI/Button'
interface PasskeyButtonProps {
mode: "register" | "login"
mode: 'register' | 'login'
onClick: () => void | Promise<void>
loading?: boolean
disabled?: boolean
@ -40,9 +36,11 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
const buttonText = (): string => {
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 => {
@ -50,7 +48,7 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
}
return (
<div class={`flex flex-col gap-2 ${props.class ?? ""}`}>
<div class={`flex flex-col gap-2 ${props.class ?? ''}`}>
<Button
variant="primary"
size="lg"
@ -80,7 +78,13 @@ export function PasskeyButton(props: PasskeyButtonProps): JSX.Element {
function PasskeyIcon(): JSX.Element {
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="4" y="4" width="2" height="2" />
<rect x="6" y="2" width="4" height="2" />

View File

@ -2,6 +2,7 @@
// © AngelaMos | 2025
// index.ts
// ===================
export { PasskeyButton } from "./PasskeyButton"
export { AuthCard } from "./AuthCard"
export { AuthForm } from "./AuthForm"
export { AuthCard } from './AuthCard'
export { AuthForm } from './AuthForm'
export { PasskeyButton } from './PasskeyButton'

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025
// ChatHeader.tsx
// ===================
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import type { Room, Participant } from "../../types"
import { OnlineStatus } from "./OnlineStatus"
import { EncryptionBadge } from "./EncryptionBadge"
import { getUserStatus } from "../../stores"
import { IconButton } from "../UI/IconButton"
import type { JSX } from 'solid-js'
import { Show } from 'solid-js'
import { getUserStatus } from '../../stores'
import type { Participant, Room } from '../../types'
import { IconButton } from '../UI/IconButton'
import { EncryptionBadge } from './EncryptionBadge'
import { OnlineStatus } from './OnlineStatus'
interface ChatHeaderProps {
room: Room | null
@ -19,16 +20,16 @@ interface ChatHeaderProps {
export function ChatHeader(props: ChatHeaderProps): JSX.Element {
const otherParticipant = (): Participant | null => {
if (props.room?.type !== "direct") return null
if (props.room?.type !== 'direct') return null
return props.room.participants[0] ?? null
}
const displayName = (): string => {
const room = props.room
if (room === null) return "CHAT"
if (room === null) return 'CHAT'
if (room.name !== undefined && room.name !== null) return room.name
const other = otherParticipant()
return other?.display_name ?? other?.username ?? "CHAT"
return other?.display_name ?? other?.username ?? 'CHAT'
}
const initials = (): string => {
@ -37,14 +38,14 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
}
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 gap-3">
<div class="relative">
<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">
{initials()}
</span>
<span class="font-pixel text-[10px] text-orange">{initials()}</span>
</div>
<Show when={otherParticipant()} keyed>
{(participant) => (
@ -59,14 +60,12 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
</div>
<div>
<h2 class="font-pixel text-xs text-white">
{displayName()}
</h2>
<h2 class="font-pixel text-xs text-white">{displayName()}</h2>
<div class="flex items-center gap-2 mt-0.5">
<Show when={props.room?.is_encrypted}>
<EncryptionBadge isEncrypted showLabel size="sm" />
</Show>
<Show when={props.room?.type === "group"}>
<Show when={props.room?.type === 'group'}>
<span class="font-pixel text-[8px] text-gray">
{props.room?.participants.length ?? 0} MEMBERS
</span>
@ -107,7 +106,13 @@ export function ChatHeader(props: ChatHeaderProps): JSX.Element {
function InfoIcon(): JSX.Element {
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="7" width="2" height="6" />
</svg>
@ -116,7 +121,13 @@ function InfoIcon(): JSX.Element {
function SettingsIcon(): JSX.Element {
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="12" width="2" height="3" />
<rect x="1" y="7" width="3" height="2" />

View File

@ -2,11 +2,12 @@
// © AngelaMos | 2025
// ChatInput.tsx
// ===================
import { createSignal, Show, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import { Button } from "../UI/Button"
import { wsManager } from "../../websocket"
import { MESSAGE_MAX_LENGTH } from "../../types"
import type { JSX } from 'solid-js'
import { createSignal, onCleanup, Show } from 'solid-js'
import { MESSAGE_MAX_LENGTH } from '../../types'
import { wsManager } from '../../websocket'
import { Button } from '../UI/Button'
interface ChatInputProps {
roomId: string
@ -17,14 +18,15 @@ interface ChatInputProps {
}
export function ChatInput(props: ChatInputProps): JSX.Element {
const [message, setMessage] = createSignal("")
const [message, setMessage] = createSignal('')
const [isTyping, setIsTyping] = createSignal(false)
let typingTimeout: ReturnType<typeof setTimeout> | undefined
let inputRef: HTMLInputElement | undefined
const charCount = (): number => message().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 target = e.target as HTMLInputElement
@ -50,7 +52,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
const content = message().trim()
props.onSend?.(content)
setMessage("")
setMessage('')
if (isTyping()) {
setIsTyping(false)
@ -61,7 +63,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
@ -77,10 +79,14 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
})
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-1 flex items-center bg-black border-2 border-orange">
<span class="font-pixel text-[10px] text-orange px-2">&gt;&gt;&gt;</span>
<span class="font-pixel text-[10px] text-orange px-2">
&gt;&gt;&gt;
</span>
<input
ref={inputRef}
type="text"
@ -106,11 +112,11 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
<div class="flex items-center justify-between mt-2">
<Show when={isOverLimit()}>
<span class="font-pixel text-[8px] text-error">
MESSAGE TOO LONG
</span>
<span class="font-pixel text-[8px] text-error">MESSAGE TOO LONG</span>
</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}
</span>
</div>
@ -120,7 +126,13 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
function SendIcon(): JSX.Element {
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="6" y="3" width="2" height="2" />
<rect x="6" y="7" width="2" height="2" />

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025
// ConversationItem.tsx
// ===================
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import type { Room, Participant } from "../../types"
import { OnlineStatus } from "./OnlineStatus"
import { EncryptionBadge } from "./EncryptionBadge"
import { getUserStatus } from "../../stores"
import { formatRelativeTime } from "../../lib/date"
import type { JSX } from 'solid-js'
import { Show } from 'solid-js'
import { formatRelativeTime } from '../../lib/date'
import { getUserStatus } from '../../stores'
import type { Participant, Room } from '../../types'
import { EncryptionBadge } from './EncryptionBadge'
import { OnlineStatus } from './OnlineStatus'
interface ConversationItemProps {
room: Room
@ -19,14 +20,14 @@ interface ConversationItemProps {
export function ConversationItem(props: ConversationItemProps): JSX.Element {
const otherParticipant = (): Participant | null => {
if (props.room.type !== "direct") return null
if (props.room.type !== 'direct') return null
return props.room.participants[0] ?? null
}
const displayName = (): string => {
if (props.room.name) return props.room.name
const other = otherParticipant()
return other?.display_name ?? other?.username ?? "CHAT"
return other?.display_name ?? other?.username ?? 'CHAT'
}
const initials = (): string => {
@ -36,20 +37,20 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
const lastMessagePreview = (): string => {
const msg = props.room.last_message
if (msg === null || msg === undefined) return "NO MESSAGES"
if (msg.is_encrypted) return "ENCRYPTED MESSAGE"
if (msg === null || msg === undefined) return 'NO MESSAGES'
if (msg.is_encrypted) return 'ENCRYPTED MESSAGE'
const content = msg.content.slice(0, 40)
return content.length < msg.content.length ? `${content}...` : content
}
const lastMessageTime = (): string => {
const msg = props.room.last_message
if (msg === null || msg === undefined) return ""
if (msg === null || msg === undefined) return ''
return formatRelativeTime(msg.created_at)
}
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) {
return `${base} bg-orange text-black border-orange`
}
@ -59,13 +60,17 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
return (
<button
type="button"
class={`${containerClasses()} ${props.class ?? ""}`}
class={`${containerClasses()} ${props.class ?? ''}`}
onClick={() => props.onClick()}
>
<div class="flex items-start gap-3">
<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"}`}>
<span class={`font-pixel text-[10px] ${props.isActive ? "text-black" : "text-orange"}`}>
<div
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()}
</span>
</div>
@ -83,16 +88,22 @@ export function ConversationItem(props: ConversationItemProps): JSX.Element {
<div class="flex-1 min-w-0 text-left">
<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()}
</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()}
</span>
</div>
<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()}
</span>
<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}>
<div class="min-w-[18px] h-[18px] bg-orange flex items-center justify-center">
<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>
</div>
</Show>

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025
// ConversationList.tsx
// ===================
import { Show, For, createMemo } from "solid-js"
import type { JSX } from "solid-js"
import { useStore } from "@nanostores/solid"
import { $rooms, $activeRoomId, setActiveRoom } from "../../stores"
import { ConversationItem } from "./ConversationItem"
import { Spinner } from "../UI/Spinner"
import type { Room } from "../../types"
import { useStore } from '@nanostores/solid'
import type { JSX } from 'solid-js'
import { createMemo, For, Show } from 'solid-js'
import { $activeRoomId, $rooms, setActiveRoom } from '../../stores'
import type { Room } from '../../types'
import { Spinner } from '../UI/Spinner'
import { ConversationItem } from './ConversationItem'
interface ConversationListProps {
loading?: boolean
@ -35,11 +36,9 @@ export function ConversationList(props: ConversationListProps): JSX.Element {
}
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">
<h2 class="font-pixel text-[10px] text-orange">
CONVERSATIONS
</h2>
<h2 class="font-pixel text-[10px] text-orange">CONVERSATIONS</h2>
<Show when={props.onNewChat}>
<button
type="button"
@ -112,7 +111,13 @@ function EmptyConversations(props: EmptyConversationsProps): JSX.Element {
function PlusIcon(): JSX.Element {
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="1" y="5" width="10" height="2" />
</svg>
@ -121,7 +126,14 @@ function PlusIcon(): JSX.Element {
function ChatIcon(): JSX.Element {
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="3" y="9" width="3" height="20" />
<rect x="34" y="9" width="3" height="20" />

View File

@ -2,31 +2,31 @@
// © AngelaMos | 2025
// 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 {
isEncrypted: boolean
showLabel?: boolean
size?: "sm" | "md"
size?: 'sm' | 'md'
class?: string
}
export function EncryptionBadge(props: EncryptionBadgeProps): JSX.Element {
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 iconSize = (): string => (props.size === 'sm' ? 'w-3 h-3' : 'w-4 h-4')
const textSize = (): string =>
props.size === 'sm' ? 'text-[6px]' : 'text-[8px]'
return (
<Show when={props.isEncrypted}>
<div
class={`flex items-center gap-1 ${props.class ?? ""}`}
class={`flex items-center gap-1 ${props.class ?? ''}`}
title="END-TO-END ENCRYPTED"
>
<LockIcon class={`${iconSize()} text-success`} />
<Show when={props.showLabel}>
<span class={`font-pixel ${textSize()} text-success`}>
E2E
</span>
<span class={`font-pixel ${textSize()} text-success`}>E2E</span>
</Show>
</div>
</Show>
@ -50,31 +50,37 @@ function LockIcon(props: LockIconProps): JSX.Element {
<rect x="11" y="4" width="1" height="4" />
<rect x="3" y="8" width="10" height="1" />
<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>
)
}
interface UnencryptedBadgeProps {
showLabel?: boolean
size?: "sm" | "md"
size?: 'sm' | 'md'
class?: string
}
export function UnencryptedBadge(props: UnencryptedBadgeProps): JSX.Element {
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 iconSize = (): string => (props.size === 'sm' ? 'w-3 h-3' : 'w-4 h-4')
const textSize = (): string =>
props.size === 'sm' ? 'text-[6px]' : 'text-[8px]'
return (
<div
class={`flex items-center gap-1 ${props.class ?? ""}`}
class={`flex items-center gap-1 ${props.class ?? ''}`}
title="NOT ENCRYPTED"
>
<UnlockIcon class={`${iconSize()} text-gray`} />
<Show when={props.showLabel}>
<span class={`font-pixel ${textSize()} text-gray`}>
UNENCRYPTED
</span>
<span class={`font-pixel ${textSize()} text-gray`}>UNENCRYPTED</span>
</Show>
</div>
)
@ -93,7 +99,14 @@ function UnlockIcon(props: LockIconProps): JSX.Element {
<rect x="11" y="2" width="1" height="2" />
<rect x="3" y="8" width="10" height="1" />
<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>
)
}

View File

@ -2,11 +2,12 @@
// © AngelaMos | 2025
// MessageBubble.tsx
// ===================
import { Show, Switch, Match } from "solid-js"
import type { JSX } from "solid-js"
import type { Message, MessageStatus } from "../../types"
import { EncryptionBadge } from "./EncryptionBadge"
import { formatTime } from "../../lib/date"
import type { JSX } from 'solid-js'
import { Match, Show, Switch } from 'solid-js'
import { formatTime } from '../../lib/date'
import type { Message, MessageStatus } from '../../types'
import { EncryptionBadge } from './EncryptionBadge'
interface MessageBubbleProps {
message: Message
@ -17,7 +18,7 @@ interface MessageBubbleProps {
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
const bubbleClasses = (): string => {
const base = "max-w-[70%] p-4"
const base = 'max-w-[70%] p-4'
if (props.isOwnMessage) {
return `${base} bg-orange text-black`
}
@ -25,7 +26,9 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
}
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()}>
<Show when={props.showSender === true && !props.isOwnMessage}>
<div class="font-pixel text-[10px] text-orange mb-2">
@ -33,7 +36,10 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
</div>
</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}
</div>
@ -41,7 +47,9 @@ export function MessageBubble(props: MessageBubbleProps): JSX.Element {
<Show when={props.message.is_encrypted}>
<EncryptionBadge isEncrypted size="sm" />
</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)}
</span>
<Show when={props.isOwnMessage}>
@ -59,20 +67,20 @@ interface MessageStatusIconProps {
function MessageStatusIcon(props: MessageStatusIconProps): JSX.Element {
return (
<Switch fallback={<></>}>
<Match when={props.status === "sending"}>
<Switch fallback={null}>
<Match when={props.status === 'sending'}>
<ClockIcon class="w-3 h-3 text-black/40" />
</Match>
<Match when={props.status === "sent"}>
<Match when={props.status === 'sent'}>
<CheckIcon class="w-3 h-3 text-black/60" />
</Match>
<Match when={props.status === "delivered"}>
<Match when={props.status === 'delivered'}>
<DoubleCheckIcon class="w-3 h-3 text-black/60" />
</Match>
<Match when={props.status === "read"}>
<Match when={props.status === 'read'}>
<DoubleCheckIcon class="w-3 h-3 text-success" />
</Match>
<Match when={props.status === "failed"}>
<Match when={props.status === 'failed'}>
<ErrorIcon class="w-3 h-3 text-error" />
</Match>
</Switch>
@ -85,7 +93,12 @@ interface IconProps {
function ClockIcon(props: IconProps): JSX.Element {
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="3" 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 {
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="4" y="8" 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 {
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="2" y="8" 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 {
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="3" y="2" width="2" height="1" />
<rect x="7" y="2" width="2" height="1" />

View File

@ -2,15 +2,19 @@
// © AngelaMos | 2025
// MessageList.tsx
// ===================
import { Show, For, createEffect, createSignal, onMount } from "solid-js"
import type { JSX } from "solid-js"
import { useStore } from "@nanostores/solid"
import { $activeRoomMessages, $activeRoomPendingMessages } from "../../stores"
import { $userId } from "../../stores"
import { MessageBubble } from "./MessageBubble"
import { TypingIndicator } from "./TypingIndicator"
import { Spinner } from "../UI/Spinner"
import type { Message } from "../../types"
import { useStore } from '@nanostores/solid'
import type { JSX } from 'solid-js'
import { createEffect, createSignal, For, onMount, Show } from 'solid-js'
import {
$activeRoomMessages,
$activeRoomPendingMessages,
$userId,
} from '../../stores'
import type { Message } from '../../types'
import { Spinner } from '../UI/Spinner'
import { MessageBubble } from './MessageBubble'
import { TypingIndicator } from './TypingIndicator'
interface MessageListProps {
roomId: string
@ -39,7 +43,12 @@ export function MessageList(props: MessageListProps): JSX.Element {
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
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()
}
}
@ -61,10 +70,10 @@ export function MessageList(props: MessageListProps): JSX.Element {
const groups = new Map<string, Message[]>()
for (const msg of msgs) {
const date = new Date(msg.created_at).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
const date = new Date(msg.created_at).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})
const existing = groups.get(date) ?? []
@ -77,7 +86,7 @@ export function MessageList(props: MessageListProps): JSX.Element {
return (
<div
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}
>
<Show when={props.loading}>
@ -86,10 +95,7 @@ export function MessageList(props: MessageListProps): JSX.Element {
</div>
</Show>
<Show
when={allMessages().length > 0}
fallback={<EmptyMessages />}
>
<Show when={allMessages().length > 0} fallback={<EmptyMessages />}>
<For each={Array.from(groupMessagesByDate(allMessages()).entries())}>
{([date, dateMessages]) => (
<div class="mb-6">
@ -101,7 +107,9 @@ export function MessageList(props: MessageListProps): JSX.Element {
index() > 0 ? dateMessages[index() - 1] : undefined
const showSender = (): boolean => {
const prev = prevMessage()
return prev === undefined || prev.sender_id !== message.sender_id
return (
prev === undefined || prev.sender_id !== message.sender_id
)
}
return (
@ -130,12 +138,8 @@ function EmptyMessages(): JSX.Element {
return (
<div class="h-full flex flex-col items-center justify-center py-12">
<MessageIcon />
<p class="font-pixel text-[10px] text-gray mt-4">
NO MESSAGES YET
</p>
<p class="font-pixel text-[8px] text-gray mt-1">
START THE CONVERSATION
</p>
<p class="font-pixel text-[10px] text-gray mt-4">NO MESSAGES YET</p>
<p class="font-pixel text-[8px] text-gray mt-1">START THE CONVERSATION</p>
</div>
)
}
@ -148,9 +152,7 @@ function DateSeparator(props: DateSeparatorProps): JSX.Element {
return (
<div class="flex items-center gap-4 my-4">
<div class="flex-1 h-px bg-dark-gray" />
<span class="font-pixel text-[8px] text-gray uppercase">
{props.date}
</span>
<span class="font-pixel text-[8px] text-gray uppercase">{props.date}</span>
<div class="flex-1 h-px bg-dark-gray" />
</div>
)
@ -158,7 +160,14 @@ function DateSeparator(props: DateSeparatorProps): JSX.Element {
function MessageIcon(): JSX.Element {
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="4" y="12" width="4" height="24" />
<rect x="40" y="12" width="4" height="24" />

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025
// NewConversation.tsx
// ===================
import { createSignal, Show } from "solid-js"
import type { JSX } from "solid-js"
import { Modal } from "../UI/Modal"
import { Button } from "../UI/Button"
import { UserSearch } from "./UserSearch"
import type { User } from "../../types"
import { showToast } from "../../stores"
import type { JSX } from 'solid-js'
import { createSignal, Show } from 'solid-js'
import { showToast } from '../../stores'
import type { User } from '../../types'
import { Button } from '../UI/Button'
import { Modal } from '../UI/Modal'
import { UserSearch } from './UserSearch'
interface NewConversationProps {
isOpen: boolean
@ -32,10 +33,14 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
try {
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()
} catch (_error: unknown) {
showToast("error", "FAILED TO CREATE CHAT", "PLEASE TRY AGAIN")
showToast('error', 'FAILED TO CREATE CHAT', 'PLEASE TRY AGAIN')
} finally {
setLoading(false)
}
@ -56,9 +61,9 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
>
<div class="space-y-4">
<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
</label>
</span>
<UserSearch
onSelect={handleUserSelect}
placeholder="ENTER USERNAME..."
@ -97,12 +102,7 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
</Show>
<div class="flex items-center gap-3 pt-4 border-t-2 border-dark-gray">
<Button
variant="secondary"
size="md"
onClick={handleClose}
fullWidth
>
<Button variant="secondary" size="md" onClick={handleClose} fullWidth>
CANCEL
</Button>
<Button
@ -123,7 +123,13 @@ export function NewConversation(props: NewConversationProps): JSX.Element {
function CloseIcon(): JSX.Element {
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="2" y="3" width="2" height="2" />
<rect x="3" y="4" width="2" height="2" />

View File

@ -2,57 +2,57 @@
// © AngelaMos | 2025
// OnlineStatus.tsx
// ===================
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import type { PresenceStatus } from "../../types"
import type { JSX } from 'solid-js'
import { Show } from 'solid-js'
import type { PresenceStatus } from '../../types'
interface OnlineStatusProps {
status: PresenceStatus
size?: "sm" | "md" | "lg"
size?: 'sm' | 'md' | 'lg'
showLabel?: boolean
class?: string
}
const SIZE_MAP = {
sm: "w-2 h-2",
md: "w-3 h-3",
lg: "w-4 h-4",
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4',
}
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 => {
switch (props.status) {
case "online":
return "bg-success"
case "away":
return "bg-away"
case "offline":
return "bg-offline"
case 'online':
return 'bg-success'
case 'away':
return 'bg-away'
case 'offline':
return 'bg-offline'
default:
return "bg-gray"
return 'bg-gray'
}
}
const statusLabel = (): string => {
switch (props.status) {
case "online":
return "ONLINE"
case "away":
return "AWAY"
case "offline":
return "OFFLINE"
case 'online':
return 'ONLINE'
case 'away':
return 'AWAY'
case 'offline':
return 'OFFLINE'
default:
return "UNKNOWN"
return 'UNKNOWN'
}
}
return (
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
<div
class={`${sizeClass()} ${statusColor()}`}
role="status"
<div class={`flex items-center gap-2 ${props.class ?? ''}`}>
<output
class={`${sizeClass()} ${statusColor()} block`}
aria-label={statusLabel()}
/>
<Show when={props.showLabel === true}>

View File

@ -2,10 +2,11 @@
// © AngelaMos | 2025
// TypingIndicator.tsx
// ===================
import { Show, For } from "solid-js"
import type { JSX } from "solid-js"
import { useStore } from "@nanostores/solid"
import { $activeRoomTypingUsernames } from "../../stores"
import { useStore } from '@nanostores/solid'
import type { JSX } from 'solid-js'
import { For, Show } from 'solid-js'
import { $activeRoomTypingUsernames } from '../../stores'
interface TypingIndicatorProps {
class?: string
@ -16,7 +17,7 @@ export function TypingIndicator(props: TypingIndicatorProps): JSX.Element {
const typingText = (): string => {
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 === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
@ -24,11 +25,9 @@ export function TypingIndicator(props: TypingIndicatorProps): JSX.Element {
return (
<Show when={typingUsernames().length > 0}>
<div class={`flex items-center gap-2 ${props.class ?? ""}`}>
<div class={`flex items-center gap-2 ${props.class ?? ''}`}>
<TypingDots />
<span class="font-pixel text-[8px] text-gray">
{typingText()}
</span>
<span class="font-pixel text-[8px] text-gray">{typingText()}</span>
</div>
</Show>
)
@ -41,7 +40,7 @@ function TypingDots(): JSX.Element {
{(index) => (
<div
class="w-1 h-1 bg-orange animate-bounce-pixel"
style={{ "animation-delay": `${index * 150}ms` }}
style={{ 'animation-delay': `${index * 150}ms` }}
/>
)}
</For>
@ -54,10 +53,12 @@ interface TypingIndicatorInlineProps {
class?: string
}
export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.Element {
export function TypingIndicatorInline(
props: TypingIndicatorInlineProps
): JSX.Element {
const typingText = (): string => {
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 === 2) return `${users[0]} AND ${users[1]} ARE TYPING`
return `${users[0]} AND ${users.length - 1} OTHERS ARE TYPING`
@ -65,11 +66,9 @@ export function TypingIndicatorInline(props: TypingIndicatorInlineProps): JSX.El
return (
<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 />
<span class="font-pixel text-[8px] text-gray">
{typingText()}
</span>
<span class="font-pixel text-[8px] text-gray">{typingText()}</span>
</div>
</Show>
)

View File

@ -2,13 +2,14 @@
// © AngelaMos | 2025
// UserSearch.tsx
// ===================
import { createSignal, Show, For, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import { Input } from "../UI/Input"
import { Spinner } from "../UI/Spinner"
import { api } from "../../lib/api-client"
import { USER_SEARCH_MIN_LENGTH, USER_SEARCH_DEFAULT_LIMIT } from "../../config"
import type { User } from "../../types"
import type { JSX } from 'solid-js'
import { createSignal, For, onCleanup, Show } from 'solid-js'
import { USER_SEARCH_DEFAULT_LIMIT, USER_SEARCH_MIN_LENGTH } from '../../config'
import { api } from '../../lib/api-client'
import type { User } from '../../types'
import { Input } from '../UI/Input'
import { Spinner } from '../UI/Spinner'
interface UserSearchProps {
onSelect: (user: User) => void
@ -24,7 +25,7 @@ interface SearchResult {
}
export function UserSearch(props: UserSearchProps): JSX.Element {
const [query, setQuery] = createSignal("")
const [query, setQuery] = createSignal('')
const [result, setResult] = createSignal<SearchResult>({
users: [],
loading: false,
@ -61,14 +62,15 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
})
setResult({ users: response.users, loading: false, error: null })
} 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 })
}
}
const handleSelect = (user: User): void => {
props.onSelect(user)
setQuery("")
setQuery('')
setResult({ users: [], loading: false, error: null })
setIsFocused(false)
}
@ -79,7 +81,10 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
}
const showResults = (): boolean => {
return isFocused() && (result().loading || filteredUsers().length > 0 || result().error !== null)
return (
isFocused() &&
(result().loading || filteredUsers().length > 0 || result().error !== null)
)
}
onCleanup(() => {
@ -89,10 +94,10 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
})
return (
<div class={`relative ${props.class ?? ""}`}>
<div class={`relative ${props.class ?? ''}`}>
<Input
name="user-search"
placeholder={props.placeholder ?? "SEARCH USERS..."}
placeholder={props.placeholder ?? 'SEARCH USERS...'}
value={query()}
onInput={handleInput}
onFocus={() => setIsFocused(true)}
@ -143,11 +148,16 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
</For>
</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">
<span class="font-pixel text-[10px] text-gray">
NO USERS FOUND
</span>
<span class="font-pixel text-[10px] text-gray">NO USERS FOUND</span>
</div>
</Show>
</div>
@ -158,7 +168,14 @@ export function UserSearch(props: UserSearchProps): JSX.Element {
function SearchIcon(): JSX.Element {
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="2" y="2" width="2" height="1" />
<rect x="8" y="2" width="2" height="1" />

View File

@ -2,14 +2,15 @@
// © AngelaMos | 2025
// index.ts
// ===================
export * from "./ConversationList"
export * from "./ConversationItem"
export * from "./MessageList"
export * from "./MessageBubble"
export * from "./ChatInput"
export * from "./ChatHeader"
export * from "./TypingIndicator"
export * from "./OnlineStatus"
export * from "./EncryptionBadge"
export * from "./UserSearch"
export * from "./NewConversation"
export * from './ChatHeader'
export * from './ChatInput'
export * from './ConversationItem'
export * from './ConversationList'
export * from './EncryptionBadge'
export * from './MessageBubble'
export * from './MessageList'
export * from './NewConversation'
export * from './OnlineStatus'
export * from './TypingIndicator'
export * from './UserSearch'

View File

@ -2,12 +2,12 @@
* Main application shell layout
*/
import type { ParentProps, JSX } from "solid-js"
import { Show } from "solid-js"
import { useStore } from "@nanostores/solid"
import { $sidebarOpen, $isMobile } from "../../stores"
import { Sidebar } from "./Sidebar"
import { Header } from "./Header"
import { useStore } from '@nanostores/solid'
import type { JSX, ParentProps } from 'solid-js'
import { Show } from 'solid-js'
import { $isMobile, $sidebarOpen } from '../../stores'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
interface AppShellProps extends ParentProps {
showSidebar?: boolean
@ -34,15 +34,19 @@ export function AppShell(props: AppShellProps): JSX.Element {
</div>
</Show>
<main class="flex-1 overflow-hidden bg-black">
{props.children}
</main>
<main class="flex-1 overflow-hidden bg-black">{props.children}</main>
</div>
<Show when={isMobile() && sidebarOpen()}>
<div
class="fixed inset-0 z-30 bg-black/80"
<button
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)}
onKeyDown={(e) => {
if (e.key === 'Escape') $sidebarOpen.set(false)
}}
/>
</Show>
</div>

View File

@ -2,20 +2,20 @@
* 8-bit styled header/navbar component
*/
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { A, useLocation } from "@solidjs/router"
import { useStore } from "@nanostores/solid"
import { useStore } from '@nanostores/solid'
import { A, useLocation } from '@solidjs/router'
import type { JSX } from 'solid-js'
import { Show } from 'solid-js'
import {
$currentUser,
$isAuthenticated,
$sidebarOpen,
$currentUser,
toggleSidebar,
openModal,
} from "../../stores"
import { IconButton } from "../UI/IconButton"
import { Badge } from "../UI/Badge"
import { Avatar } from "../UI/Avatar"
toggleSidebar,
} from '../../stores'
import { Avatar } from '../UI/Avatar'
import { Badge } from '../UI/Badge'
import { IconButton } from '../UI/IconButton'
export function Header(): JSX.Element {
const isAuthenticated = useStore($isAuthenticated)
@ -30,7 +30,7 @@ export function Header(): JSX.Element {
<Show when={isAuthenticated()}>
<IconButton
icon={sidebarOpen() ? <CloseMenuIcon /> : <MenuIcon />}
ariaLabel={sidebarOpen() ? "Close menu" : "Open menu"}
ariaLabel={sidebarOpen() ? 'Close menu' : 'Open menu'}
onClick={toggleSidebar}
size="sm"
/>
@ -75,20 +75,26 @@ export function Header(): JSX.Element {
icon={<SearchIcon />}
ariaLabel="Search"
size="sm"
onClick={() => openModal("new-conversation")}
onClick={() => openModal('new-conversation')}
/>
<Show when={currentUser()} keyed>
{(user) => (
<A
href="/settings"
class={`flex items-center gap-2 px-2 py-1 border-2 ${
location.pathname === "/settings"
? "border-orange bg-orange text-black"
: "border-transparent text-white hover:text-orange"
location.pathname === '/settings'
? 'border-orange bg-orange text-black'
: 'border-transparent text-white hover:text-orange'
}`}
>
<Avatar 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>
<Avatar
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>
)}
</Show>
@ -101,7 +107,13 @@ export function Header(): JSX.Element {
function MenuIcon(): JSX.Element {
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="7" 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 {
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="5" y="5" 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 {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<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
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
>
<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>
)
}
function SearchIcon(): JSX.Element {
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="2" y="4" width="2" height="6" />
<rect x="10" y="4" width="2" height="6" />

View File

@ -2,12 +2,12 @@
* Protected route wrapper that redirects unauthenticated users
*/
import type { ParentProps, JSX } from "solid-js"
import { Show, createEffect } from "solid-js"
import { useNavigate, useLocation } from "@solidjs/router"
import { useStore } from "@nanostores/solid"
import { $isAuthenticated } from "../../stores"
import { Spinner } from "../UI/Spinner"
import { useStore } from '@nanostores/solid'
import { useLocation, useNavigate } from '@solidjs/router'
import type { JSX, ParentProps } from 'solid-js'
import { createEffect, Show } from 'solid-js'
import { $isAuthenticated } from '../../stores'
import { Spinner } from '../UI/Spinner'
interface ProtectedRouteProps extends ParentProps {
redirectTo?: string
@ -18,14 +18,15 @@ export function ProtectedRoute(props: ProtectedRouteProps): JSX.Element {
const location = useLocation()
const isAuthenticated = useStore($isAuthenticated)
const redirectPath = (): string => props.redirectTo ?? "/login"
const redirectPath = (): string => props.redirectTo ?? '/login'
createEffect(() => {
if (!isAuthenticated()) {
const currentPath = location.pathname
const redirectUrl = currentPath !== "/"
? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}`
: redirectPath()
const redirectUrl =
currentPath !== '/'
? `${redirectPath()}?redirect=${encodeURIComponent(currentPath)}`
: redirectPath()
navigate(redirectUrl, { replace: true })
}
@ -58,7 +59,7 @@ export function GuestRoute(props: ParentProps): JSX.Element {
createEffect(() => {
if (isAuthenticated()) {
const params = new URLSearchParams(location.search)
const redirectPath = params.get("redirect") ?? "/chat"
const redirectPath = params.get('redirect') ?? '/chat'
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="flex flex-col items-center gap-4">
<Spinner size="lg" />
<span class="font-pixel text-[10px] text-orange">
REDIRECTING...
</span>
<span class="font-pixel text-[10px] text-orange">REDIRECTING...</span>
</div>
</div>
}

View File

@ -2,21 +2,21 @@
* Sidebar - Room list only
*/
import type { JSX } from "solid-js"
import { Show, Index, createMemo } from "solid-js"
import type { Participant } from "../../types"
import { useStore } from "@nanostores/solid"
import { useStore } from '@nanostores/solid'
import type { JSX } from 'solid-js'
import { createMemo, Index, Show } from 'solid-js'
import {
$activeRoomId,
$currentUser,
$rooms,
$activeRoomId,
$totalUnreadCount,
setActiveRoom,
openModal,
} from "../../stores"
import { Avatar } from "../UI/Avatar"
import { Badge } from "../UI/Badge"
import { IconButton } from "../UI/IconButton"
setActiveRoom,
} from '../../stores'
import type { Participant } from '../../types'
import { Avatar } from '../UI/Avatar'
import { Badge } from '../UI/Badge'
import { IconButton } from '../UI/IconButton'
export function Sidebar(): JSX.Element {
const currentUser = useStore($currentUser)
@ -39,7 +39,9 @@ export function Sidebar(): JSX.Element {
<div class="flex items-center justify-between">
<h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
<Show when={totalUnread() > 0}>
<Badge variant="primary" size="xs">{totalUnread()}</Badge>
<Badge variant="primary" size="xs">
{totalUnread()}
</Badge>
</Show>
</div>
</div>
@ -51,16 +53,22 @@ export function Sidebar(): JSX.Element {
variant="subtle"
size="sm"
class="w-full justify-start gap-2 px-3"
onClick={() => openModal("new-conversation")}
onClick={() => openModal('new-conversation')}
/>
</div>
<div class="flex-1 overflow-y-auto p-2">
<Index each={sortedRooms()}>
{(room, idx) => {
{(room, _idx) => {
const isActive = createMemo(() => activeRoomId() === room().id)
const other = createMemo(() => room().participants?.find((p: Participant) => p.user_id !== currentUser()?.id))
const displayName = createMemo(() => room().name ?? other()?.display_name ?? "Chat")
const other = createMemo(() =>
room().participants?.find(
(p: Participant) => p.user_id !== currentUser()?.id
)
)
const displayName = createMemo(
() => room().name ?? other()?.display_name ?? 'Chat'
)
return (
<button
@ -69,17 +77,19 @@ export function Sidebar(): JSX.Element {
data-active={isActive()}
onClick={() => setActiveRoom(room().id)}
style={{
width: "100%",
display: "flex",
"align-items": "center",
gap: "12px",
padding: "12px",
"margin-bottom": "4px",
border: isActive() ? "2px solid #FF5300" : "2px solid transparent",
background: isActive() ? "#FF5300" : "black",
color: isActive() ? "black" : "white",
cursor: "pointer",
"text-align": "left",
width: '100%',
display: 'flex',
'align-items': 'center',
gap: '12px',
padding: '12px',
'margin-bottom': '4px',
border: isActive()
? '2px solid #FF5300'
: '2px solid transparent',
background: isActive() ? '#FF5300' : 'black',
color: isActive() ? 'black' : 'white',
cursor: 'pointer',
'text-align': 'left',
}}
>
<Avatar
@ -87,14 +97,14 @@ export function Sidebar(): JSX.Element {
size="sm"
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">
{displayName()}
</span>
<Show when={room().last_message}>
<p
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}
</p>
@ -116,7 +126,13 @@ export function Sidebar(): JSX.Element {
function PlusIcon(): JSX.Element {
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="2" y="7" width="12" height="2" />
</svg>

View File

@ -2,7 +2,7 @@
// © AngelaMos | 2025
// index.ts
// ===================
export { AppShell } from "./AppShell"
export { Sidebar } from "./Sidebar"
export { Header } from "./Header"
export { ProtectedRoute, GuestRoute } from "./ProtectedRoute"
export { AppShell } from './AppShell'
export { Header } from './Header'
export { GuestRoute, ProtectedRoute } from './ProtectedRoute'
export { Sidebar } from './Sidebar'

View File

@ -2,37 +2,37 @@
* 8-bit styled avatar component
*/
import { Show, createSignal, onMount } from "solid-js"
import type { JSX } from "solid-js"
import type { AvatarProps, Size, PresenceStatus } from "../../types"
import type { JSX } from 'solid-js'
import { createSignal, onMount, Show } from 'solid-js'
import type { AvatarProps, PresenceStatus, Size } from '../../types'
const SIZE_CLASSES: Record<Size, string> = {
xs: "w-6 h-6 text-[8px]",
sm: "w-8 h-8 text-[10px]",
md: "w-10 h-10 text-xs",
lg: "w-12 h-12 text-sm",
xl: "w-16 h-16 text-base",
xs: 'w-6 h-6 text-[8px]',
sm: 'w-8 h-8 text-[10px]',
md: 'w-10 h-10 text-xs',
lg: 'w-12 h-12 text-sm',
xl: 'w-16 h-16 text-base',
}
const STATUS_COLORS: Record<PresenceStatus, string> = {
online: "bg-success",
away: "bg-orange",
offline: "bg-gray",
online: 'bg-success',
away: 'bg-orange',
offline: 'bg-gray',
}
const STATUS_SIZE: Record<Size, string> = {
xs: "w-2 h-2",
sm: "w-2.5 h-2.5",
md: "w-3 h-3",
lg: "w-3.5 h-3.5",
xl: "w-4 h-4",
xs: 'w-2 h-2',
sm: 'w-2.5 h-2.5',
md: 'w-3 h-3',
lg: 'w-3.5 h-3.5',
xl: 'w-4 h-4',
}
export function Avatar(props: AvatarProps): JSX.Element {
const [imageError, setImageError] = createSignal(false)
let imgRef: HTMLImageElement | undefined
const size = (): Size => props.size ?? "md"
const size = (): Size => props.size ?? 'md'
const showStatus = (): boolean => props.showStatus ?? false
const getFallbackInitials = (): string => {
@ -44,7 +44,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
onMount(() => {
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
bg-black border-2 border-orange
${SIZE_CLASSES[size()]}
${props.class ?? ""}
${props.class ?? ''}
`}
>
<Show
@ -74,7 +74,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
src={props.src}
alt={props.alt}
class="w-full h-full object-cover"
style={{ "image-rendering": "pixelated" }}
style={{ 'image-rendering': 'pixelated' }}
/>
</Show>

View File

@ -2,37 +2,37 @@
* 8-bit styled badge component
*/
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import type { BadgeProps, Size, BadgeVariant } from "../../types"
import type { JSX } from 'solid-js'
import { Show } from 'solid-js'
import type { BadgeProps, BadgeVariant, Size } from '../../types'
const SIZE_CLASSES: Record<Size, string> = {
xs: "px-1 py-0.5 text-[6px]",
sm: "px-1.5 py-0.5 text-[8px]",
md: "px-2 py-1 text-[10px]",
lg: "px-3 py-1 text-xs",
xl: "px-4 py-1.5 text-sm",
xs: 'px-1 py-0.5 text-[6px]',
sm: 'px-1.5 py-0.5 text-[8px]',
md: 'px-2 py-1 text-[10px]',
lg: 'px-3 py-1 text-xs',
xl: 'px-4 py-1.5 text-sm',
}
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
default: "bg-dark-gray text-white border-gray",
primary: "bg-black text-orange border-orange",
success: "bg-black text-success border-success",
warning: "bg-black text-orange border-orange",
error: "bg-black text-error border-error",
default: 'bg-dark-gray text-white border-gray',
primary: 'bg-black text-orange border-orange',
success: 'bg-black text-success border-success',
warning: 'bg-black text-orange border-orange',
error: 'bg-black text-error border-error',
}
const DOT_COLORS: Record<BadgeVariant, string> = {
default: "bg-gray",
primary: "bg-orange",
success: "bg-success",
warning: "bg-orange",
error: "bg-error",
default: 'bg-gray',
primary: 'bg-orange',
success: 'bg-success',
warning: 'bg-orange',
error: 'bg-error',
}
export function Badge(props: BadgeProps): JSX.Element {
const variant = (): BadgeVariant => props.variant ?? "default"
const size = (): Size => props.size ?? "md"
const variant = (): BadgeVariant => props.variant ?? 'default'
const size = (): Size => props.size ?? 'md'
const showDot = (): boolean => props.dot ?? false
return (
@ -42,7 +42,7 @@ export function Badge(props: BadgeProps): JSX.Element {
font-pixel border-2 uppercase
${SIZE_CLASSES[size()]}
${VARIANT_CLASSES[variant()]}
${props.class ?? ""}
${props.class ?? ''}
`}
>
<Show when={showDot()}>

View File

@ -2,17 +2,17 @@
* 8-bit styled button component
*/
import { Show, splitProps } from "solid-js"
import type { JSX } from "solid-js"
import type { ButtonProps, Size, ButtonVariant } from "../../types"
import { Spinner } from "./Spinner"
import type { JSX } from 'solid-js'
import { Show, splitProps } from 'solid-js'
import type { ButtonProps, ButtonVariant, Size } from '../../types'
import { Spinner } from './Spinner'
const SIZE_CLASSES: Record<Size, string> = {
xs: "px-2 py-1 text-[8px]",
sm: "px-3 py-1.5 text-[10px]",
md: "px-4 py-2 text-xs",
lg: "px-6 py-3 text-sm",
xl: "px-8 py-4 text-base",
xs: 'px-2 py-1 text-[8px]',
sm: 'px-3 py-1.5 text-[10px]',
md: 'px-4 py-2 text-xs',
lg: 'px-6 py-3 text-sm',
xl: 'px-8 py-4 text-base',
}
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
@ -49,21 +49,21 @@ const DISABLED_CLASSES = `
export function Button(props: ButtonProps): JSX.Element {
const [local, rest] = splitProps(props, [
"variant",
"size",
"fullWidth",
"disabled",
"loading",
"leftIcon",
"rightIcon",
"type",
"onClick",
"class",
"children",
'variant',
'size',
'fullWidth',
'disabled',
'loading',
'leftIcon',
'rightIcon',
'type',
'onClick',
'class',
'children',
])
const variant = (): ButtonVariant => local.variant ?? "primary"
const size = (): Size => local.size ?? "md"
const variant = (): ButtonVariant => local.variant ?? 'primary'
const size = (): Size => local.size ?? 'md'
const isDisabled = (): boolean => local.disabled ?? false
const isLoading = (): boolean => local.loading ?? false
@ -75,7 +75,7 @@ export function Button(props: ButtonProps): JSX.Element {
return (
<button
type={local.type ?? "button"}
type={local.type ?? 'button'}
disabled={isDisabled() || isLoading()}
onClick={handleClick}
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
${SIZE_CLASSES[size()]}
${VARIANT_CLASSES[variant()]}
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""}
${local.fullWidth === true ? "w-full" : ""}
${local.class ?? ""}
${isDisabled() || isLoading() ? DISABLED_CLASSES : ''}
${local.fullWidth === true ? 'w-full' : ''}
${local.class ?? ''}
`}
{...rest}
>
<Show when={isLoading()}>
<Spinner size="xs" />
</Show>
<Show when={!isLoading() && local.leftIcon}>
{local.leftIcon}
</Show>
<Show when={!isLoading() && local.leftIcon}>{local.leftIcon}</Show>
<span>{local.children}</span>
<Show when={!isLoading() && local.rightIcon}>
{local.rightIcon}
</Show>
<Show when={!isLoading() && local.rightIcon}>{local.rightIcon}</Show>
</button>
)
}

View File

@ -2,30 +2,32 @@
* 8-bit styled dropdown menu component
*/
import { Show, For, createSignal, onCleanup, createEffect } from "solid-js"
import type { JSX } from "solid-js"
import type { DropdownProps, DropdownItem } from "../../types"
import type { JSX } from 'solid-js'
import { createEffect, createSignal, For, onCleanup, Show } from 'solid-js'
import type { DropdownItem, DropdownProps } from '../../types'
type DropdownAlign = "left" | "right"
type DropdownAlign = 'left' | 'right'
export function Dropdown(props: DropdownProps): JSX.Element {
const [isOpen, setIsOpen] = createSignal(false)
let containerRef: HTMLDivElement | undefined
const align = (): DropdownAlign => props.align ?? "left"
const align = (): DropdownAlign => props.align ?? 'left'
const getItemClasses = (item: DropdownItem): string => {
if (item.disabled === true) {
return "text-gray cursor-not-allowed"
return 'text-gray cursor-not-allowed'
}
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 => {
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 => {
@ -45,41 +47,38 @@ export function Dropdown(props: DropdownProps): JSX.Element {
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
if (e.key === 'Escape') {
setIsOpen(false)
}
}
createEffect(() => {
if (isOpen()) {
document.addEventListener("click", handleClickOutside)
document.addEventListener("keydown", handleKeyDown)
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
}
onCleanup(() => {
document.removeEventListener("click", handleClickOutside)
document.removeEventListener("keydown", handleKeyDown)
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
})
})
return (
<div
ref={containerRef}
class={`relative inline-block ${props.class ?? ""}`}
>
<div
role="button"
<div ref={containerRef} class={`relative inline-block ${props.class ?? ''}`}>
<button
type="button"
tabIndex={0}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleToggle()
}
}}
>
{props.trigger}
</div>
</button>
<Show when={isOpen()}>
<div
@ -88,7 +87,7 @@ export function Dropdown(props: DropdownProps): JSX.Element {
bg-black border-2 border-orange
shadow-[4px_4px_0_var(--color-orange)]
animate-scale-in origin-top
${align() === "right" ? "right-0" : "left-0"}
${align() === 'right' ? 'right-0' : 'left-0'}
`}
role="menu"
>

View File

@ -2,27 +2,27 @@
* 8-bit styled icon button component
*/
import { Show, splitProps } from "solid-js"
import type { JSX } from "solid-js"
import type { IconButtonProps, Size } from "../../types"
import { Spinner } from "./Spinner"
import type { JSX } from 'solid-js'
import { Show, splitProps } from 'solid-js'
import type { IconButtonProps, Size } from '../../types'
import { Spinner } from './Spinner'
type IconButtonVariant = "ghost" | "subtle"
type IconButtonVariant = 'ghost' | 'subtle'
const SIZE_CLASSES: Record<Size, string> = {
xs: "w-6 h-6",
sm: "w-8 h-8",
md: "w-10 h-10",
lg: "w-12 h-12",
xl: "w-14 h-14",
xs: 'w-6 h-6',
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12',
xl: 'w-14 h-14',
}
const ICON_SIZE_CLASSES: Record<Size, string> = {
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
xl: "w-7 h-7",
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
xl: 'w-7 h-7',
}
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 {
const [local, rest] = splitProps(props, [
"icon",
"onClick",
"size",
"variant",
"ariaLabel",
"disabled",
"loading",
"class",
'icon',
'onClick',
'size',
'variant',
'ariaLabel',
'disabled',
'loading',
'class',
])
const size = (): Size => local.size ?? "md"
const variant = (): IconButtonVariant => local.variant ?? "ghost"
const size = (): Size => local.size ?? 'md'
const variant = (): IconButtonVariant => local.variant ?? 'ghost'
const isDisabled = (): boolean => local.disabled ?? 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
${SIZE_CLASSES[size()]}
${VARIANT_CLASSES[variant()]}
${(isDisabled() || isLoading()) ? DISABLED_CLASSES : ""}
${local.class ?? ""}
${isDisabled() || isLoading() ? DISABLED_CLASSES : ''}
${local.class ?? ''}
`}
{...rest}
>
<Show
when={!isLoading()}
fallback={<Spinner size={size() === "xs" ? "xs" : "sm"} />}
fallback={<Spinner size={size() === 'xs' ? 'xs' : 'sm'} />}
>
<span class={ICON_SIZE_CLASSES[size()]}>
{local.icon}
</span>
<span class={ICON_SIZE_CLASSES[size()]}>{local.icon}</span>
</Show>
</button>
)

View File

@ -2,32 +2,32 @@
* 8-bit styled input component
*/
import { Show, splitProps, createSignal } from "solid-js"
import type { JSX } from "solid-js"
import type { InputProps } from "../../types"
import type { JSX } from 'solid-js'
import { createSignal, Show, splitProps } from 'solid-js'
import type { InputProps } from '../../types'
export function Input(props: InputProps): JSX.Element {
const [local, rest] = splitProps(props, [
"type",
"name",
"placeholder",
"value",
"onInput",
"onChange",
"onFocus",
"onBlur",
"disabled",
"error",
"label",
"hint",
"leftIcon",
"rightIcon",
"fullWidth",
"maxLength",
"minLength",
"required",
"autofocus",
"class",
'type',
'name',
'placeholder',
'value',
'onInput',
'onChange',
'onFocus',
'onBlur',
'disabled',
'error',
'label',
'hint',
'leftIcon',
'rightIcon',
'fullWidth',
'maxLength',
'minLength',
'required',
'autofocus',
'class',
])
const [focused, setFocused] = createSignal(false)
@ -48,7 +48,9 @@ export function Input(props: InputProps): JSX.Element {
const isDisabled = (): boolean => local.disabled ?? false
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}>
<label
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
bg-black border-2
transition-all duration-100
${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)]" : ""}
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""}
${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)]' : ''}
${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<Show when={local.leftIcon}>
@ -76,11 +78,11 @@ export function Input(props: InputProps): JSX.Element {
</Show>
<input
type={local.type ?? "text"}
type={local.type ?? 'text'}
name={local.name}
id={local.name}
placeholder={local.placeholder}
value={local.value ?? ""}
value={local.value ?? ''}
disabled={isDisabled()}
maxLength={local.maxLength}
minLength={local.minLength}
@ -102,9 +104,9 @@ export function Input(props: InputProps): JSX.Element {
placeholder:font-placeholder placeholder:text-[16px] placeholder:text-gray
focus:outline-none
disabled:cursor-not-allowed
${local.leftIcon !== undefined ? "pl-1" : ""}
${local.rightIcon !== undefined ? "pr-1" : ""}
${local.class ?? ""}
${local.leftIcon !== undefined ? 'pl-1' : ''}
${local.rightIcon !== undefined ? 'pr-1' : ''}
${local.class ?? ''}
`}
{...rest}
/>

View File

@ -2,50 +2,44 @@
* 8-bit styled modal component
*/
import { Show, createEffect, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import type { ModalProps, Size } from "../../types"
import { IconButton } from "./IconButton"
import type { JSX } from 'solid-js'
import { createEffect, onCleanup, Show } from 'solid-js'
import type { ModalProps, Size } from '../../types'
import { IconButton } from './IconButton'
const SIZE_CLASSES: Record<Size, string> = {
xs: "max-w-xs",
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
xs: 'max-w-xs',
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
}
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 showCloseButton = (): boolean => props.showCloseButton ?? true
const handleOverlayClick = (e: MouseEvent): void => {
if (e.target === e.currentTarget && closeOnOverlayClick()) {
props.onClose()
}
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
if (e.key === 'Escape') {
props.onClose()
}
}
createEffect(() => {
if (props.isOpen) {
document.addEventListener("keydown", handleKeyDown)
document.body.style.overflow = "hidden"
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
document.body.style.overflow = ""
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
})
})
const handleOverlayKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" || e.key === " ") {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (closeOnOverlayClick()) {
props.onClose()
@ -55,15 +49,17 @@ export function Modal(props: ModalProps): JSX.Element {
return (
<Show when={props.isOpen}>
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="button"
tabIndex={0}
onClick={handleOverlayClick}
onKeyDown={handleOverlayKeyDown}
aria-label="Close modal overlay"
>
<div class="absolute inset-0 bg-black/80 animate-fade-in" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
class="absolute inset-0 w-full h-full bg-black/80 animate-fade-in border-0 cursor-default appearance-none"
tabIndex={-1}
aria-label="Close modal"
onClick={() => {
if (closeOnOverlayClick()) props.onClose()
}}
onKeyDown={handleOverlayKeyDown}
/>
<div
class={`
@ -75,9 +71,14 @@ export function Modal(props: ModalProps): JSX.Element {
`}
role="dialog"
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-1">
<Show when={props.title}>
@ -107,9 +108,7 @@ export function Modal(props: ModalProps): JSX.Element {
</div>
</Show>
<div class="p-4">
{props.children}
</div>
<div class="p-4">{props.children}</div>
</div>
</div>
</Show>
@ -124,6 +123,7 @@ function CloseIcon(): JSX.Element {
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M2 2L14 14M14 2L2 14"

View File

@ -2,25 +2,24 @@
* 8-bit styled skeleton loading component
*/
import { For } from "solid-js"
import type { JSX } from "solid-js"
import type { SkeletonProps } from "../../types"
import type { JSX } from 'solid-js'
import { For } from 'solid-js'
import type { SkeletonProps } from '../../types'
type SkeletonVariant = "text" | "circular" | "rectangular"
type SkeletonVariant = 'text' | 'circular' | 'rectangular'
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 getVariantClasses = (): string => {
switch (variant()) {
case "circular":
return "rounded-none aspect-square"
case "rectangular":
return ""
case "text":
case 'circular':
return 'rounded-none aspect-square'
case 'rectangular':
return ''
default:
return "h-4"
return 'h-4'
}
}
@ -39,7 +38,7 @@ export function Skeleton(props: SkeletonProps): JSX.Element {
}
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)}>
{(_, index) => (
<div
@ -47,7 +46,7 @@ export function Skeleton(props: SkeletonProps): JSX.Element {
bg-dark-gray
animate-pixel-pulse
${getVariantClasses()}
${index() === lines() - 1 && variant() === "text" ? "w-3/4" : "w-full"}
${index() === lines() - 1 && variant() === 'text' ? 'w-3/4' : 'w-full'}
`}
style={getStyle()}
/>

View File

@ -2,28 +2,27 @@
* 8-bit styled spinner component
*/
import type { JSX } from "solid-js"
import type { SpinnerProps, Size } from "../../types"
import type { JSX } from 'solid-js'
import type { Size, SpinnerProps } from '../../types'
const SIZE_CLASSES: Record<Size, string> = {
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
xl: "w-12 h-12",
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
xl: 'w-12 h-12',
}
export function Spinner(props: SpinnerProps): JSX.Element {
const size = (): Size => props.size ?? "md"
const size = (): Size => props.size ?? 'md'
return (
<div
<output
class={`
inline-block animate-pixel-spin
${SIZE_CLASSES[size()]}
${props.class ?? ""}
${props.class ?? ''}
`}
role="status"
aria-label="Loading"
>
<svg
@ -31,17 +30,74 @@ export function Spinner(props: SpinnerProps): JSX.Element {
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-full"
aria-hidden="true"
>
<rect x="6" y="0" width="4" height="4" fill="currentColor" class="text-orange" />
<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" />
<rect
x="6"
y="0"
width="4"
height="4"
fill="currentColor"
class="text-orange"
/>
<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>
</div>
</output>
)
}

View File

@ -2,23 +2,23 @@
* 8-bit styled textarea component
*/
import { Show, splitProps, createSignal, createEffect, onMount } from "solid-js"
import type { JSX } from "solid-js"
import type { TextAreaProps } from "../../types"
import type { JSX } from 'solid-js'
import { createEffect, createSignal, onMount, Show, splitProps } from 'solid-js'
import type { TextAreaProps } from '../../types'
export function TextArea(props: TextAreaProps): JSX.Element {
const [local, rest] = splitProps(props, [
"name",
"placeholder",
"value",
"onInput",
"disabled",
"error",
"label",
"rows",
"maxLength",
"autoResize",
"class",
'name',
'placeholder',
'value',
'onInput',
'disabled',
'error',
'label',
'rows',
'maxLength',
'autoResize',
'class',
])
const [focused, setFocused] = createSignal(false)
@ -36,7 +36,7 @@ export function TextArea(props: TextAreaProps): JSX.Element {
const adjustHeight = (): void => {
if (textareaRef !== undefined) {
textareaRef.style.height = "auto"
textareaRef.style.height = 'auto'
textareaRef.style.height = `${textareaRef.scrollHeight}px`
}
}
@ -48,14 +48,18 @@ export function TextArea(props: TextAreaProps): JSX.Element {
})
createEffect(() => {
if (local.autoResize === true && local.value !== undefined && textareaRef !== undefined) {
if (
local.autoResize === true &&
local.value !== undefined &&
textareaRef !== undefined
) {
adjustHeight()
}
})
const hasError = (): boolean => Boolean(local.error)
const isDisabled = (): boolean => local.disabled ?? false
const currentLength = (): number => (local.value ?? "").length
const currentLength = (): number => (local.value ?? '').length
return (
<div class="flex flex-col gap-1 w-full">
@ -73,9 +77,9 @@ export function TextArea(props: TextAreaProps): JSX.Element {
relative
bg-black border-2
transition-all duration-100
${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)]" : ""}
${isDisabled() ? "opacity-50 cursor-not-allowed" : ""}
${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)]' : ''}
${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<textarea
@ -83,7 +87,7 @@ export function TextArea(props: TextAreaProps): JSX.Element {
name={local.name}
id={local.name}
placeholder={local.placeholder}
value={local.value ?? ""}
value={local.value ?? ''}
disabled={isDisabled()}
rows={local.rows ?? 3}
maxLength={local.maxLength}
@ -97,8 +101,8 @@ export function TextArea(props: TextAreaProps): JSX.Element {
focus:outline-none
disabled:cursor-not-allowed
resize-none
${local.autoResize === true ? "overflow-hidden" : ""}
${local.class ?? ""}
${local.autoResize === true ? 'overflow-hidden' : ''}
${local.class ?? ''}
`}
{...rest}
/>
@ -116,8 +120,8 @@ export function TextArea(props: TextAreaProps): JSX.Element {
<span
class={`font-pixel text-[8px] ${
currentLength() > (local.maxLength ?? 0) * 0.9
? "text-error"
: "text-gray"
? 'text-error'
: 'text-gray'
}`}
>
{currentLength()}/{local.maxLength}

View File

@ -2,20 +2,20 @@
* 8-bit styled toast notification component
*/
import { For, Show } from "solid-js"
import type { JSX } from "solid-js"
import type { ToastProps } from "../../types"
import { $toasts, dismissToast } from "../../stores"
import { useStore } from "@nanostores/solid"
import { IconButton } from "./IconButton"
import { useStore } from '@nanostores/solid'
import type { JSX } from 'solid-js'
import { For, Show } from 'solid-js'
import { $toasts, dismissToast } from '../../stores'
import type { ToastProps } from '../../types'
import { IconButton } from './IconButton'
type ToastVariant = "info" | "success" | "warning" | "error"
type ToastVariant = 'info' | 'success' | 'warning' | 'error'
const VARIANT_CLASSES: Record<ToastVariant, string> = {
info: "border-info text-info",
success: "border-success text-success",
warning: "border-orange text-orange",
error: "border-error text-error",
info: 'border-info text-info',
success: 'border-success text-success',
warning: 'border-orange text-orange',
error: 'border-error text-error',
}
const VARIANT_ICONS: Record<ToastVariant, () => JSX.Element> = {
@ -41,22 +41,17 @@ export function Toast(props: ToastProps): JSX.Element {
`}
role="alert"
>
<span class="flex-shrink-0 mt-0.5">
{VARIANT_ICONS[props.variant]()}
</span>
<span class="flex-shrink-0 mt-0.5">{VARIANT_ICONS[props.variant]()}</span>
<div class="flex-1 min-w-0">
<p class="font-pixel text-[10px] text-white uppercase">
{props.title}
</p>
<p class="font-pixel text-[10px] text-white uppercase">{props.title}</p>
<Show when={props.description}>
<p class="font-pixel text-[8px] text-gray mt-1">
{props.description}
</p>
<p class="font-pixel text-[8px] text-gray mt-1">{props.description}</p>
</Show>
<Show when={props.action} keyed>
{(action) => (
<button
type="button"
onClick={() => action.onClick()}
class="font-pixel text-[8px] text-orange hover:underline mt-2"
>
@ -82,16 +77,20 @@ export function ToastContainer(): JSX.Element {
return (
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
<For each={toasts()}>
{(toast) => <Toast {...toast} />}
</For>
<For each={toasts()}>{(toast) => <Toast {...toast} />}</For>
</div>
)
}
function InfoIcon(): JSX.Element {
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="7" width="2" height="6" />
</svg>
@ -100,7 +99,13 @@ function InfoIcon(): JSX.Element {
function CheckIcon(): JSX.Element {
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="5" y="10" 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 {
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="12" width="2" height="2" />
</svg>
@ -121,7 +132,13 @@ function WarningIcon(): JSX.Element {
function ErrorIcon(): JSX.Element {
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="5" y="5" 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 {
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="4" y="4" width="2" height="2" />
<rect x="6" y="4" width="2" height="2" />

View File

@ -2,24 +2,26 @@
* 8-bit styled tooltip component
*/
import { Show, createSignal, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import type { TooltipProps } from "../../types"
import type { JSX } from 'solid-js'
import { createSignal, onCleanup, Show } from 'solid-js'
import type { TooltipProps } from '../../types'
type TooltipPosition = "top" | "bottom" | "left" | "right"
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'
const POSITION_CLASSES: Record<TooltipPosition, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-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",
right: "left-full top-1/2 -translate-y-1/2 ml-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',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
}
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",
bottom: "bottom-full left-1/2 -translate-x-1/2 border-b-orange border-x-transparent border-t-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",
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',
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
@ -28,7 +30,7 @@ export function Tooltip(props: TooltipProps): JSX.Element {
const [visible, setVisible] = createSignal(false)
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 showTooltip = (): void => {
@ -51,9 +53,8 @@ export function Tooltip(props: TooltipProps): JSX.Element {
})
return (
<div
<span
class="relative inline-block"
role="presentation"
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
@ -91,6 +92,6 @@ export function Tooltip(props: TooltipProps): JSX.Element {
</div>
</div>
</Show>
</div>
</span>
)
}

View File

@ -2,15 +2,21 @@
// © AngelaMos | 2025
// index.ts
// ===================
export { Button } from "./Button"
export { Input } from "./Input"
export { TextArea } from "./TextArea"
export { Avatar } from "./Avatar"
export { Badge } from "./Badge"
export { Modal } from "./Modal"
export { Spinner, LoadingOverlay } from "./Spinner"
export { Toast, ToastContainer } from "./Toast"
export { Skeleton, MessageSkeleton, ConversationSkeleton, AvatarSkeleton } from "./Skeleton"
export { Tooltip } from "./Tooltip"
export { Dropdown } from "./Dropdown"
export { IconButton } from "./IconButton"
export { Avatar } from './Avatar'
export { Badge } from './Badge'
export { Button } from './Button'
export { Dropdown } from './Dropdown'
export { IconButton } from './IconButton'
export { Input } from './Input'
export { Modal } from './Modal'
export {
AvatarSkeleton,
ConversationSkeleton,
MessageSkeleton,
Skeleton,
} from './Skeleton'
export { LoadingOverlay, Spinner } from './Spinner'
export { TextArea } from './TextArea'
export { Toast, ToastContainer } from './Toast'
export { Tooltip } from './Tooltip'

View File

@ -2,21 +2,32 @@
// © AngelaMos | 2025
// config.ts
// ===================
const envApiUrl: string | undefined = import.meta.env.VITE_API_URL as string | 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;
const envApiUrl: string | undefined = import.meta.env.VITE_API_URL as
| string
| 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 WS_URL = envWsUrl !== undefined && envWsUrl !== "" ? envWsUrl : "ws://localhost:8000";
export const RP_ID = envRpId !== undefined && envRpId !== "" ? envRpId : "localhost";
export const API_URL =
envApiUrl !== undefined && envApiUrl !== ''
? 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_RECONNECT_DELAY = 5000;
export const MESSAGE_MAX_LENGTH = 50000;
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 50;
export const DISPLAY_NAME_MIN_LENGTH = 1;
export const DISPLAY_NAME_MAX_LENGTH = 100;
export const WS_HEARTBEAT_INTERVAL = 30000
export const WS_RECONNECT_DELAY = 5000
export const MESSAGE_MAX_LENGTH = 50000
export const USERNAME_MIN_LENGTH = 3
export const USERNAME_MAX_LENGTH = 50
export const DISPLAY_NAME_MIN_LENGTH = 1
export const DISPLAY_NAME_MAX_LENGTH = 100
export const USER_SEARCH_MIN_LENGTH = 2;
export const USER_SEARCH_DEFAULT_LIMIT = 10;
export const USER_SEARCH_MIN_LENGTH = 2
export const USER_SEARCH_DEFAULT_LIMIT = 10

View File

@ -2,53 +2,54 @@
// © AngelaMos | 2025
// crypto-service.ts
// ===================
import { api } from '../lib/api-client'
import type {
IdentityKeyPair,
SignedPreKey,
OneTimePreKey,
DoubleRatchetState,
EncryptedMessage,
X3DHHeader,
FullMessageHeader,
IdentityKeyPair,
MessageHeader,
} from "../types"
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
OneTimePreKey,
SignedPreKey,
X3DHHeader,
} from '../types'
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from '../types'
import {
generateIdentityKeyPair,
generateSignedPreKey,
generateOneTimePreKeys,
initiateX3DH,
receiveX3DH,
} from "./x3dh"
import {
initializeRatchetSender,
initializeRatchetReceiver,
encryptMessage,
decryptMessage,
serializeRatchetState,
deserializeRatchetState,
} from "./double-ratchet"
encryptMessage,
initializeRatchetReceiver,
initializeRatchetSender,
serializeRatchetState,
} from './double-ratchet'
import {
saveIdentityKey,
getIdentityKey,
saveSignedPreKey,
getLatestSignedPreKey,
saveOneTimePreKeys,
getUnusedOneTimePreKeys,
getOneTimePreKeyByPublicKey,
markOneTimePreKeyUsed,
saveRatchetState,
getRatchetState,
deleteRatchetState,
clearAllKeys,
} from "./key-store"
import { api } from "../lib/api-client"
deleteRatchetState,
getIdentityKey,
getLatestSignedPreKey,
getOneTimePreKeyByPublicKey,
getRatchetState,
getUnusedOneTimePreKeys,
markOneTimePreKeyUsed,
saveIdentityKey,
saveOneTimePreKeys,
saveRatchetState,
saveSignedPreKey,
} from './key-store'
import {
base64ToBytes,
bytesToBase64,
importX25519PrivateKey,
importX25519PublicKey,
} from "./primitives"
} from './primitives'
import {
generateIdentityKeyPair,
generateOneTimePreKeys,
generateSignedPreKey,
initiateX3DH,
receiveX3DH,
} from './x3dh'
class CryptoService {
private userId: string | null = null
@ -72,7 +73,10 @@ class CryptoService {
this.signedPreKey = await getLatestSignedPreKey(userId)
if (this.signedPreKey === null || this.isSignedPreKeyExpired(this.signedPreKey)) {
if (
this.signedPreKey === null ||
this.isSignedPreKeyExpired(this.signedPreKey)
) {
await this.rotateSignedPreKey()
}
@ -89,40 +93,49 @@ class CryptoService {
}
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()
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)
const oneTimePreKeys = await generateOneTimePreKeys(DEFAULT_ONE_TIME_PREKEY_COUNT)
const oneTimePreKeys = await generateOneTimePreKeys(
DEFAULT_ONE_TIME_PREKEY_COUNT
)
await saveOneTimePreKeys(this.userId, oneTimePreKeys)
await this.uploadPublicKeys(oneTimePreKeys)
}
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 api.encryption.rotateSignedPrekey(this.userId)
}
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)
}
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) {
throw new Error("Keys not generated")
throw new Error('Keys not generated')
}
await api.encryption.uploadKeys(this.userId, {
@ -135,7 +148,8 @@ class CryptoService {
}
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)
if (existingState !== null) return
@ -157,7 +171,9 @@ class CryptoService {
const x3dhHeader: X3DHHeader = {
identity_key: this.identityKeyPair.x25519_public,
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)
@ -172,7 +188,7 @@ class CryptoService {
oneTimePreKeyPublic: string | null
): Promise<void> {
if (this.identityKeyPair === null || this.signedPreKey === null) {
throw new Error("Keys not initialized")
throw new Error('Keys not initialized')
}
let oneTimePreKey: OneTimePreKey | null = null
@ -214,7 +230,10 @@ class CryptoService {
await saveRatchetState(serialized)
}
async encrypt(peerId: string, plaintext: string): Promise<{
async encrypt(
peerId: string,
plaintext: string
): Promise<{
ciphertext: string
nonce: string
header: string
@ -261,7 +280,7 @@ class CryptoService {
let ratchetHeader: MessageHeader
let x3dhHeader: X3DHHeader | undefined
if ("ratchet" in parsedHeader) {
if ('ratchet' in parsedHeader) {
ratchetHeader = parsedHeader.ratchet
x3dhHeader = parsedHeader.x3dh
} else {
@ -276,7 +295,7 @@ class CryptoService {
if (state === null) {
if (!x3dhHeader) {
throw new Error("Cannot establish session: missing X3DH header")
throw new Error('Cannot establish session: missing X3DH header')
}
await this.handleIncomingSession(
@ -289,7 +308,7 @@ class CryptoService {
}
if (state === null) {
throw new Error("Failed to establish session")
throw new Error('Failed to establish session')
}
const plaintextBytes = await decryptMessage(state, encryptedMessage)
@ -300,7 +319,9 @@ class CryptoService {
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)
if (state === undefined) {
@ -337,18 +358,19 @@ class CryptoService {
this.ratchetStates.clear()
this.pendingX3DHHeaders.clear()
const database = indexedDB.open("encrypted-chat-keys", 1)
const database = indexedDB.open('encrypted-chat-keys', 1)
database.onsuccess = () => {
const db = database.result
const tx = db.transaction("ratchet_states", "readwrite")
tx.objectStore("ratchet_states").clear()
const tx = db.transaction('ratchet_states', 'readwrite')
tx.objectStore('ratchet_states').clear()
}
}
}
export const cryptoService = new CryptoService()
if (typeof window !== "undefined") {
(window as unknown as { resetCryptoSessions: () => Promise<void> }).resetCryptoSessions =
() => cryptoService.resetAllSessions()
if (typeof window !== 'undefined') {
;(
window as unknown as { resetCryptoSessions: () => Promise<void> }
).resetCryptoSessions = () => cryptoService.resetAllSessions()
}

View File

@ -4,32 +4,35 @@
// ===================
import type {
DoubleRatchetState,
SerializedRatchetState,
EncryptedMessage,
MessageHeader,
} from "../types"
import { MAX_SKIP_MESSAGE_KEYS } from "../types"
SerializedRatchetState,
} from '../types'
import { MAX_SKIP_MESSAGE_KEYS } from '../types'
import {
generateX25519KeyPair,
x25519DeriveSharedSecret,
importX25519PublicKey,
importX25519PrivateKey,
exportPublicKey,
exportPrivateKey,
hkdfDerive,
aesGcmEncrypt,
aesGcmDecrypt,
hmacSha256,
concatBytes,
bytesToBase64,
aesGcmEncrypt,
base64ToBytes,
} from "./primitives"
bytesToBase64,
concatBytes,
exportPrivateKey,
exportPublicKey,
generateX25519KeyPair,
hkdfDerive,
hmacSha256,
importX25519PrivateKey,
importX25519PublicKey,
x25519DeriveSharedSecret,
} from './primitives'
const RATCHET_INFO = new TextEncoder().encode("DoubleRatchet")
const MESSAGE_KEY_INFO = new TextEncoder().encode("MessageKey")
const CHAIN_KEY_INFO = new TextEncoder().encode("ChainKey")
const RATCHET_INFO = new TextEncoder().encode('DoubleRatchet')
const MESSAGE_KEY_INFO = new TextEncoder().encode('MessageKey')
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}`
}
@ -45,7 +48,12 @@ export async function initializeRatchetSender(
const dhOutput = await x25519DeriveSharedSecret(dhKeyPair.privateKey, peerKey)
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 sendingChainKey = derivedKeys.slice(32, 64)
@ -105,7 +113,7 @@ async function performDHRatchet(
peerPublicKey: Uint8Array
): Promise<void> {
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
@ -114,10 +122,18 @@ async function performDHRatchet(
state.dh_peer_public_key = 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 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.receiving_chain_key = derivedKeys.slice(32, 64)
@ -126,9 +142,17 @@ async function performDHRatchet(
state.dh_private_key = newDHKeyPair
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 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.sending_chain_key = newDerivedKeys.slice(32, 64)
@ -138,16 +162,22 @@ async function skipMessageKeys(
state: DoubleRatchetState,
until: number
): 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) {
throw new Error("Too many skipped messages")
throw new Error('Too many skipped messages')
}
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.receiving_chain_key = nextChainKey
@ -161,10 +191,12 @@ export async function encryptMessage(
associatedData?: Uint8Array
): Promise<EncryptedMessage> {
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 = {
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 aad = associatedData !== undefined ? concatBytes(associatedData, headerBytes) : headerBytes
const aad =
associatedData !== undefined
? concatBytes(associatedData, headerBytes)
: headerBytes
const { ciphertext, nonce } = await aesGcmEncrypt(messageKey, plaintext, aad)
@ -194,14 +229,20 @@ export async function decryptMessage(
): Promise<Uint8Array> {
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)
if (skippedKey !== undefined) {
state.skipped_message_keys.delete(skippedKeyId)
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)
}
@ -221,15 +262,25 @@ export async function decryptMessage(
await skipMessageKeys(state, message.header.message_number)
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 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_message_number++
@ -242,7 +293,9 @@ export async function serializeRatchetState(
): Promise<SerializedRatchetState> {
let dhPrivateKey: string | null = 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)
}
@ -255,14 +308,17 @@ export async function serializeRatchetState(
peer_id: state.peer_id,
root_key: bytesToBase64(state.root_key),
sending_chain_key: bytesToBase64(state.sending_chain_key),
receiving_chain_key: state.receiving_chain_key !== null
? bytesToBase64(state.receiving_chain_key)
: null,
receiving_chain_key:
state.receiving_chain_key !== null
? bytesToBase64(state.receiving_chain_key)
: null,
dh_private_key: dhPrivateKey,
dh_public_key: state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null,
dh_peer_public_key: state.dh_peer_public_key !== null
? bytesToBase64(state.dh_peer_public_key)
: null,
dh_public_key:
state.dh_public_key !== null ? bytesToBase64(state.dh_public_key) : null,
dh_peer_public_key:
state.dh_peer_public_key !== null
? bytesToBase64(state.dh_peer_public_key)
: null,
sending_message_number: state.sending_message_number,
receiving_message_number: state.receiving_message_number,
previous_sending_chain_length: state.previous_sending_chain_length,
@ -332,7 +388,9 @@ interface SerializedEncryptedMessage {
header: MessageHeader
}
export function deserializeEncryptedMessage(serialized: string): EncryptedMessage {
export function deserializeEncryptedMessage(
serialized: string
): EncryptedMessage {
const parsed = JSON.parse(serialized) as SerializedEncryptedMessage
return {
ciphertext: base64ToBytes(parsed.ciphertext),

View File

@ -2,9 +2,10 @@
// © AngelaMos | 2025
// index.ts
// ===================
export * from "./primitives"
export * from "./key-store"
export * from "./message-store"
export * from "./x3dh"
export * from "./double-ratchet"
export * from "./crypto-service"
export * from './crypto-service'
export * from './double-ratchet'
export * from './key-store'
export * from './message-store'
export * from './primitives'
export * from './x3dh'

View File

@ -4,19 +4,19 @@
// ===================
import type {
IdentityKeyPair,
SignedPreKey,
OneTimePreKey,
SerializedRatchetState,
} from "../types"
SignedPreKey,
} from '../types'
const DB_NAME = "encrypted-chat-keys"
const DB_NAME = 'encrypted-chat-keys'
const DB_VERSION = 1
const STORES = {
IDENTITY: "identity_keys",
SIGNED_PREKEYS: "signed_prekeys",
ONE_TIME_PREKEYS: "one_time_prekeys",
RATCHET_STATES: "ratchet_states",
IDENTITY: 'identity_keys',
SIGNED_PREKEYS: 'signed_prekeys',
ONE_TIME_PREKEYS: 'one_time_prekeys',
RATCHET_STATES: 'ratchet_states',
} as const
let db: IDBDatabase | null = null
@ -28,7 +28,7 @@ async function openDatabase(): Promise<IDBDatabase> {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
reject(new Error("Failed to open key database"))
reject(new Error('Failed to open key database'))
}
request.onsuccess = () => {
@ -40,22 +40,26 @@ async function openDatabase(): Promise<IDBDatabase> {
const database = (event.target as IDBOpenDBRequest).result
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)) {
const signedStore = database.createObjectStore(STORES.SIGNED_PREKEYS, { keyPath: "id" })
signedStore.createIndex("userId", "userId", { unique: false })
const signedStore = database.createObjectStore(STORES.SIGNED_PREKEYS, {
keyPath: 'id',
})
signedStore.createIndex('userId', 'userId', { unique: false })
}
if (!database.objectStoreNames.contains(STORES.ONE_TIME_PREKEYS)) {
const otpStore = database.createObjectStore(STORES.ONE_TIME_PREKEYS, { keyPath: "id" })
otpStore.createIndex("userId", "userId", { unique: false })
otpStore.createIndex("isUsed", "is_used", { unique: false })
const otpStore = database.createObjectStore(STORES.ONE_TIME_PREKEYS, {
keyPath: 'id',
})
otpStore.createIndex('userId', 'userId', { unique: false })
otpStore.createIndex('isUsed', 'is_used', { unique: false })
}
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)
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,
}
await performTransaction(
STORES.IDENTITY,
"readwrite",
(store) => store.put(stored)
await performTransaction(STORES.IDENTITY, '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>(
STORES.IDENTITY,
"readonly",
'readonly',
(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> {
await performTransaction(
STORES.IDENTITY,
"readwrite",
(store) => store.delete(userId)
await performTransaction(STORES.IDENTITY, 'readwrite', (store) =>
store.delete(userId)
)
}
@ -136,17 +139,15 @@ export async function saveSignedPreKey(
...preKey,
}
await performTransaction(
STORES.SIGNED_PREKEYS,
"readwrite",
(store) => store.put(stored)
await performTransaction(STORES.SIGNED_PREKEYS, 'readwrite', (store) =>
store.put(stored)
)
}
export async function getSignedPreKey(id: string): Promise<SignedPreKey | null> {
const result = await performTransaction<StoredSignedPreKey | undefined>(
STORES.SIGNED_PREKEYS,
"readonly",
'readonly',
(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()
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 index = store.index("userId")
const index = store.index('userId')
const request = index.getAll(userId)
request.onsuccess = () => {
@ -179,7 +182,8 @@ export async function getLatestSignedPreKey(userId: string): Promise<SignedPreKe
}
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]
@ -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> {
await performTransaction(
STORES.SIGNED_PREKEYS,
"readwrite",
(store) => store.delete(id)
await performTransaction(STORES.SIGNED_PREKEYS, 'readwrite', (store) =>
store.delete(id)
)
}
@ -218,10 +221,8 @@ export async function saveOneTimePreKey(
...preKey,
}
await performTransaction(
STORES.ONE_TIME_PREKEYS,
"readwrite",
(store) => store.put(stored)
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
store.put(stored)
)
}
@ -232,11 +233,14 @@ export async function saveOneTimePreKeys(
const database = await openDatabase()
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)
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) {
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>(
STORES.ONE_TIME_PREKEYS,
"readonly",
'readonly',
(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()
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 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()
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 index = store.index("userId")
const index = store.index('userId')
const request = index.getAll(userId)
request.onsuccess = () => {
@ -320,14 +333,19 @@ export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePr
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> {
const preKey = await performTransaction<StoredOneTimePreKey | undefined>(
STORES.ONE_TIME_PREKEYS,
"readonly",
'readonly',
(store) => store.get(id) as IDBRequest<StoredOneTimePreKey | undefined>
)
@ -335,33 +353,31 @@ export async function markOneTimePreKeyUsed(id: string): Promise<void> {
preKey.is_used = true
await performTransaction(
STORES.ONE_TIME_PREKEYS,
"readwrite",
(store) => store.put(preKey)
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
store.put(preKey)
)
}
export async function deleteOneTimePreKey(id: string): Promise<void> {
await performTransaction(
STORES.ONE_TIME_PREKEYS,
"readwrite",
(store) => store.delete(id)
await performTransaction(STORES.ONE_TIME_PREKEYS, 'readwrite', (store) =>
store.delete(id)
)
}
export async function saveRatchetState(state: SerializedRatchetState): Promise<void> {
await performTransaction(
STORES.RATCHET_STATES,
"readwrite",
(store) => store.put(state)
export async function saveRatchetState(
state: SerializedRatchetState
): Promise<void> {
await performTransaction(STORES.RATCHET_STATES, 'readwrite', (store) =>
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>(
STORES.RATCHET_STATES,
"readonly",
'readonly',
(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> {
await performTransaction(
STORES.RATCHET_STATES,
"readwrite",
(store) => store.delete(peerId)
await performTransaction(STORES.RATCHET_STATES, 'readwrite', (store) =>
store.delete(peerId)
)
}
@ -380,12 +394,15 @@ export async function getAllRatchetStates(): Promise<SerializedRatchetState[]> {
const database = await openDatabase()
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 request = store.getAll()
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,
]
const transaction = database.transaction(storeNames, "readwrite")
const transaction = database.transaction(storeNames, 'readwrite')
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) {
transaction.objectStore(storeName).clear()

View File

@ -2,13 +2,13 @@
// © AngelaMos | 2025
// 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 STORES = {
MESSAGES: "decrypted_messages",
MESSAGES: 'decrypted_messages',
} as const
let db: IDBDatabase | null = null
@ -20,7 +20,7 @@ async function openDatabase(): Promise<IDBDatabase> {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
reject(new Error("Failed to open message database"))
reject(new Error('Failed to open message database'))
}
request.onsuccess = () => {
@ -32,10 +32,14 @@ async function openDatabase(): Promise<IDBDatabase> {
const database = (event.target as IDBOpenDBRequest).result
if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
const messageStore = database.createObjectStore(STORES.MESSAGES, { keyPath: "id" })
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 })
const messageStore = database.createObjectStore(STORES.MESSAGES, {
keyPath: 'id',
})
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)
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> {
await performTransaction(
STORES.MESSAGES,
"readwrite",
(store) => store.put(message)
await performTransaction(STORES.MESSAGES, 'readwrite', (store) =>
store.put(message)
)
}
@ -70,11 +73,12 @@ export async function saveDecryptedMessages(messages: Message[]): Promise<void>
const database = await openDatabase()
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)
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) {
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>(
STORES.MESSAGES,
"readonly",
'readonly',
(store) => store.get(messageId) as IDBRequest<Message | undefined>
)
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()
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 index = store.index("room_id")
const index = store.index('room_id')
const request = index.getAll(roomId)
request.onsuccess = () => {
let results = request.result as Message[]
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) {
@ -114,19 +124,22 @@ export async function getDecryptedMessages(roomId: string, limit?: number): Prom
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()
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 index = store.index("room_created")
const range = IDBKeyRange.bound([roomId, ""], [roomId, "\uffff"])
const request = index.openCursor(range, "prev")
const index = store.index('room_created')
const range = IDBKeyRange.bound([roomId, ''], [roomId, '\uffff'])
const request = index.openCursor(range, 'prev')
request.onsuccess = () => {
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> {
await performTransaction(
STORES.MESSAGES,
"readwrite",
(store) => store.delete(messageId)
await performTransaction(STORES.MESSAGES, '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)
if (message) {
await deleteMessage(oldId)
@ -163,9 +180,9 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
const database = await openDatabase()
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 index = store.index("room_id")
const index = store.index('room_id')
const request = index.openCursor(IDBKeyRange.only(roomId))
request.onsuccess = () => {
@ -177,29 +194,29 @@ export async function clearRoomMessages(roomId: string): Promise<void> {
}
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> {
await performTransaction(
STORES.MESSAGES,
"readwrite",
(store) => store.clear()
)
await performTransaction(STORES.MESSAGES, 'readwrite', (store) => store.clear())
}
export async function getMessageCount(roomId: string): Promise<number> {
const database = await openDatabase()
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 index = store.index("room_id")
const index = store.index('room_id')
const request = index.count(IDBKeyRange.only(roomId))
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'))
})
}

View File

@ -3,23 +3,23 @@
// primitives.ts
// ===================
import {
X25519_KEY_SIZE,
AES_GCM_KEY_SIZE,
AES_GCM_NONCE_SIZE,
HKDF_OUTPUT_SIZE,
} from "../types"
X25519_KEY_SIZE,
} from '../types'
const crypto = globalThis.crypto
const subtle = crypto.subtle
export async function generateX25519KeyPair(): Promise<CryptoKeyPair> {
const keyPair = await subtle.generateKey(
const keyPair = (await subtle.generateKey(
{
name: "X25519",
name: 'X25519',
},
true,
["deriveBits"]
) as CryptoKeyPair
['deriveBits']
)) as CryptoKeyPair
return keyPair
}
@ -29,7 +29,7 @@ export async function x25519DeriveSharedSecret(
): Promise<Uint8Array> {
const sharedBits = await subtle.deriveBits(
{
name: "X25519",
name: 'X25519',
public: publicKey,
},
privateKey,
@ -38,61 +38,65 @@ export async function x25519DeriveSharedSecret(
return new Uint8Array(sharedBits)
}
export async function importX25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
export async function importX25519PublicKey(
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (keyBytes.length === 32) {
return await subtle.importKey(
"raw",
'raw',
keyBytes.buffer as ArrayBuffer,
{ name: "X25519" },
{ name: 'X25519' },
true,
[]
)
}
return await subtle.importKey(
"spki",
'spki',
keyBytes.buffer as ArrayBuffer,
{ name: "X25519" },
{ name: 'X25519' },
true,
[]
)
}
export async function importX25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
export async function importX25519PrivateKey(
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (keyBytes.length === 32) {
return await subtle.importKey(
"raw",
'raw',
keyBytes.buffer as ArrayBuffer,
{ name: "X25519" },
{ name: 'X25519' },
true,
["deriveBits"]
['deriveBits']
)
}
return await subtle.importKey(
"pkcs8",
'pkcs8',
keyBytes.buffer as ArrayBuffer,
{ name: "X25519" },
{ name: 'X25519' },
true,
["deriveBits"]
['deriveBits']
)
}
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)
}
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)
}
export async function generateEd25519KeyPair(): Promise<CryptoKeyPair> {
return await subtle.generateKey(
{
name: "Ed25519",
name: 'Ed25519',
},
true,
["sign", "verify"]
['sign', 'verify']
)
}
@ -102,7 +106,7 @@ export async function ed25519Sign(
): Promise<Uint8Array> {
const signature = await subtle.sign(
{
name: "Ed25519",
name: 'Ed25519',
},
privateKey,
data.buffer as ArrayBuffer
@ -117,7 +121,7 @@ export async function ed25519Verify(
): Promise<boolean> {
return await subtle.verify(
{
name: "Ed25519",
name: 'Ed25519',
},
publicKey,
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) {
return await subtle.importKey(
"raw",
'raw',
keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" },
{ name: 'Ed25519' },
true,
["verify"]
['verify']
)
}
return await subtle.importKey(
"spki",
'spki',
keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" },
{ name: 'Ed25519' },
true,
["verify"]
['verify']
)
}
export async function importEd25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
export async function importEd25519PrivateKey(
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (keyBytes.length === 32) {
return await subtle.importKey(
"raw",
'raw',
keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" },
{ name: 'Ed25519' },
true,
["sign"]
['sign']
)
}
return await subtle.importKey(
"pkcs8",
'pkcs8',
keyBytes.buffer as ArrayBuffer,
{ name: "Ed25519" },
{ name: 'Ed25519' },
true,
["sign"]
['sign']
)
}
@ -170,17 +178,17 @@ export async function hkdfDerive(
outputLength: number = HKDF_OUTPUT_SIZE
): Promise<Uint8Array> {
const baseKey = await subtle.importKey(
"raw",
'raw',
inputKeyMaterial.buffer as ArrayBuffer,
{ name: "HKDF" },
{ name: 'HKDF' },
false,
["deriveBits"]
['deriveBits']
)
const derivedBits = await subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
name: 'HKDF',
hash: 'SHA-256',
salt: salt.buffer as ArrayBuffer,
info: info.buffer as ArrayBuffer,
},
@ -197,27 +205,27 @@ export async function hkdfDeriveKey(
info: Uint8Array
): Promise<CryptoKey> {
const keyMaterial = await subtle.importKey(
"raw",
'raw',
inputKeyMaterial.buffer as ArrayBuffer,
{ name: "HKDF" },
{ name: 'HKDF' },
false,
["deriveKey"]
['deriveKey']
)
return await subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
name: 'HKDF',
hash: 'SHA-256',
salt: salt.buffer as ArrayBuffer,
info: info.buffer as ArrayBuffer,
},
keyMaterial,
{
name: "AES-GCM",
name: 'AES-GCM',
length: AES_GCM_KEY_SIZE * 8,
},
true,
["encrypt", "decrypt"]
['encrypt', 'decrypt']
)
}
@ -230,11 +238,11 @@ export async function aesGcmEncrypt(
if (key instanceof Uint8Array) {
cryptoKey = await subtle.importKey(
"raw",
'raw',
key.buffer as ArrayBuffer,
{ name: "AES-GCM", length: AES_GCM_KEY_SIZE * 8 },
{ name: 'AES-GCM', length: AES_GCM_KEY_SIZE * 8 },
false,
["encrypt"]
['encrypt']
)
} else {
cryptoKey = key
@ -244,7 +252,7 @@ export async function aesGcmEncrypt(
const ciphertext = await subtle.encrypt(
{
name: "AES-GCM",
name: 'AES-GCM',
iv: nonce.buffer as ArrayBuffer,
additionalData: associatedData?.buffer as ArrayBuffer | undefined,
},
@ -268,11 +276,11 @@ export async function aesGcmDecrypt(
if (key instanceof Uint8Array) {
cryptoKey = await subtle.importKey(
"raw",
'raw',
key.buffer as ArrayBuffer,
{ name: "AES-GCM", length: AES_GCM_KEY_SIZE * 8 },
{ name: 'AES-GCM', length: AES_GCM_KEY_SIZE * 8 },
false,
["decrypt"]
['decrypt']
)
} else {
cryptoKey = key
@ -280,7 +288,7 @@ export async function aesGcmDecrypt(
const plaintext = await subtle.decrypt(
{
name: "AES-GCM",
name: 'AES-GCM',
iv: nonce.buffer as ArrayBuffer,
additionalData: associatedData?.buffer as ArrayBuffer | undefined,
},
@ -298,12 +306,12 @@ export function generateRandomBytes(length: number): 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)
}
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)
}
@ -312,17 +320,21 @@ export async function hmacSha256(
data: Uint8Array
): Promise<Uint8Array> {
const cryptoKey = await subtle.importKey(
"raw",
'raw',
key.buffer as ArrayBuffer,
{
name: "HMAC",
hash: "SHA-256",
name: 'HMAC',
hash: 'SHA-256',
},
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)
}
@ -332,17 +344,22 @@ export async function hmacSha256Verify(
data: Uint8Array
): Promise<boolean> {
const cryptoKey = await subtle.importKey(
"raw",
'raw',
key.buffer as ArrayBuffer,
{
name: "HMAC",
hash: "SHA-256",
name: 'HMAC',
hash: 'SHA-256',
},
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 {
@ -373,8 +390,8 @@ export function base64ToBytes(base64: string): Uint8Array {
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
export function hexToBytes(hex: string): Uint8Array {

View File

@ -3,34 +3,34 @@
// x3dh.ts
// ===================
import type {
PreKeyBundle,
X3DHResult,
X3DHHeader,
IdentityKeyPair,
SignedPreKey,
OneTimePreKey,
} from "../types"
PreKeyBundle,
SignedPreKey,
X3DHHeader,
X3DHResult,
} from '../types'
import { DEFAULT_ONE_TIME_PREKEY_COUNT, HKDF_OUTPUT_SIZE } from '../types'
import {
generateX25519KeyPair,
x25519DeriveSharedSecret,
importX25519PublicKey,
importX25519PrivateKey,
exportPublicKey,
exportPrivateKey,
generateEd25519KeyPair,
base64ToBytes,
bytesToBase64,
concatBytes,
ed25519Sign,
ed25519Verify,
importEd25519PublicKey,
importEd25519PrivateKey,
hkdfDerive,
concatBytes,
bytesToBase64,
base64ToBytes,
exportPrivateKey,
exportPublicKey,
generateEd25519KeyPair,
generateRandomBytes,
} from "./primitives"
import { HKDF_OUTPUT_SIZE, DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
generateX25519KeyPair,
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)
export async function generateIdentityKeyPair(): Promise<IdentityKeyPair> {
@ -57,7 +57,9 @@ export async function generateSignedPreKey(
const publicKey = await exportPublicKey(keyPair.publicKey)
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 id = bytesToBase64(generateRandomBytes(16))
@ -102,7 +104,9 @@ export async function verifySignedPreKey(
signature: string
): Promise<boolean> {
try {
const verifyKey = await importEd25519PublicKey(base64ToBytes(identityPublicKey))
const verifyKey = await importEd25519PublicKey(
base64ToBytes(identityPublicKey)
)
const publicKeyBytes = base64ToBytes(signedPreKeyPublic)
const signatureBytes = base64ToBytes(signature)
@ -123,7 +127,7 @@ export async function initiateX3DH(
)
if (!signatureValid) {
throw new Error("Invalid signed prekey signature")
throw new Error('Invalid signed prekey signature')
}
const ephemeralKeyPair = await generateX25519KeyPair()
@ -139,9 +143,18 @@ export async function initiateX3DH(
base64ToBytes(recipientBundle.signed_prekey)
)
const dh1 = await x25519DeriveSharedSecret(senderIdentityPrivate, recipientSignedPreKeyPublic)
const dh2 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientIdentityPublic)
const dh3 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientSignedPreKeyPublic)
const dh1 = await x25519DeriveSharedSecret(
senderIdentityPrivate,
recipientSignedPreKeyPublic
)
const dh2 = await x25519DeriveSharedSecret(
ephemeralKeyPair.privateKey,
recipientIdentityPublic
)
const dh3 = await x25519DeriveSharedSecret(
ephemeralKeyPair.privateKey,
recipientSignedPreKeyPublic
)
let dhResults: Uint8Array[]
let usedOneTimePreKey = false
@ -150,7 +163,10 @@ export async function initiateX3DH(
const recipientOneTimePreKeyPublic = await importX25519PublicKey(
base64ToBytes(recipientBundle.one_time_prekey)
)
const dh4 = await x25519DeriveSharedSecret(ephemeralKeyPair.privateKey, recipientOneTimePreKeyPublic)
const dh4 = await x25519DeriveSharedSecret(
ephemeralKeyPair.privateKey,
recipientOneTimePreKeyPublic
)
dhResults = [dh1, dh2, dh3, dh4]
usedOneTimePreKey = true
} else {
@ -192,9 +208,18 @@ export async function receiveX3DH(
base64ToBytes(senderEphemeralKey)
)
const dh1 = await x25519DeriveSharedSecret(recipientSignedPreKeyPrivate, senderIdentityPublic)
const dh2 = await x25519DeriveSharedSecret(recipientIdentityPrivate, senderEphemeralPublic)
const dh3 = await x25519DeriveSharedSecret(recipientSignedPreKeyPrivate, senderEphemeralPublic)
const dh1 = await x25519DeriveSharedSecret(
recipientSignedPreKeyPrivate,
senderIdentityPublic
)
const dh2 = await x25519DeriveSharedSecret(
recipientIdentityPrivate,
senderEphemeralPublic
)
const dh3 = await x25519DeriveSharedSecret(
recipientSignedPreKeyPrivate,
senderEphemeralPublic
)
let dhResults: Uint8Array[]
@ -202,7 +227,10 @@ export async function receiveX3DH(
const recipientOneTimePreKeyPrivate = await importX25519PrivateKey(
base64ToBytes(oneTimePreKey.private_key)
)
const dh4 = await x25519DeriveSharedSecret(recipientOneTimePreKeyPrivate, senderEphemeralPublic)
const dh4 = await x25519DeriveSharedSecret(
recipientOneTimePreKeyPrivate,
senderEphemeralPublic
)
dhResults = [dh1, dh2, dh3, dh4]
} else {
dhResults = [dh1, dh2, dh3]

View File

@ -1,9 +1,9 @@
import { render } from "solid-js/web";
import { Router } from "@solidjs/router";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import App from "./App";
import { ToastContainer } from "./components/UI/Toast";
import "./index.css";
import { Router } from '@solidjs/router'
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { render } from 'solid-js/web'
import App from './App'
import { ToastContainer } from './components/UI/Toast'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
@ -13,12 +13,12 @@ const queryClient = new QueryClient({
staleTime: 5 * 60 * 1000,
},
},
});
})
const root = document.getElementById("root");
const root = document.getElementById('root')
if (root === null) {
throw new Error("Root element not found");
throw new Error('Root element not found')
}
render(
@ -31,4 +31,4 @@ render(
</QueryClientProvider>
),
root
);
)

View File

@ -2,36 +2,33 @@
// © AngelaMos | 2025
// Chat.tsx
// ===================
import { Show, onMount, onCleanup, createEffect } from "solid-js"
import type { JSX } from "solid-js"
import type { Participant } from "../types"
import { useStore } from "@nanostores/solid"
import { AppShell, ProtectedRoute } from "../components/Layout"
import { useStore } from '@nanostores/solid'
import type { JSX } from 'solid-js'
import { createEffect, onCleanup, onMount, Show } from 'solid-js'
import {
MessageList,
ChatHeader,
ChatInput,
MessageList,
NewConversation,
} from "../components/Chat"
} from '../components/Chat'
import { AppShell, ProtectedRoute } from '../components/Layout'
import { cryptoService, saveDecryptedMessage } from '../crypto'
import { roomService } from '../services'
import {
$activeModal,
$activeRoom,
$activeRoomId,
$activeModal,
$userId,
$currentUser,
showToast,
setActiveRoom,
openModal,
closeModal,
$userId,
addMessage,
} from "../stores"
import {
connectWebSocket,
disconnectWebSocket,
wsManager,
} from "../websocket"
import { roomService } from "../services"
import { cryptoService, saveDecryptedMessage } from "../crypto"
closeModal,
openModal,
setActiveRoom,
showToast,
} from '../stores'
import type { Participant } from '../types'
import { connectWebSocket, disconnectWebSocket, wsManager } from '../websocket'
export default function Chat(): JSX.Element {
const activeRoom = useStore($activeRoom)
@ -51,8 +48,7 @@ export default function Chat(): JSX.Element {
if (currentUserId) {
try {
await cryptoService.initialize(currentUserId)
} catch {
}
} catch {}
connectWebSocket()
await roomService.loadRooms(currentUserId)
}
@ -69,19 +65,21 @@ export default function Chat(): JSX.Element {
const user = $currentUser.get()
if (roomId === null || room === null) {
showToast("error", "SEND FAILED", "NO ACTIVE ROOM")
showToast('error', 'SEND FAILED', 'NO ACTIVE ROOM')
return
}
if (currentUserId === null) {
showToast("error", "SEND FAILED", "NOT AUTHENTICATED")
showToast('error', 'SEND FAILED', 'NOT AUTHENTICATED')
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) {
showToast("error", "SEND FAILED", "NO RECIPIENT FOUND")
showToast('error', 'SEND FAILED', 'NO RECIPIENT FOUND')
return
}
@ -92,9 +90,9 @@ export default function Chat(): JSX.Element {
id: tempId,
room_id: roomId,
sender_id: currentUserId,
sender_username: user?.username ?? "me",
sender_username: user?.username ?? 'me',
content,
status: "sending" as const,
status: 'sending' as const,
is_encrypted: true,
created_at: now,
updated_at: now,
@ -114,11 +112,10 @@ export default function Chat(): JSX.Element {
if (sent) {
void saveDecryptedMessage(messageToSend)
} else {
showToast("error", "SEND FAILED", "NOT CONNECTED")
showToast('error', 'SEND FAILED', 'NOT CONNECTED')
}
} catch (error) {
console.error("[Chat] Encryption failed:", error)
showToast("error", "SEND FAILED", "ENCRYPTION ERROR")
} catch (_error) {
showToast('error', 'SEND FAILED', 'ENCRYPTION ERROR')
}
}
@ -126,7 +123,7 @@ export default function Chat(): JSX.Element {
const currentUserId = userId()
if (currentUserId === null) {
showToast("error", "FAILED", "NOT AUTHENTICATED")
showToast('error', 'FAILED', 'NOT AUTHENTICATED')
return
}
@ -136,12 +133,12 @@ export default function Chat(): JSX.Element {
setActiveRoom(room.id)
closeModal()
} else {
showToast("error", "FAILED", "COULD NOT CREATE CONVERSATION")
showToast('error', 'FAILED', 'COULD NOT CREATE CONVERSATION')
}
}
const handleNewChat = (): void => {
openModal("new-conversation")
openModal('new-conversation')
}
return (
@ -155,15 +152,15 @@ export default function Chat(): JSX.Element {
>
{(roomId) => (
<>
<ChatHeader
room={activeRoom()}
/>
<MessageList
roomId={roomId}
/>
<ChatHeader room={activeRoom()} />
<MessageList roomId={roomId} />
<ChatInput
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}
/>
</>
@ -172,7 +169,7 @@ export default function Chat(): JSX.Element {
</div>
<NewConversation
isOpen={activeModal() === "new-conversation"}
isOpen={activeModal() === 'new-conversation'}
onClose={closeModal}
onCreateRoom={handleCreateRoom}
/>
@ -210,7 +207,14 @@ function EmptyState(props: EmptyStateProps): JSX.Element {
function ChatIcon(): JSX.Element {
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="4" y="12" width="4" height="24" />
<rect x="40" y="12" width="4" height="24" />

View File

@ -2,12 +2,12 @@
* Home page - landing or redirect to chat
*/
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { A } from "@solidjs/router"
import { useStore } from "@nanostores/solid"
import { $isAuthenticated } from "../stores"
import { Button } from "../components/UI"
import { useStore } from '@nanostores/solid'
import { A } from '@solidjs/router'
import type { JSX } from 'solid-js'
import { Show } from 'solid-js'
import { Button } from '../components/UI'
import { $isAuthenticated } from '../stores'
export default function Home(): JSX.Element {
const isAuthenticated = useStore($isAuthenticated)
@ -19,13 +19,11 @@ export default function Home(): JSX.Element {
<LockIcon />
</div>
<h1 class="font-pixel text-2xl text-orange mb-4">
ENCRYPTED CHAT
</h1>
<h1 class="font-pixel text-2xl text-orange mb-4">ENCRYPTED CHAT</h1>
<p class="font-pixel text-[10px] text-gray mb-8 leading-relaxed">
END-TO-END ENCRYPTED MESSAGING WITH DOUBLE RATCHET PROTOCOL.
YOUR MESSAGES ARE SECURE AND PRIVATE.
END-TO-END ENCRYPTED MESSAGING WITH DOUBLE RATCHET PROTOCOL. YOUR
MESSAGES ARE SECURE AND PRIVATE.
</p>
<div class="flex flex-col gap-4 items-center">
@ -80,20 +78,75 @@ function FeatureItem(props: FeatureItemProps): JSX.Element {
function LockIcon(): JSX.Element {
return (
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" class="mx-auto">
<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
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
class="mx-auto"
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>
)
}
function EncryptIcon(): JSX.Element {
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="6" 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 {
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="8" y="4" 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 {
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="6" y="4" width="4" height="2" />
<rect x="14" y="4" width="4" height="2" />

View File

@ -2,9 +2,9 @@
* Login page with WebAuthn passkey authentication
*/
import type { JSX } from "solid-js"
import { AuthForm } from "../components/Auth"
import { GuestRoute } from "../components/Layout"
import type { JSX } from 'solid-js'
import { AuthForm } from '../components/Auth'
import { GuestRoute } from '../components/Layout'
export default function Login(): JSX.Element {
return (

View File

@ -2,21 +2,17 @@
* 404 Not Found page
*/
import type { JSX } from "solid-js"
import { A } from "@solidjs/router"
import { Button } from "../components/UI"
import { A } from '@solidjs/router'
import type { JSX } from 'solid-js'
import { Button } from '../components/UI'
export default function NotFound(): JSX.Element {
return (
<div class="min-h-screen flex flex-col items-center justify-center bg-black p-4">
<div class="text-center">
<h1 class="font-pixel text-6xl text-orange mb-4">
404
</h1>
<h1 class="font-pixel text-6xl text-orange mb-4">404</h1>
<p class="font-pixel text-sm text-white mb-2">
PAGE NOT FOUND
</p>
<p class="font-pixel text-sm text-white mb-2">PAGE NOT FOUND</p>
<p class="font-pixel text-[10px] text-gray mb-8">
THE PAGE YOU ARE LOOKING FOR DOES NOT EXIST

View File

@ -3,9 +3,9 @@
// Registration page with WebAuthn passkey creation
// ===================
import type { JSX } from "solid-js"
import { AuthForm } from "../components/Auth"
import { GuestRoute } from "../components/Layout"
import type { JSX } from 'solid-js'
import { AuthForm } from '../components/Auth'
import { GuestRoute } from '../components/Layout'
export default function Register(): JSX.Element {
return (

View File

@ -3,20 +3,17 @@
* Handles passkey registration and authentication flows
*/
import { api } from "../lib/api-client"
import {
base64UrlEncode,
base64UrlDecode,
} from "../lib/base64"
import { cryptoService } from '../crypto'
import { api } from '../lib/api-client'
import { base64UrlDecode, base64UrlEncode } from '../lib/base64'
import { setCurrentUser, logout as storeLogout } from '../stores'
import type {
User,
RegistrationBeginRequest,
AuthenticationBeginRequest,
PublicKeyCredentialJSON,
} from "../types"
import { isPublicKeyCredential } from "../types/guards"
import { setCurrentUser, logout as storeLogout } from "../stores"
import { cryptoService } from "../crypto"
RegistrationBeginRequest,
User,
} from '../types'
import { isPublicKeyCredential } from '../types/guards'
interface PublicKeyCredentialStatic {
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
@ -31,22 +28,32 @@ function publicKeyCredentialToJSON(
const json: PublicKeyCredentialJSON = {
id: credential.id,
rawId: base64UrlEncode(credential.rawId),
type: "public-key",
type: 'public-key',
response: {
clientDataJSON: base64UrlEncode(response.clientDataJSON),
},
authenticatorAttachment: credential.authenticatorAttachment as "platform" | "cross-platform" | undefined,
clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>,
authenticatorAttachment: credential.authenticatorAttachment as
| 'platform'
| 'cross-platform'
| undefined,
clientExtensionResults: credential.getClientExtensionResults() as Record<
string,
unknown
>,
}
if ("attestationObject" in response) {
if ('attestationObject' in response) {
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
json.response.authenticatorData = base64UrlEncode(assertionResponse.authenticatorData)
json.response.authenticatorData = base64UrlEncode(
assertionResponse.authenticatorData
)
json.response.signature = base64UrlEncode(assertionResponse.signature)
if (assertionResponse.userHandle !== null) {
json.response.userHandle = base64UrlEncode(assertionResponse.userHandle)
@ -57,7 +64,10 @@ function publicKeyCredentialToJSON(
}
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(
@ -65,34 +75,48 @@ function preparePublicKeyOptions(
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
const prepared = { ...options }
if ("challenge" in prepared && typeof prepared.challenge === "string") {
prepared.challenge = toBufferSource(base64UrlDecode(prepared.challenge as unknown as string))
if ('challenge' in prepared && typeof prepared.challenge === 'string') {
prepared.challenge = toBufferSource(
base64UrlDecode(prepared.challenge as unknown as string)
)
}
if ("user" in prepared) {
if ('user' in prepared) {
const creationOptions = prepared as PublicKeyCredentialCreationOptions
if (typeof creationOptions.user.id === "string") {
creationOptions.user.id = toBufferSource(base64UrlDecode(creationOptions.user.id as unknown as string))
if (typeof creationOptions.user.id === '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
if (creationOptions.excludeCredentials !== undefined) {
creationOptions.excludeCredentials = creationOptions.excludeCredentials.map((cred) => ({
...cred,
id: typeof cred.id === "string" ? toBufferSource(base64UrlDecode(cred.id as unknown as string)) : cred.id,
}))
creationOptions.excludeCredentials = creationOptions.excludeCredentials.map(
(cred) => ({
...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
if (requestOptions.allowCredentials !== undefined) {
requestOptions.allowCredentials = requestOptions.allowCredentials.map((cred) => ({
...cred,
id: typeof cred.id === "string" ? toBufferSource(base64UrlDecode(cred.id as unknown as string)) : cred.id,
}))
requestOptions.allowCredentials = requestOptions.allowCredentials.map(
(cred) => ({
...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)) {
throw new Error("Failed to create credential")
throw new Error('Failed to create credential')
}
const credentialJSON = publicKeyCredentialToJSON(credential)
@ -153,7 +177,7 @@ export async function login(username?: string): Promise<User> {
})
if (!isPublicKeyCredential(credential)) {
throw new Error("Failed to get credential")
throw new Error('Failed to get credential')
}
const credentialJSON = publicKeyCredentialToJSON(credential)
@ -174,9 +198,9 @@ export function logout(): void {
export function isWebAuthnSupported(): boolean {
return (
typeof window !== "undefined" &&
typeof window.PublicKeyCredential !== "undefined" &&
typeof navigator.credentials !== "undefined"
typeof window !== 'undefined' &&
typeof window.PublicKeyCredential !== 'undefined' &&
typeof navigator.credentials !== 'undefined'
)
}
@ -199,7 +223,7 @@ export async function isConditionalUIAvailable(): Promise<boolean> {
try {
const pkc = PublicKeyCredential as unknown as PublicKeyCredentialStatic
if (typeof pkc.isConditionalMediationAvailable === "function") {
if (typeof pkc.isConditionalMediationAvailable === 'function') {
return await pkc.isConditionalMediationAvailable()
}
return false

View File

@ -2,5 +2,5 @@
// © AngelaMos | 2025
// index.ts
// ===================
export * from "./auth.service"
export * from "./room.service"
export * from './auth.service'
export * from './room.service'

View File

@ -3,21 +3,21 @@
* room.service.ts
*/
import { api } from "../lib/api-client"
import type { Message, Room } from "../types"
import {
setRooms,
addRoom,
setRoomMessages,
setHasMore,
$userId,
} from "../stores"
import {
getDecryptedMessages,
getDecryptedMessage,
saveDecryptedMessage,
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[]> {
try {
@ -32,7 +32,7 @@ export async function loadRooms(userId: string): Promise<Room[]> {
export async function createRoom(
creatorId: string,
participantId: string,
roomType: "direct" | "group" | "ephemeral" = "direct"
roomType: 'direct' | 'group' | 'ephemeral' = 'direct'
): Promise<Room | null> {
try {
const room = await api.rooms.create({
@ -42,8 +42,7 @@ export async function createRoom(
})
addRoom(room)
return room
} catch (err) {
console.error("[RoomService] Failed to create room:", err)
} catch (_err) {
return null
}
}
@ -73,7 +72,7 @@ export async function loadMessages(
continue
}
let content = "[Encrypted - from another session]"
let content = '[Encrypted - from another session]'
const isOwnMessage = msg.sender_id === currentUserId
if (isOwnMessage) {
@ -81,7 +80,7 @@ export async function loadMessages(
if (localCopy) {
content = localCopy.content
} else {
content = "[Your message - not stored locally]"
content = '[Your message - not stored locally]'
}
} else {
try {
@ -92,7 +91,7 @@ export async function loadMessages(
msg.header
)
} 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_username: msg.sender_username,
content,
status: "delivered" as const,
status: 'delivered' as const,
is_encrypted: true,
encrypted_content: msg.ciphertext,
nonce: msg.nonce,
@ -111,7 +110,10 @@ export async function loadMessages(
updated_at: msg.created_at,
}
if (!content.startsWith("[Encrypted") && !content.startsWith("[Your message")) {
if (
!content.startsWith('[Encrypted') &&
!content.startsWith('[Your message')
) {
void saveDecryptedMessage(decryptedMessage)
}
@ -121,13 +123,15 @@ export async function loadMessages(
const allMessages = [...localMessages, ...newMessages]
const uniqueMessages = Array.from(
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)
setHasMore(roomId, response.has_more)
return uniqueMessages
} catch (err) {
console.error("[RoomService] Failed to load messages:", err)
} catch (_err) {
const localMessages = await getDecryptedMessages(roomId, limit)
if (localMessages.length > 0) {
setRoomMessages(roomId, localMessages)

View File

@ -3,18 +3,18 @@
* Manages current user and authentication status
*/
import { atom, computed } from "nanostores"
import { persistentAtom } from "@nanostores/persistent"
import type { User, Session } from "../types"
import { persistentAtom } from '@nanostores/persistent'
import { atom, computed } from 'nanostores'
import type { Session, User } from '../types'
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,
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)
@ -22,7 +22,7 @@ export const $session = atom<Session | null>(null)
export function setCurrentUser(user: User | null): void {
$currentUser.set(user)
$userIdRaw.set(user?.id ?? "")
$userIdRaw.set(user?.id ?? '')
if (user !== null) {
$session.set({
@ -39,7 +39,7 @@ export function setCurrentUser(user: User | null): void {
export function logout(): void {
$currentUser.set(null)
$userIdRaw.set("")
$userIdRaw.set('')
$session.set(null)
}

View File

@ -2,11 +2,11 @@
// © AngelaMos | 2025
// index.ts
// ===================
export * from "./auth.store"
export * from "./session.store"
export * from "./settings.store"
export * from "./ui.store"
export * from "./rooms.store"
export * from "./messages.store"
export * from "./presence.store"
export * from "./typing.store"
export * from './auth.store'
export * from './messages.store'
export * from './presence.store'
export * from './rooms.store'
export * from './session.store'
export * from './settings.store'
export * from './typing.store'
export * from './ui.store'

View File

@ -3,9 +3,9 @@
* Manages message cache per room
*/
import { map, computed } from "nanostores"
import type { Message, MessageStatus } from "../types"
import { $activeRoomId } from "./rooms.store"
import { computed, map } from 'nanostores'
import type { Message, MessageStatus } from '../types'
import { $activeRoomId } from './rooms.store'
export const $messagesByRoom = map<Record<string, Message[]>>({})
@ -17,12 +17,12 @@ export const $oldestMessageIdByRoom = map<Record<string, string | undefined>>({}
export const $activeRoomMessages = computed(
[$messagesByRoom, $activeRoomId],
(messages, roomId) => (roomId ? messages[roomId] ?? [] : [])
(messages, roomId) => (roomId ? (messages[roomId] ?? []) : [])
)
export const $activeRoomPendingMessages = computed(
[$pendingMessages, $activeRoomId],
(pending, roomId) => (roomId ? pending[roomId] ?? [] : [])
(pending, roomId) => (roomId ? (pending[roomId] ?? []) : [])
)
export function addMessage(roomId: string, message: Message): void {
@ -142,7 +142,10 @@ export function clearAllMessages(): void {
$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]
return messages?.find((m) => m.id === messageId) ?? null
}

View File

@ -3,8 +3,8 @@
* Manages user online/offline status
*/
import { map } from "nanostores"
import type { PresenceStatus, UserPresence } from "../types"
import { map } from 'nanostores'
import type { PresenceStatus, UserPresence } from '../types'
export const $presenceByUser = map<Record<string, UserPresence>>({})
@ -38,18 +38,18 @@ export function getUserPresence(userId: string): UserPresence | null {
}
export function getUserStatus(userId: string): PresenceStatus {
return $presenceByUser.get()[userId]?.status ?? "offline"
return $presenceByUser.get()[userId]?.status ?? 'offline'
}
export function isUserOnline(userId: string): boolean {
const status = getUserStatus(userId)
return status === "online"
return status === 'online'
}
export function getOnlineUserIds(): string[] {
const presences = $presenceByUser.get()
return Object.entries(presences)
.filter(([_, p]) => p.status === "online")
.filter(([_, p]) => p.status === 'online')
.map(([id]) => id)
}

View File

@ -3,16 +3,15 @@
* Manages chat rooms and active conversation state
*/
import { atom, computed, map } from "nanostores"
import type { Room } from "../types"
import { atom, computed, map } from 'nanostores'
import type { Room } from '../types'
export const $rooms = map<Record<string, Room>>({})
export const $activeRoomId = atom<string | null>(null)
export const $activeRoom = computed(
[$rooms, $activeRoomId],
(rooms, activeId) => (activeId ? rooms[activeId] ?? null : null)
export const $activeRoom = computed([$rooms, $activeRoomId], (rooms, activeId) =>
activeId ? (rooms[activeId] ?? null) : null
)
export const $roomList = computed($rooms, (rooms) =>

View File

@ -3,7 +3,7 @@
* Stores authentication tokens in localStorage
*/
import { persistentMap, persistentAtom } from "@nanostores/persistent"
import { persistentAtom, persistentMap } from '@nanostores/persistent'
interface SessionTokens {
accessToken: string
@ -12,13 +12,13 @@ interface SessionTokens {
}
const DEFAULT_TOKENS: SessionTokens = {
accessToken: "",
refreshToken: "",
accessToken: '',
refreshToken: '',
expiresAt: 0,
}
export const $sessionTokens = persistentMap<SessionTokens>(
"chat:session:",
'chat:session:',
DEFAULT_TOKENS,
{
encode: JSON.stringify,
@ -26,14 +26,10 @@ export const $sessionTokens = persistentMap<SessionTokens>(
}
)
export const $lastActivity = persistentAtom<string>(
"chat:last_activity",
"",
{
encode: String,
decode: String,
}
)
export const $lastActivity = persistentAtom<string>('chat:last_activity', '', {
encode: String,
decode: String,
})
export function setSessionTokens(
accessToken: string,
@ -55,7 +51,7 @@ export function clearSessionTokens(): void {
export function isSessionValid(): boolean {
const tokens = $sessionTokens.get()
if (tokens.accessToken === "") {
if (tokens.accessToken === '') {
return false
}
return tokens.expiresAt > Date.now()
@ -70,7 +66,7 @@ export function getAccessToken(): string | null {
export function getRefreshToken(): string | null {
const tokens = $sessionTokens.get()
return tokens.refreshToken !== "" ? tokens.refreshToken : null
return tokens.refreshToken !== '' ? tokens.refreshToken : null
}
export function updateLastActivity(): void {
@ -79,5 +75,5 @@ export function updateLastActivity(): void {
export function getLastActivity(): Date | null {
const activity = $lastActivity.get()
return activity !== "" ? new Date(activity) : null
return activity !== '' ? new Date(activity) : null
}

View File

@ -3,12 +3,12 @@
* Stores user preferences in localStorage
*/
import { persistentMap } from "@nanostores/persistent"
import { computed } from "nanostores"
import { persistentMap } from '@nanostores/persistent'
import { computed } from 'nanostores'
type Theme = "dark" | "light"
type FontSize = "small" | "medium" | "large"
type NotificationSound = "retro" | "subtle" | "none"
type Theme = 'dark' | 'light'
type FontSize = 'small' | 'medium' | 'large'
type NotificationSound = 'retro' | 'subtle' | 'none'
interface UserSettings {
theme: Theme
@ -24,10 +24,10 @@ interface UserSettings {
}
const DEFAULT_SETTINGS: UserSettings = {
theme: "dark",
fontSize: "medium",
theme: 'dark',
fontSize: 'medium',
soundEnabled: true,
notificationSound: "retro",
notificationSound: 'retro',
desktopNotifications: true,
showTypingIndicators: true,
showReadReceipts: true,
@ -37,7 +37,7 @@ const DEFAULT_SETTINGS: UserSettings = {
}
export const $settings = persistentMap<UserSettings>(
"chat:settings:",
'chat:settings:',
DEFAULT_SETTINGS,
{
encode: JSON.stringify,
@ -50,43 +50,43 @@ export const $theme = computed($settings, (settings) => settings.theme)
export const $fontSize = computed($settings, (settings) => settings.fontSize)
export function setTheme(theme: Theme): void {
$settings.setKey("theme", theme)
$settings.setKey('theme', theme)
}
export function setFontSize(fontSize: FontSize): void {
$settings.setKey("fontSize", fontSize)
$settings.setKey('fontSize', fontSize)
}
export function setSoundEnabled(enabled: boolean): void {
$settings.setKey("soundEnabled", enabled)
$settings.setKey('soundEnabled', enabled)
}
export function setNotificationSound(sound: NotificationSound): void {
$settings.setKey("notificationSound", sound)
$settings.setKey('notificationSound', sound)
}
export function setDesktopNotifications(enabled: boolean): void {
$settings.setKey("desktopNotifications", enabled)
$settings.setKey('desktopNotifications', enabled)
}
export function setShowTypingIndicators(show: boolean): void {
$settings.setKey("showTypingIndicators", show)
$settings.setKey('showTypingIndicators', show)
}
export function setShowReadReceipts(show: boolean): void {
$settings.setKey("showReadReceipts", show)
$settings.setKey('showReadReceipts', show)
}
export function setShowOnlineStatus(show: boolean): void {
$settings.setKey("showOnlineStatus", show)
$settings.setKey('showOnlineStatus', show)
}
export function setEnterToSend(enabled: boolean): void {
$settings.setKey("enterToSend", enabled)
$settings.setKey('enterToSend', enabled)
}
export function setCompactMode(enabled: boolean): void {
$settings.setKey("compactMode", enabled)
$settings.setKey('compactMode', enabled)
}
export function resetSettings(): void {

View File

@ -3,9 +3,9 @@
* Manages typing status per room
*/
import { map, computed } from "nanostores"
import type { TypingState } from "../types"
import { $activeRoomId } from "./rooms.store"
import { computed, map } from 'nanostores'
import type { TypingState } from '../types'
import { $activeRoomId } from './rooms.store'
const TYPING_TIMEOUT_MS = 5000
@ -15,7 +15,7 @@ const typingTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
export const $activeRoomTyping = computed(
[$typingByRoom, $activeRoomId],
(typing, roomId) => (roomId ? typing[roomId] ?? [] : [])
(typing, roomId) => (roomId ? (typing[roomId] ?? []) : [])
)
export const $activeRoomTypingUsernames = computed(

View File

@ -3,20 +3,20 @@
* Manages sidebar, modals, and other UI state
*/
import { atom, computed } from "nanostores"
import { atom, computed } from 'nanostores'
type ModalType =
| "new-conversation"
| "user-search"
| "settings"
| "profile"
| "encryption-info"
| "confirm-logout"
| 'new-conversation'
| 'user-search'
| 'settings'
| 'profile'
| 'encryption-info'
| 'confirm-logout'
| null
interface ToastNotification {
id: string
variant: "info" | "success" | "warning" | "error"
variant: 'info' | 'success' | 'warning' | 'error'
title: string
description?: string
duration?: number
@ -34,16 +34,13 @@ export const $toasts = atom<ToastNotification[]>([])
export const $isLoading = atom<boolean>(false)
export const $loadingMessage = atom<string>("")
export const $loadingMessage = atom<string>('')
export const $isMobile = atom<boolean>(false)
export const $searchQuery = atom<string>("")
export const $searchQuery = atom<string>('')
export const $hasActiveModal = computed(
$activeModal,
(modal) => modal !== null
)
export const $hasActiveModal = computed($activeModal, (modal) => modal !== null)
let toastIdCounter = 0
@ -74,14 +71,15 @@ export function closeModal(): void {
}
export function showToast(
variant: ToastNotification["variant"],
variant: ToastNotification['variant'],
title: string,
description?: string,
duration?: number
): string {
const existingToasts = $toasts.get()
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) {
@ -119,7 +117,7 @@ export function clearAllToasts(): void {
export function setLoading(loading: boolean, message?: string): void {
$isLoading.set(loading)
$loadingMessage.set(message ?? "")
$loadingMessage.set(message ?? '')
}
export function setIsMobile(isMobile: boolean): void {
@ -131,7 +129,7 @@ export function setSearchQuery(query: string): void {
}
export function clearSearchQuery(): void {
$searchQuery.set("")
$searchQuery.set('')
}
export type { ModalType, ToastNotification }

View File

@ -9,7 +9,7 @@ export class ApiError extends Error {
message?: string
) {
super(message ?? `API Error: ${status}`)
this.name = "ApiError"
this.name = 'ApiError'
}
}

View File

@ -41,13 +41,15 @@ export interface AuthenticationCompleteRequest {
}
export interface WebAuthnOptions {
publicKey: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions
publicKey:
| PublicKeyCredentialCreationOptions
| PublicKeyCredentialRequestOptions
}
export interface PublicKeyCredentialJSON {
id: string
rawId: string
type: "public-key"
type: 'public-key'
response: {
clientDataJSON: string
attestationObject?: string
@ -55,7 +57,7 @@ export interface PublicKeyCredentialJSON {
signature?: string
userHandle?: string
}
authenticatorAttachment?: "platform" | "cross-platform"
authenticatorAttachment?: 'platform' | 'cross-platform'
clientExtensionResults: Record<string, unknown>
}

View File

@ -2,11 +2,11 @@
// © AngelaMos | 2025
// 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 {
id: string
@ -41,7 +41,7 @@ export interface Participant {
user_id: string
username: string
display_name: string
role: "owner" | "admin" | "member"
role: 'owner' | 'admin' | 'member'
joined_at: string
}

View File

@ -2,14 +2,14 @@
// © AngelaMos | 2025
// components.ts
// ===================
import type { JSX } from "solid-js"
import type { MessageStatus, PresenceStatus } from "./chat"
import type { JSX } from 'solid-js'
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 {
variant?: ButtonVariant
@ -19,14 +19,14 @@ export interface ButtonProps {
loading?: boolean
leftIcon?: JSX.Element
rightIcon?: JSX.Element
type?: "button" | "submit" | "reset"
type?: 'button' | 'submit' | 'reset'
onClick?: () => void
class?: string
children: JSX.Element
}
export interface InputProps {
type?: "text" | "email" | "password" | "search"
type?: 'text' | 'email' | 'password' | 'search'
name?: string
placeholder?: string
value?: string
@ -93,7 +93,7 @@ export interface ModalProps {
export interface ToastProps {
id: string
variant: "info" | "success" | "warning" | "error"
variant: 'info' | 'success' | 'warning' | 'error'
title: string
description?: string
duration?: number
@ -109,7 +109,7 @@ export interface SpinnerProps {
}
export interface SkeletonProps {
variant?: "text" | "circular" | "rectangular"
variant?: 'text' | 'circular' | 'rectangular'
width?: string
height?: string
lines?: number
@ -118,7 +118,7 @@ export interface SkeletonProps {
export interface TooltipProps {
content: string
position?: "top" | "bottom" | "left" | "right"
position?: 'top' | 'bottom' | 'left' | 'right'
delay?: number
children: JSX.Element
}
@ -135,7 +135,7 @@ export interface DropdownProps {
items: DropdownItem[]
onSelect: (value: string) => void
trigger: JSX.Element
align?: "left" | "right"
align?: 'left' | 'right'
class?: string
}
@ -143,7 +143,7 @@ export interface IconButtonProps {
icon: JSX.Element
onClick?: () => void
size?: Size
variant?: "ghost" | "subtle"
variant?: 'ghost' | 'subtle'
ariaLabel: string
disabled?: boolean
loading?: boolean

View File

@ -3,40 +3,40 @@
*/
import type {
User,
Message,
Room,
MessageStatus,
PresenceStatus,
RoomType,
PreKeyBundle,
ApiErrorResponse,
ValidationError,
WSMessage,
EncryptedMessageWS,
TypingIndicatorWS,
ErrorMessageWS,
HeartbeatWS,
Message,
MessageSentWS,
MessageStatus,
PreKeyBundle,
PresenceStatus,
PresenceUpdateWS,
ReadReceiptWS,
HeartbeatWS,
ErrorMessageWS,
Room,
RoomCreatedWS,
MessageSentWS,
} from "./index"
RoomType,
TypingIndicatorWS,
User,
ValidationError,
WSMessage,
} from './index'
export function isString(value: unknown): value is string {
return typeof value === "string"
return typeof value === 'string'
}
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 {
return typeof value === "boolean"
return typeof value === 'boolean'
}
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[] {
@ -62,20 +62,20 @@ export function isUser(value: unknown): value is User {
export function isMessageStatus(value: unknown): value is MessageStatus {
return (
value === "sending" ||
value === "sent" ||
value === "delivered" ||
value === "read" ||
value === "failed"
value === 'sending' ||
value === 'sent' ||
value === 'delivered' ||
value === 'read' ||
value === 'failed'
)
}
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 {
return value === "direct" || value === "group" || value === "ephemeral"
return value === 'direct' || value === 'group' || value === 'ephemeral'
}
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)
}
export function isValidationErrorArray(value: unknown): value is ValidationError[] {
export function isValidationErrorArray(
value: unknown
): value is ValidationError[] {
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)
}
export function isEncryptedMessageWS(value: unknown): value is EncryptedMessageWS {
export function isEncryptedMessageWS(
value: unknown
): value is EncryptedMessageWS {
if (!isObject(value)) return false
return (
value.type === "encrypted_message" &&
value.type === 'encrypted_message' &&
isNonEmptyString(value.message_id) &&
isNonEmptyString(value.sender_id) &&
isNonEmptyString(value.recipient_id) &&
@ -155,7 +159,7 @@ export function isTypingIndicatorWS(value: unknown): value is TypingIndicatorWS
if (!isObject(value)) return false
return (
value.type === "typing" &&
value.type === 'typing' &&
isNonEmptyString(value.user_id) &&
isNonEmptyString(value.room_id) &&
isBoolean(value.is_typing) &&
@ -167,7 +171,7 @@ export function isPresenceUpdateWS(value: unknown): value is PresenceUpdateWS {
if (!isObject(value)) return false
return (
value.type === "presence" &&
value.type === 'presence' &&
isNonEmptyString(value.user_id) &&
isPresenceStatus(value.status) &&
isNonEmptyString(value.last_seen)
@ -178,7 +182,7 @@ export function isReadReceiptWS(value: unknown): value is ReadReceiptWS {
if (!isObject(value)) return false
return (
value.type === "receipt" &&
value.type === 'receipt' &&
isNonEmptyString(value.message_id) &&
isNonEmptyString(value.room_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 {
if (!isObject(value)) return false
return value.type === "heartbeat"
return value.type === 'heartbeat'
}
export function isErrorMessageWS(value: unknown): value is ErrorMessageWS {
if (!isObject(value)) return false
return (
value.type === "error" &&
value.type === 'error' &&
isNonEmptyString(value.error_code) &&
isNonEmptyString(value.error_message)
)
@ -206,7 +210,7 @@ export function isRoomCreatedWS(value: unknown): value is RoomCreatedWS {
if (!isObject(value)) return false
return (
value.type === "room_created" &&
value.type === 'room_created' &&
isNonEmptyString(value.room_id) &&
isNonEmptyString(value.room_type) &&
isArray(value.participants) &&
@ -220,7 +224,7 @@ export function isMessageSentWS(value: unknown): value is MessageSentWS {
if (!isObject(value)) return false
return (
value.type === "message_sent" &&
value.type === 'message_sent' &&
isNonEmptyString(value.temp_id) &&
isNonEmptyString(value.message_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
}
export function isPublicKeyCredential(value: Credential | null): value is PublicKeyCredential {
return value !== null && value.type === "public-key"
export function isPublicKeyCredential(
value: Credential | null
): value is PublicKeyCredential {
return value !== null && value.type === 'public-key'
}

View File

@ -2,10 +2,10 @@
// © AngelaMos | 2025
// index.ts
// ===================
export * from "./api"
export * from "./auth"
export * from "./chat"
export * from "./websocket"
export * from "./encryption"
export * from "./components"
export * from "./guards"
export * from './api'
export * from './auth'
export * from './chat'
export * from './components'
export * from './encryption'
export * from './guards'
export * from './websocket'

View File

@ -2,17 +2,17 @@
// © AngelaMos | 2025
// websockets.ts
// ===================
import type { PresenceStatus } from "./chat"
import type { PresenceStatus } from './chat'
export type WSMessageType =
| "encrypted_message"
| "typing"
| "presence"
| "receipt"
| "heartbeat"
| "error"
| "room_created"
| "message_sent"
| 'encrypted_message'
| 'typing'
| 'presence'
| 'receipt'
| 'heartbeat'
| 'error'
| 'room_created'
| 'message_sent'
export interface BaseWSMessage {
type: WSMessageType
@ -20,7 +20,7 @@ export interface BaseWSMessage {
}
export interface EncryptedMessageWS extends BaseWSMessage {
type: "encrypted_message"
type: 'encrypted_message'
message_id: string
sender_id: string
recipient_id: string
@ -33,7 +33,7 @@ export interface EncryptedMessageWS extends BaseWSMessage {
}
export interface TypingIndicatorWS extends BaseWSMessage {
type: "typing"
type: 'typing'
user_id: string
room_id: string
is_typing: boolean
@ -41,14 +41,14 @@ export interface TypingIndicatorWS extends BaseWSMessage {
}
export interface PresenceUpdateWS extends BaseWSMessage {
type: "presence"
type: 'presence'
user_id: string
status: PresenceStatus
last_seen: string
}
export interface ReadReceiptWS extends BaseWSMessage {
type: "receipt"
type: 'receipt'
message_id: string
room_id: string
user_id: string
@ -56,18 +56,18 @@ export interface ReadReceiptWS extends BaseWSMessage {
}
export interface HeartbeatWS extends BaseWSMessage {
type: "heartbeat"
type: 'heartbeat'
}
export interface ErrorMessageWS extends BaseWSMessage {
type: "error"
type: 'error'
error_code: string
error_message: string
details?: Record<string, unknown>
}
export interface RoomCreatedWS extends BaseWSMessage {
type: "room_created"
type: 'room_created'
room_id: string
room_type: string
name: string | null
@ -84,7 +84,7 @@ export interface RoomCreatedWS extends BaseWSMessage {
}
export interface MessageSentWS extends BaseWSMessage {
type: "message_sent"
type: 'message_sent'
temp_id: string
message_id: string
room_id: string
@ -103,7 +103,7 @@ export type WSMessage =
| MessageSentWS
export interface WSOutgoingEncryptedMessage {
type: "encrypted_message"
type: 'encrypted_message'
recipient_id: string
room_id: string
ciphertext: string
@ -113,24 +113,24 @@ export interface WSOutgoingEncryptedMessage {
}
export interface WSOutgoingTyping {
type: "typing"
type: 'typing'
room_id: string
is_typing: boolean
}
export interface WSOutgoingPresence {
type: "presence"
type: 'presence'
status: PresenceStatus
}
export interface WSOutgoingReceipt {
type: "receipt"
type: 'receipt'
message_id: string
room_id: string
}
export interface WSOutgoingHeartbeat {
type: "heartbeat"
type: 'heartbeat'
timestamp: string
}

View File

@ -2,5 +2,6 @@
// © AngelaMos | 2025
// index.ts
// ===================
export * from "./websocket-manager"
export * from "./message-handlers"
export * from './message-handlers'
export * from './websocket-manager'

View File

@ -2,47 +2,44 @@
// © AngelaMos | 2025
// message-handlers.ts
// ===================
import type {
WSMessage,
EncryptedMessageWS,
TypingIndicatorWS,
PresenceUpdateWS,
ReadReceiptWS,
ErrorMessageWS,
RoomCreatedWS,
MessageSentWS,
Message,
Room,
} from "../types"
import {
isEncryptedMessageWS,
isTypingIndicatorWS,
isPresenceUpdateWS,
isReadReceiptWS,
isErrorMessageWS,
isRoomCreatedWS,
isMessageSentWS,
} from "../types/guards"
import { cryptoService, saveDecryptedMessage, updateMessageId } from '../crypto'
import {
addMessage,
updateMessageStatus,
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 {
addRoom,
} from "../stores/rooms.store"
import {
setUserPresence,
} from "../stores/presence.store"
import {
setUserTyping,
} from "../stores/typing.store"
import { showToast } from "../stores/ui.store"
import { cryptoService, saveDecryptedMessage, updateMessageId } from "../crypto"
isEncryptedMessageWS,
isErrorMessageWS,
isMessageSentWS,
isPresenceUpdateWS,
isReadReceiptWS,
isRoomCreatedWS,
isTypingIndicatorWS,
} from '../types/guards'
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
try {
@ -53,7 +50,7 @@ async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<voi
message.header
)
} catch {
decryptedContent = "[Encrypted message - decryption failed]"
decryptedContent = '[Encrypted message - decryption failed]'
}
const chatMessage: Message = {
@ -62,7 +59,7 @@ async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<voi
sender_id: message.sender_id,
sender_username: message.sender_username,
content: decryptedContent,
status: "delivered",
status: 'delivered',
is_encrypted: true,
encrypted_content: message.ciphertext,
nonce: message.nonce,
@ -85,25 +82,21 @@ const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => {
}
const presenceUpdateHandler: MessageHandler<PresenceUpdateWS> = (message) => {
setUserPresence(
message.user_id,
message.status,
message.last_seen
)
setUserPresence(message.user_id, message.status, message.last_seen)
}
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) => {
showToast("error", "CONNECTION ERROR", message.error_message.toUpperCase())
showToast('error', 'CONNECTION ERROR', message.error_message.toUpperCase())
}
const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
const room: Room = {
id: message.room_id,
type: message.room_type as "direct" | "group" | "ephemeral",
type: message.room_type as 'direct' | 'group' | 'ephemeral',
name: message.name,
participants: message.participants,
unread_count: 0,
@ -113,17 +106,17 @@ const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
}
addRoom(room)
showToast("info", "NEW CHAT", `NEW CONVERSATION STARTED`)
showToast('info', 'NEW CHAT', `NEW CONVERSATION STARTED`)
}
const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
const confirmedMessage: Message = {
id: message.message_id,
room_id: message.room_id,
sender_id: "",
sender_username: "",
content: "",
status: "sent",
sender_id: '',
sender_username: '',
content: '',
status: 'sent',
is_encrypted: true,
created_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)
}

View File

@ -2,49 +2,54 @@
// ©AngelaMos | 2025
// websocket-manager.ts
// ===================
import { atom, computed } from "nanostores"
import {
WS_URL,
WS_HEARTBEAT_INTERVAL,
WS_RECONNECT_DELAY,
} from "../config"
import { $userId } from "../stores"
import { atom, computed } from 'nanostores'
import { WS_HEARTBEAT_INTERVAL, WS_RECONNECT_DELAY, WS_URL } from '../config'
import { $userId } from '../stores'
import type {
WSMessage,
WSOutgoingMessage,
WSOutgoingEncryptedMessage,
WSOutgoingHeartbeat,
WSOutgoingTyping,
WSOutgoingMessage,
WSOutgoingPresence,
WSOutgoingReceipt,
WSOutgoingEncryptedMessage,
} from "../types"
import { handleWSMessage } from "./message-handlers"
WSOutgoingTyping,
} from '../types'
import {
isWSMessage,
isEncryptedMessageWS,
isTypingIndicatorWS,
isErrorMessageWS,
isHeartbeatWS,
isMessageSentWS,
isPresenceUpdateWS,
isReadReceiptWS,
isHeartbeatWS,
isErrorMessageWS,
isRoomCreatedWS,
isMessageSentWS,
} from "../types/guards"
isTypingIndicatorWS,
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 $lastError = atom<string | null>(null)
export const $isConnected = computed(
$connectionStatus,
(status) => status === "connected"
(status) => status === 'connected'
)
const MAX_RECONNECT_ATTEMPTS = 10
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 {
private ws: WebSocket | null = null
@ -57,7 +62,7 @@ class WebSocketManager {
connect(): void {
const userId = $userId.get()
if (!userId) {
$lastError.set("Cannot connect: User not authenticated")
$lastError.set('Cannot connect: User not authenticated')
return
}
@ -70,7 +75,7 @@ class WebSocketManager {
}
this.intentionalClose = false
$connectionStatus.set("connecting")
$connectionStatus.set('connecting')
$lastError.set(null)
const wsUrl = `${WS_URL}/ws?user_id=${userId}`
@ -86,7 +91,7 @@ class WebSocketManager {
this.intentionalClose = true
this.fatalError = false
this.cleanup()
$connectionStatus.set("disconnected")
$connectionStatus.set('disconnected')
$reconnectAttempts.set(0)
}
@ -112,7 +117,7 @@ class WebSocketManager {
tempId: string
): boolean {
const message: WSOutgoingEncryptedMessage = {
type: "encrypted_message",
type: 'encrypted_message',
recipient_id: recipientId,
room_id: roomId,
ciphertext: encrypted.ciphertext,
@ -125,16 +130,16 @@ class WebSocketManager {
sendTypingIndicator(roomId: string, isTyping: boolean): boolean {
const message: WSOutgoingTyping = {
type: "typing",
type: 'typing',
room_id: roomId,
is_typing: isTyping,
}
return this.send(message)
}
sendPresenceUpdate(status: "online" | "away" | "offline"): boolean {
sendPresenceUpdate(status: 'online' | 'away' | 'offline'): boolean {
const message: WSOutgoingPresence = {
type: "presence",
type: 'presence',
status,
}
return this.send(message)
@ -142,7 +147,7 @@ class WebSocketManager {
sendReadReceipt(messageId: string, roomId: string): boolean {
const message: WSOutgoingReceipt = {
type: "receipt",
type: 'receipt',
message_id: messageId,
room_id: roomId,
}
@ -154,12 +159,12 @@ class WebSocketManager {
}
private handleOpen(): void {
$connectionStatus.set("connected")
$connectionStatus.set('connected')
$reconnectAttempts.set(0)
$lastError.set(null)
this.startHeartbeat()
this.sendPresenceUpdate("online")
this.sendPresenceUpdate('online')
this.flushMessageQueue()
}
@ -205,21 +210,23 @@ class WebSocketManager {
this.cleanup()
if (this.intentionalClose) {
$connectionStatus.set("disconnected")
$connectionStatus.set('disconnected')
return
}
if (this.fatalError) {
$connectionStatus.set("disconnected")
$connectionStatus.set('disconnected')
return
}
const attempts = $reconnectAttempts.get()
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
$connectionStatus.set("disconnected")
const reason = event.reason !== "" ? event.reason : "Unknown reason"
$lastError.set(`Connection failed after maximum retry attempts (code: ${event.code}, reason: ${reason})`)
$connectionStatus.set('disconnected')
const reason = event.reason !== '' ? event.reason : 'Unknown reason'
$lastError.set(
`Connection failed after maximum retry attempts (code: ${event.code}, reason: ${reason})`
)
return
}
@ -227,11 +234,11 @@ class WebSocketManager {
$lastError.set(`Connection closed unexpectedly (code: ${event.code})`)
}
$connectionStatus.set("reconnecting")
$connectionStatus.set('reconnecting')
$reconnectAttempts.set(attempts + 1)
const delay = Math.min(
WS_RECONNECT_DELAY * Math.pow(2, attempts),
WS_RECONNECT_DELAY * 2 ** attempts,
MAX_RECONNECT_DELAY
)
@ -241,7 +248,7 @@ class WebSocketManager {
}
private handleError(_event: Event): void {
$lastError.set("WebSocket connection error")
$lastError.set('WebSocket connection error')
}
private startHeartbeat(): void {
@ -250,7 +257,7 @@ class WebSocketManager {
this.heartbeatInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
const heartbeat: WSOutgoingHeartbeat = {
type: "heartbeat",
type: 'heartbeat',
timestamp: new Date().toISOString(),
}
this.ws.send(JSON.stringify(heartbeat))
@ -266,7 +273,10 @@ class WebSocketManager {
}
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()
if (message !== undefined) {
try {
@ -293,7 +303,10 @@ class WebSocketManager {
this.ws.onclose = 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 = null

View File

@ -2,45 +2,46 @@
// © AngelaMos | 2025
// vite.config.ts
// ===================
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
export default defineConfig({
plugins: [solid(), tailwindcss()],
envDir: "../",
envDir: '../',
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),
'~': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
host: true,
proxy: {
"/api": {
target: "http://backend:8000",
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
"/ws": {
target: "ws://backend:8000",
'/ws': {
target: 'ws://backend:8000',
ws: true,
},
},
},
build: {
target: "esnext",
outDir: "dist",
target: 'esnext',
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
"solid-js": ["solid-js"],
"solid-router": ["@solidjs/router"],
"solid-query": ["@tanstack/solid-query"],
'solid-js': ['solid-js'],
'solid-router': ['@solidjs/router'],
'solid-query': ['@tanstack/solid-query'],
},
},
},
},
});
})

View File

@ -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": {
"enabled": true,
"clientKind": "git",

View File

@ -25,7 +25,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@biomejs/biome": "^2.3.15",
"@biomejs/biome": "^2.4.2",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View File

@ -37,8 +37,8 @@ importers:
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)
devDependencies:
'@biomejs/biome':
specifier: ^2.3.15
version: 2.3.15
specifier: ^2.4.2
version: 2.4.2
'@types/node':
specifier: ^25.2.3
version: 25.2.3
@ -162,59 +162,59 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@biomejs/biome@2.3.15':
resolution: {integrity: sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==}
'@biomejs/biome@2.4.2':
resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.3.15':
resolution: {integrity: sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==}
'@biomejs/cli-darwin-arm64@2.4.2':
resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.3.15':
resolution: {integrity: sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==}
'@biomejs/cli-darwin-x64@2.4.2':
resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.3.15':
resolution: {integrity: sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==}
'@biomejs/cli-linux-arm64-musl@2.4.2':
resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.3.15':
resolution: {integrity: sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==}
'@biomejs/cli-linux-arm64@2.4.2':
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.3.15':
resolution: {integrity: sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==}
'@biomejs/cli-linux-x64-musl@2.4.2':
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.3.15':
resolution: {integrity: sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==}
'@biomejs/cli-linux-x64@2.4.2':
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.3.15':
resolution: {integrity: sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==}
'@biomejs/cli-win32-arm64@2.4.2':
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.3.15':
resolution: {integrity: sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==}
'@biomejs/cli-win32-x64@2.4.2':
resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@ -1423,39 +1423,39 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@biomejs/biome@2.3.15':
'@biomejs/biome@2.4.2':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.15
'@biomejs/cli-darwin-x64': 2.3.15
'@biomejs/cli-linux-arm64': 2.3.15
'@biomejs/cli-linux-arm64-musl': 2.3.15
'@biomejs/cli-linux-x64': 2.3.15
'@biomejs/cli-linux-x64-musl': 2.3.15
'@biomejs/cli-win32-arm64': 2.3.15
'@biomejs/cli-win32-x64': 2.3.15
'@biomejs/cli-darwin-arm64': 2.4.2
'@biomejs/cli-darwin-x64': 2.4.2
'@biomejs/cli-linux-arm64': 2.4.2
'@biomejs/cli-linux-arm64-musl': 2.4.2
'@biomejs/cli-linux-x64': 2.4.2
'@biomejs/cli-linux-x64-musl': 2.4.2
'@biomejs/cli-win32-arm64': 2.4.2
'@biomejs/cli-win32-x64': 2.4.2
'@biomejs/cli-darwin-arm64@2.3.15':
'@biomejs/cli-darwin-arm64@2.4.2':
optional: true
'@biomejs/cli-darwin-x64@2.3.15':
'@biomejs/cli-darwin-x64@2.4.2':
optional: true
'@biomejs/cli-linux-arm64-musl@2.3.15':
'@biomejs/cli-linux-arm64-musl@2.4.2':
optional: true
'@biomejs/cli-linux-arm64@2.3.15':
'@biomejs/cli-linux-arm64@2.4.2':
optional: true
'@biomejs/cli-linux-x64-musl@2.3.15':
'@biomejs/cli-linux-x64-musl@2.4.2':
optional: true
'@biomejs/cli-linux-x64@2.3.15':
'@biomejs/cli-linux-x64@2.4.2':
optional: true
'@biomejs/cli-win32-arm64@2.3.15':
'@biomejs/cli-win32-arm64@2.4.2':
optional: true
'@biomejs/cli-win32-x64@2.3.15':
'@biomejs/cli-win32-x64@2.4.2':
optional: true
'@cacheable/memory@2.0.7':

View File

@ -469,3 +469,19 @@ def main() -> None:
if __name__ == "__main__":
main()
"""
"""

View File

@ -1,8 +1,5 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-prettier-scss"
],
"extends": ["stylelint-config-standard-scss", "stylelint-config-prettier-scss"],
"rules": {
"scss/at-rule-no-unknown": null,
"selector-class-pattern": null

View File

@ -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"
}
}
}
}
]
}

View File

@ -2,14 +2,14 @@
// © AngelaMos | 2025
// eslint.config.js
// ===================
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import prettierConfig from 'eslint-config-prettier';
import globals from 'globals';
import js from '@eslint/js'
import prettierConfig from 'eslint-config-prettier'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
@ -47,15 +47,28 @@ export default tseslint.config(
},
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'@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/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@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/no-non-null-assertion': 'error',
'@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-unnecessary-condition': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/strict-boolean-expressions': ['error', {
allowString: false,
allowNumber: false,
allowNullableObject: false,
allowNullableString: true,
allowAny: true
}],
'@typescript-eslint/strict-boolean-expressions': [
'error',
{
allowString: false,
allowNumber: false,
allowNullableObject: false,
allowNullableString: true,
allowAny: true,
},
],
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
@ -78,7 +94,14 @@ export default tseslint.config(
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'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-pascal-case': ['error', { allowAllCaps: false }],
'react/no-array-index-key': 'warn',
@ -106,8 +129,11 @@ export default tseslint.config(
'object-shorthand': 'error',
'no-nested-ternary': 'error',
'max-depth': ['error', 6],
'max-lines': ['error', { max: 2000, skipBlankLines: true, skipComments: true }],
'complexity': ['error', 55],
'max-lines': [
'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

View File

@ -4,62 +4,66 @@
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"format": "prettier --write \"**/*.{ts,tsx,scss}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,scss}\"",
"lint": "npm run lint:eslint && npm run lint:scss && npm run lint:types",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"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:fix": "stylelint \"src/**/*.css\" --fix",
"lint:types": "tsc --project tsconfig.app.json --noEmit"
"lint:scss:fix": "stylelint \"src/**/*.css\" --fix"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query-devtools": "^5.91.2",
"axios": "^1.12.2",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
"axios": "^1.13.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"immer": "^11.1.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"immer": "^11.1.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.12.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"zod": "^4.3.6",
"zustand": "^5.0.10"
"zustand": "^5.0.11"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@eslint/js": "^9.39.2",
"@types/react": "^19.2.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.1.0",
"globals": "^17.3.0",
"husky": "^9.1.7",
"prettier": "^3.8.1",
"stylelint": "^17.0.0",
"stylelint": "^17.3.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^17.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.53.1",
"typescript-eslint": "^8.56.0",
"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

View File

@ -3,41 +3,34 @@
* Main application component
*/
import { useEffect } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { RouterProvider } from 'react-router-dom';
import { Toaster } from 'sonner';
import { queryClient } from '@/lib/queryClient';
import { router } from '@/router';
import { useAuthStore } from '@/store/authStore';
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useEffect } from 'react'
import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'
import { queryClient } from '@/lib/queryClient'
import { router } from '@/router'
import { useAuthStore } from '@/store/authStore'
const AuthInitializer = (): null => {
const loadUserFromStorage = useAuthStore(
(state) => state.loadUserFromStorage,
);
const loadUserFromStorage = useAuthStore((state) => state.loadUserFromStorage)
useEffect(() => {
loadUserFromStorage();
}, [loadUserFromStorage]);
loadUserFromStorage()
}, [loadUserFromStorage])
return null;
};
return null
}
function App(): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>
<AuthInitializer />
<RouterProvider router={router} />
<Toaster
position="top-right"
closeButton
duration={4500}
theme="dark"
/>
<Toaster position="top-right" closeButton duration={4500} theme="dark" />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
)
}
export default App;
export default App

View File

@ -3,133 +3,125 @@
// ©AngelaMos | 2025
// ===========================
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { isAxiosError } from 'axios';
import { Button } from '@/components/common/Button';
import { Input } from '@/components/common/Input';
import { useLogin } from '@/hooks/useAuth';
import { useUIStore } from '@/store/uiStore';
import { loginSchema } from '@/lib/validation';
import './AuthForm.css';
import { isAxiosError } from 'axios'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/common/Button'
import { Input } from '@/components/common/Input'
import { useLogin } from '@/hooks/useAuth'
import { loginSchema } from '@/lib/validation'
import { useUIStore } from '@/store/uiStore'
import './AuthForm.css'
export const LoginForm = (): React.ReactElement => {
const loginFormState = useUIStore((state) => state.loginForm);
const setLoginFormField = useUIStore((state) => state.setLoginFormField);
const clearLoginForm = useUIStore((state) => state.clearLoginForm);
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
const loginFormState = useUIStore((state) => state.loginForm)
const setLoginFormField = useUIStore((state) => state.setLoginFormField)
const clearLoginForm = useUIStore((state) => state.clearLoginForm)
const clearExpiredData = useUIStore((state) => state.clearExpiredData)
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [errors, setErrors] = useState<{
email?: string;
password?: string;
}>({});
email?: string
password?: string
}>({})
const { mutate: login, isPending, error } = useLogin();
const { mutate: login, isPending, error } = useLogin()
useEffect(() => {
clearExpiredData();
setEmail(loginFormState.email);
setPassword(loginFormState.password);
}, [clearExpiredData, loginFormState.email, loginFormState.password]);
clearExpiredData()
setEmail(loginFormState.email)
setPassword(loginFormState.password)
}, [clearExpiredData, loginFormState.email, loginFormState.password])
const handleEmailChange = (value: string): void => {
setEmail(value);
setLoginFormField('email', value);
setEmail(value)
setLoginFormField('email', value)
if (errors.email !== null && errors.email !== undefined) {
validateField('email', value);
validateField('email', value)
}
};
}
const handlePasswordChange = (value: string): void => {
setPassword(value);
setLoginFormField('password', value);
setPassword(value)
setLoginFormField('password', value)
if (errors.password !== null && errors.password !== undefined) {
validateField('password', value);
validateField('password', value)
}
};
}
const validateField = (
field: 'email' | 'password',
value: string,
): void => {
const validateField = (field: 'email' | 'password', value: string): void => {
const result = loginSchema.safeParse({
email: field === 'email' ? value : email,
password: field === 'password' ? value : password,
});
})
if (!result.success) {
const fieldError = result.error.issues.find(
(err) => err.path[0] === field,
);
const fieldError = result.error.issues.find((err) => err.path[0] === field)
if (fieldError !== null && fieldError !== undefined) {
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
setErrors((prev) => ({ ...prev, [field]: fieldError.message }))
} else {
setErrors((prev) => {
const { [field]: _, ...rest } = prev;
return rest;
});
const { [field]: _, ...rest } = prev
return rest
})
}
} else {
setErrors((prev) => {
const { [field]: _, ...rest } = prev;
return rest;
});
const { [field]: _, ...rest } = prev
return rest
})
}
};
}
const handleBlur = (field: 'email' | 'password'): void => {
const value = field === 'email' ? email : password;
validateField(field, value);
};
const value = field === 'email' ? email : password
validateField(field, value)
}
const validateForm = (): boolean => {
const result = loginSchema.safeParse({
email,
password,
});
})
if (!result.success) {
const newErrors: { email?: string; password?: string } = {};
const newErrors: { email?: string; password?: string } = {}
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) {
newErrors[field] = err.message;
newErrors[field] = err.message
}
});
})
setErrors(newErrors);
return false;
setErrors(newErrors)
return false
}
setErrors({});
return true;
};
setErrors({})
return true
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
e.preventDefault()
if (!validateForm()) {
return;
return
}
login(
{ email, password },
{
onSuccess: () => {
clearLoginForm();
clearLoginForm()
},
},
);
};
}
)
}
return (
<form
className="auth-form"
onSubmit={handleSubmit}
>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="auth-form__header">
<h1 className="auth-form__title">Welcome Back</h1>
<p className="auth-form__subtitle">Sign in to your account</p>
@ -162,32 +154,22 @@ export const LoginForm = (): React.ReactElement => {
</div>
{error !== null && error !== undefined && isAxiosError(error) ? (
<div
className="auth-form__error-message"
role="alert"
>
{(error.response?.data as { detail?: string } | undefined)
?.detail ?? 'Login failed. Please try again.'}
<div className="auth-form__error-message" role="alert">
{(error.response?.data as { detail?: string } | undefined)?.detail ??
'Login failed. Please try again.'}
</div>
) : null}
<Button
type="submit"
isLoading={isPending}
disabled={isPending}
>
<Button type="submit" isLoading={isPending} disabled={isPending}>
Sign In
</Button>
<p className="auth-form__link">
Don&apos;t have an account?{' '}
<Link
to="/register"
className="auth-form__link-text"
>
<Link to="/register" className="auth-form__link-text">
Sign up
</Link>
</p>
</form>
);
};
)
}

View File

@ -3,169 +3,157 @@
// ©AngelaMos | 2025
// ===========================
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { isAxiosError } from 'axios';
import { Button } from '@/components/common/Button';
import { Input } from '@/components/common/Input';
import { useRegister } from '@/hooks/useAuth';
import { useUIStore } from '@/store/uiStore';
import { registerSchema } from '@/lib/validation';
import './AuthForm.css';
import { isAxiosError } from 'axios'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/common/Button'
import { Input } from '@/components/common/Input'
import { useRegister } from '@/hooks/useAuth'
import { registerSchema } from '@/lib/validation'
import { useUIStore } from '@/store/uiStore'
import './AuthForm.css'
export const RegisterForm = (): React.ReactElement => {
const registerFormState = useUIStore((state) => state.registerForm);
const setRegisterFormField = useUIStore(
(state) => state.setRegisterFormField,
);
const clearRegisterForm = useUIStore((state) => state.clearRegisterForm);
const clearExpiredData = useUIStore((state) => state.clearExpiredData);
const registerFormState = useUIStore((state) => state.registerForm)
const setRegisterFormField = useUIStore((state) => state.setRegisterFormField)
const clearRegisterForm = useUIStore((state) => state.clearRegisterForm)
const clearExpiredData = useUIStore((state) => state.clearExpiredData)
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [confirmPassword, setConfirmPassword] = useState<string>('');
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
const [errors, setErrors] = useState<{
email?: string;
password?: string;
confirmPassword?: string;
}>({});
email?: string
password?: string
confirmPassword?: string
}>({})
const { mutate: register, isPending, error } = useRegister();
const { mutate: register, isPending, error } = useRegister()
useEffect(() => {
clearExpiredData();
setEmail(registerFormState.email);
setPassword(registerFormState.password);
setConfirmPassword(registerFormState.confirmPassword);
clearExpiredData()
setEmail(registerFormState.email)
setPassword(registerFormState.password)
setConfirmPassword(registerFormState.confirmPassword)
}, [
clearExpiredData,
registerFormState.email,
registerFormState.password,
registerFormState.confirmPassword,
]);
])
const handleEmailChange = (value: string): void => {
setEmail(value);
setRegisterFormField('email', value);
setEmail(value)
setRegisterFormField('email', value)
if (errors.email !== null && errors.email !== undefined) {
validateField('email', value);
validateField('email', value)
}
};
}
const handlePasswordChange = (value: string): void => {
setPassword(value);
setRegisterFormField('password', value);
setPassword(value)
setRegisterFormField('password', value)
if (errors.password !== null && errors.password !== undefined) {
validateField('password', value);
validateField('password', value)
}
};
}
const handleConfirmPasswordChange = (value: string): void => {
setConfirmPassword(value);
setRegisterFormField('confirmPassword', value);
if (
errors.confirmPassword !== null &&
errors.confirmPassword !== undefined
) {
validateField('confirmPassword', value);
setConfirmPassword(value)
setRegisterFormField('confirmPassword', value)
if (errors.confirmPassword !== null && errors.confirmPassword !== undefined) {
validateField('confirmPassword', value)
}
};
}
const validateField = (
field: 'email' | 'password' | 'confirmPassword',
value: string,
value: string
): void => {
const result = registerSchema.safeParse({
email: field === 'email' ? value : email,
password: field === 'password' ? value : password,
confirmPassword: field === 'confirmPassword' ? value : confirmPassword,
});
})
if (!result.success) {
const fieldError = result.error.issues.find(
(err) => err.path[0] === field,
);
const fieldError = result.error.issues.find((err) => err.path[0] === field)
if (fieldError !== null && fieldError !== undefined) {
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
setErrors((prev) => ({ ...prev, [field]: fieldError.message }))
} else {
setErrors((prev) => {
const { [field]: _, ...rest } = prev;
return rest;
});
const { [field]: _, ...rest } = prev
return rest
})
}
} else {
setErrors((prev) => {
const { [field]: _, ...rest } = prev;
return rest;
});
const { [field]: _, ...rest } = prev
return rest
})
}
};
}
const handleBlur = (
field: 'email' | 'password' | 'confirmPassword',
): void => {
let value: string;
const handleBlur = (field: 'email' | 'password' | 'confirmPassword'): void => {
let value: string
if (field === 'email') {
value = email;
value = email
} else if (field === 'password') {
value = password;
value = password
} else {
value = confirmPassword;
value = confirmPassword
}
validateField(field, value);
};
validateField(field, value)
}
const validateForm = (): boolean => {
const result = registerSchema.safeParse({
email,
password,
confirmPassword,
});
})
if (!result.success) {
const newErrors: {
email?: string;
password?: string;
confirmPassword?: string;
} = {};
email?: string
password?: string
confirmPassword?: string
} = {}
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) {
newErrors[field] = err.message;
newErrors[field] = err.message
}
});
})
setErrors(newErrors);
return false;
setErrors(newErrors)
return false
}
setErrors({});
return true;
};
setErrors({})
return true
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
e.preventDefault()
if (!validateForm()) {
return;
return
}
register(
{ email, password },
{
onSuccess: () => {
clearRegisterForm();
clearRegisterForm()
},
},
);
};
}
)
}
return (
<form
className="auth-form"
onSubmit={handleSubmit}
>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="auth-form__header">
<h1 className="auth-form__title">Create Account</h1>
<p className="auth-form__subtitle">
@ -212,32 +200,22 @@ export const RegisterForm = (): React.ReactElement => {
</div>
{error !== null && error !== undefined && isAxiosError(error) ? (
<div
className="auth-form__error-message"
role="alert"
>
{(error.response?.data as { detail?: string } | undefined)
?.detail ?? 'Registration failed. Please try again.'}
<div className="auth-form__error-message" role="alert">
{(error.response?.data as { detail?: string } | undefined)?.detail ??
'Registration failed. Please try again.'}
</div>
) : null}
<Button
type="submit"
isLoading={isPending}
disabled={isPending}
>
<Button type="submit" isLoading={isPending} disabled={isPending}>
Create Account
</Button>
<p className="auth-form__link">
Already have an account?{' '}
<Link
to="/login"
className="auth-form__link-text"
>
<Link to="/login" className="auth-form__link-text">
Sign in
</Link>
</p>
</form>
);
};
)
}

View File

@ -3,14 +3,14 @@
// ©AngelaMos | 2025
// ===========================
import { type ButtonHTMLAttributes } from 'react';
import './Button.css';
import type { ButtonHTMLAttributes } from 'react'
import './Button.css'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
children: React.ReactNode
}
export const Button = ({
@ -30,5 +30,5 @@ export const Button = ({
>
{isLoading ? 'Loading...' : children}
</button>
);
};
)
}

View File

@ -3,50 +3,42 @@
// ©AngelaMos | 2025
// ===========================
import { type InputHTMLAttributes, forwardRef } from 'react';
import './Input.css';
import { forwardRef, type InputHTMLAttributes } from 'react'
import './Input.css'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string | undefined;
label: string
error?: string | undefined
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, id, ...props }, ref) => {
const inputId =
id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
const errorId = `${inputId}-error`;
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`
const errorId = `${inputId}-error`
return (
<div className="input-wrapper">
<label
htmlFor={inputId}
className="input-label"
>
<label htmlFor={inputId} className="input-label">
{label}
</label>
<input
ref={ref}
id={inputId}
className={`input ${error !== null && error !== undefined ? 'input--error' : ''}`}
aria-invalid={error !== null && error !== undefined ? true : false}
aria-invalid={!!(error !== null && error !== undefined)}
aria-describedby={
error !== null && error !== undefined ? errorId : undefined
}
{...props}
/>
{error !== null && error !== undefined ? (
<p
id={errorId}
className="input-error"
role="alert"
>
<p id={errorId} className="input-error" role="alert">
{error}
</p>
) : null}
</div>
);
},
);
)
}
)
Input.displayName = 'Input';
Input.displayName = 'Input'

View File

@ -3,25 +3,21 @@
// ©AngelaMos | 2025
// ===========================
import { TEST_TYPE_LABELS, type ScanTestType } from '@/config/constants';
import './LoadingOverlay.css';
import { type ScanTestType, TEST_TYPE_LABELS } from '@/config/constants'
import './LoadingOverlay.css'
interface LoadingOverlayProps {
tests: ScanTestType[];
tests: ScanTestType[]
}
export const LoadingOverlay = ({
tests,
}: LoadingOverlayProps): React.ReactElement => {
return (
<div
className="loading-overlay"
role="dialog"
aria-label="Scan in progress"
>
<div className="loading-overlay" role="dialog" aria-label="Scan in progress">
<div className="loading-overlay__content">
<div className="loading-overlay__spinner">
<div className="spinner"></div>
<div className="spinner" />
</div>
<h2 className="loading-overlay__title">Running Security Scan</h2>
<p className="loading-overlay__subtitle">
@ -30,15 +26,12 @@ export const LoadingOverlay = ({
</p>
<div className="loading-overlay__tests">
{tests.map((test) => (
<div
key={test}
className="loading-overlay__test"
>
<div key={test} className="loading-overlay__test">
{TEST_TYPE_LABELS[test]}
</div>
))}
</div>
</div>
</div>
);
};
)
}

Some files were not shown because too many files have changed in this diff Show More