mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
368 Commits
@@ -0,0 +1,40 @@
|
||||
# Code Cleanup Command
|
||||
|
||||
## Overview
|
||||
Analyze git diff between the specified branch and HEAD (defaults to main if no branch specified) and clean up code quality issues without altering functionality.
|
||||
|
||||
## Process
|
||||
1. **Get diff**: Run `git diff <branch>..HEAD` to see changes
|
||||
2. **Identify issues**: Look for code quality problems in the diff
|
||||
3. **Clean up**: Remove only the identified issues
|
||||
4. **Verify**: Ensure no behavioral changes
|
||||
|
||||
## What to Clean Up
|
||||
- **Debug artifacts**: `console.log()`, `debugger`, `print()` statements
|
||||
- **Unused code**: Variables, imports, functions, parameters
|
||||
- **Commented code**: Dead code blocks, TODO comments (unless active)
|
||||
- **Formatting**: Inconsistent spacing, trailing whitespace
|
||||
- **Temporary code**: Test values, hardcoded strings meant to be dynamic
|
||||
- **Redundant code**: Duplicate logic, unnecessary intermediate variables
|
||||
|
||||
## What NOT to Touch
|
||||
- **Functional logic**: Don't change how features work
|
||||
- **API interfaces**: Keep method signatures intact
|
||||
- **Configuration**: Don't modify settings or constants
|
||||
- **Comments**: Keep documentation and explanatory comments
|
||||
- **Error handling**: Don't remove try-catch blocks or validation
|
||||
|
||||
## Safety Rules
|
||||
- ✅ Only modify code that appears in the git diff
|
||||
- ✅ Preserve all existing functionality
|
||||
- ✅ Maintain code readability and structure
|
||||
- ❌ Don't refactor or optimize beyond cleanup
|
||||
- ❌ Don't add new features or improvements
|
||||
- ❌ Don't change variable names or function signatures
|
||||
|
||||
## Example
|
||||
```bash
|
||||
# If user specifies: "/clean-up main"
|
||||
git diff main
|
||||
# Clean only the issues found in this diff
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
Analyze my codebase and think how it could be better organized, like a better folder or code structure.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Security Audit Command
|
||||
|
||||
Perform a comprehensive security audit of the FromChat **Android application** only.
|
||||
|
||||
## Project Context
|
||||
|
||||
**FromChat Android** is a 100% open source secure messaging mobile application built with:
|
||||
|
||||
- Kotlin Multiplatform (KMP) shared code
|
||||
- Jetpack Compose UI framework
|
||||
- End-to-End Encryption (NaCl, AES-GCM)
|
||||
- WebSocket support for real-time features
|
||||
- LiveKit integration for calls
|
||||
- Local database storage (SQLite)
|
||||
|
||||
## Scope: Android Only
|
||||
|
||||
**OUT OF SCOPE:**
|
||||
|
||||
- Web backend (Python FastAPI, Caddy infrastructure)
|
||||
- React/TypeScript frontend
|
||||
|
||||
**IN SCOPE:**
|
||||
|
||||
- Android app code (`app/android`, `app/shared/src/androidMain`)
|
||||
- Shared cross-platform code (`app/shared/src/commonMain`)
|
||||
- Local encryption implementation (NaCl, AES-GCM)
|
||||
- Secure storage (Android Keystore, encrypted SharedPreferences)
|
||||
- WebSocket client security
|
||||
- Permission usage and handling
|
||||
- Call security (LiveKit integration)
|
||||
- Memory safety and injection attacks
|
||||
- Backend
|
||||
|
||||
## Important Design Decisions (NOT Vulnerabilities)
|
||||
|
||||
When auditing, remember these are **intentional design choices**:
|
||||
|
||||
1. **Local message caching** - Messages downloaded and stored locally (by design)
|
||||
- Messages are end-to-end encrypted at rest in local DB
|
||||
- Public DMs are not encrypted (messages are public)
|
||||
- Private DMs use NaCl encryption
|
||||
- Cache persists across app restarts for offline access
|
||||
2. **Local key storage** - Encryption keys stored on device (by design)
|
||||
- Keys protected by Android Keystore (hardware-backed when available)
|
||||
- Encrypted with device-specific secrets
|
||||
- User data never leaves device in plaintext
|
||||
- Do NOT flag key storage as critical (Keystore is production-ready)
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide a **clean, concise report** with:
|
||||
|
||||
1. **Executive Summary** - Overall rating and production readiness
|
||||
2. **Security Status** - Critical issues (if any) and recommendations
|
||||
3. **Security Strengths** - What's done well
|
||||
4. **Component Ratings** - Table format for quick reference
|
||||
5. **Architecture Review** - Data flow, encryption boundaries
|
||||
6. **Threat Analysis** - Current realistic threats (e.g., rooted device, malicious APK)
|
||||
7. **Recommendations** - Prioritized with time estimates
|
||||
8. **Conclusion** - Clear production readiness statement
|
||||
|
||||
**Keep it under 500 lines** - focus on actionable findings, not verbose explanations.
|
||||
|
||||
## Common False Positives to Avoid
|
||||
|
||||
❌ **DO NOT FLAG THESE AS ISSUES:**
|
||||
|
||||
- Local message caching (intentional for offline access)
|
||||
- Public message viewing without auth (intentional design)
|
||||
- Debuggable APK (only relevant if signed/released)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
Please analyze the recent commits in this repository and help me clean up the commit history. I want you to:
|
||||
|
||||
1. First, show me all commits between the target ref and HEAD using: `git log --oneline <base-branch>..HEAD`
|
||||
2. Identify which commits should be squashed together (like cleanup commits, small fixes, or related changes)
|
||||
3. For each group of commits you plan to squash, read their full details using: `git show <commit-hash>` to understand what changes they contain
|
||||
4. Create a git-rebase-todo.txt file with your recommended rebase plan
|
||||
5. Explain your reasoning for the squashing decisions, including what changes each squashed group contains. Be concise.
|
||||
6. Stop and ask me if i agree with your plan.
|
||||
7. Then execute the rebase using these exact commands (replace `<base-branch>` with the target branch/ref):
|
||||
```bash
|
||||
export GIT_SEQUENCE_EDITOR="cp git-rebase-todo.txt"
|
||||
git rebase -i <base-branch>
|
||||
```
|
||||
8. If there are conflicts, stop and ask me to fix them.
|
||||
9. Delete the `git-rebase-todo.txt` file you created.s
|
||||
|
||||
**Usage:** You can specify a target branch/ref as an argument. If no argument is provided, stop and ask me for the branch or ref to rebase onto.
|
||||
|
||||
Focus on:
|
||||
- Squashing small cleanup commits into their related feature commits
|
||||
- Combining related bug fixes
|
||||
- Keeping meaningful feature commits separate
|
||||
- Maintaining a clean, logical commit history
|
||||
|
||||
Please be conservative - if you're unsure about squashing something, ask me for clarification.
|
||||
@@ -0,0 +1 @@
|
||||
Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mdui": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mdui/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When using the browser, use this information to work better:
|
||||
|
||||
## Login credentials
|
||||
|
||||
Username: test
|
||||
Password: 11111
|
||||
|
||||
## Server URL
|
||||
|
||||
http://localhost:8301
|
||||
|
||||
## Rules
|
||||
- Do NOT start the dev server yourself, it's started automatically.
|
||||
If the URL doesn't work, stop and ask me to turn on the dev server.
|
||||
- Don't wait, you are slow enough to keep up with the browser.
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
description: Documentation rules
|
||||
alwaysApply: false
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When documenting this project, follow these rules:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When working with this project, follow these rules:
|
||||
|
||||
## Core Behavior
|
||||
- NEVER do anything i didn't ask you for!
|
||||
- Don't talk like a robot. Behave more like a human.
|
||||
- Be concise and direct in responses.
|
||||
- If you're unsure about something, ask for clarification instead of guessing.
|
||||
|
||||
## Code Quality & Principles
|
||||
- Follow DRY, SOLID, YAGNI and KISS principles.
|
||||
- Do NOT use old, outdated or deprecated APIs and functions.
|
||||
- Use double quotes ("") for strings consistently.
|
||||
- Prefer functional components over class components in React.
|
||||
- Use TypeScript strictly - avoid `any` types unless absolutely necessary.
|
||||
- DO NOT leave placeholders - ask me when it would be better or implement it fully.
|
||||
|
||||
## File Operations
|
||||
- If possible, try to update files in a single edit when making multiple changes.
|
||||
- Do NOT "cd" to the project directory.
|
||||
- NEVER edit/delete/regenerate .env files without explicit permission.
|
||||
- Don't use rg, it doesn't work anyway.
|
||||
|
||||
## Testing & Validation
|
||||
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||
need to typecheck or build the app, in that case:
|
||||
- To typecheck, run `npm run frontend:typecheck`.
|
||||
- To build, run `npm run frontend:build`.
|
||||
- Do NOT execute other commands like "cd".
|
||||
- If the typecheck passed, there's no need for checking the linter errors.
|
||||
|
||||
## Async Operations
|
||||
- When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async, make it async.
|
||||
|
||||
## Database
|
||||
- NEVER create database migrations, they are auto-generated.
|
||||
|
||||
## Project Structure Awareness
|
||||
- This is a React/TypeScript browser frontend (desktop is Compose Multiplatform in the Android/KMP repo)
|
||||
- Uses MDUI components for UI
|
||||
- Uses Zustand for state management
|
||||
- Uses use-immer for immutable state updates
|
||||
- Uses React Router for navigation
|
||||
- Has WebSocket support for real-time features
|
||||
- Uses encryption (tweetnacl) for security
|
||||
|
||||
## Performance & Efficiency
|
||||
- Batch tool calls when possible to reduce latency
|
||||
- Use semantic search before grep when looking for concepts
|
||||
- Use TODOs for complex multi-step tasks to track progress
|
||||
|
||||
## Styling
|
||||
- Use SCSS modules
|
||||
- Use nested styles
|
||||
- Put SCSS into one folder per page
|
||||
|
||||
## Animations with Framer Motion
|
||||
- Don't use variants if they are used only once
|
||||
|
||||
## Debug Mode
|
||||
- When in debug mode and the issue is not yet fixed, ALWAYS end responses with `<reproduction_steps>` containing the steps to reproduce the issue and trigger logging
|
||||
- When NOT in debug mode or when the issue IS fixed, escape the tag as `<reproduction_steps>` to avoid triggering it
|
||||
- Never use other `<re>` tags, only `<reproduction_steps>`
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Do not filter cache, API, or DB fields by matching fixed UI/placeholder English strings
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# No magic-string “sanitization” of user or message data
|
||||
|
||||
- **Never** strip, null out, or rewrite stored or displayed values by comparing them to hard-coded UI strings (e.g. `"Direct messages"`, `"Direct message"`, `"User 123"`, etc.). Those strings can be legitimate **usernames, display names, or message text**.
|
||||
- **Prefer**: fix the source (don’t persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
|
||||
- Applies especially to: local storage/cache layers, list previews, and any code that “cleans” strings before show or read.
|
||||
@@ -1,8 +1,13 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When you work with UI:
|
||||
|
||||
1. Use MDUI components as HTML elements
|
||||
2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML.
|
||||
1. Use MDUI components through the wrapper: `@/utils/material`. If the component you want to use is missing in that wrapper,
|
||||
add it. Do NOT remove anything.
|
||||
3. The supporting text slot for MDUI lists is "description".
|
||||
4. When working with lists/sets in states, use the "useImmer" hook.
|
||||
5. Do NOT use inline styles in React components if they are static, instead write them in CSS.
|
||||
Find the appropriate file to put the styles in, or create a new one.
|
||||
6. In SCSS, for Material Design colors use `$color-dark-<color-name>` variables. For all colors, refer
|
||||
to `frontend/src/css/_material.scss`.
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: decrypt
|
||||
description: Decrypts a DM message on localhost by granting temporary compliance extract access, running extract and decrypt CLI commands, then reverting all temporary changes. Use when the user asks to decrypt a message or run the compliance decryption workflow on localhost.
|
||||
---
|
||||
|
||||
# Decrypt (local debug)
|
||||
|
||||
End-to-end workflow for extracting and decrypting one message on **localhost**. All temporary backend access must be removed when finished.
|
||||
|
||||
**After decrypt:** what to do with the plaintext and files (compare, inspect, report, etc.) comes from the **current conversation** — not from this skill.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Web dev server running at `http://localhost:8301` (browser rules default).
|
||||
- `compliance_keypair.txt` at the Web repo root (`Web/compliance_keypair.txt`).
|
||||
- Python deps for `scripts/compliance/decryption/` (`cryptography`, etc.).
|
||||
|
||||
## Step 0 — Read the compliance keypair
|
||||
|
||||
Read `Web/compliance_keypair.txt` before any decrypt step. Confirm the **private** key line exists (line after `PRIVATE KEY` header, base64).
|
||||
|
||||
The decrypt CLI loads `compliance_keypair.txt` from the **current working directory**. Always run extract/decrypt with `cd` to the Web repo root:
|
||||
|
||||
```bash
|
||||
cd /path/to/Web
|
||||
```
|
||||
|
||||
If `load_compliance_private_key` fails, the file format may need a `PRIVATE_KEY=<base64>` line (the loader expects that or a 43-char base64 line; 32-byte keys are often 44 chars with padding).
|
||||
|
||||
## Step 1 — Create a temporary user and capture token
|
||||
|
||||
Generate random credentials (username 3–20 chars: letters, digits, `-`, `_`; password 5–50 chars, no spaces):
|
||||
|
||||
```bash
|
||||
DEBUG_USER="decryptdbg$(openssl rand -hex 3)"
|
||||
DEBUG_PASS="$(openssl rand -base64 12 | tr -d '/+=' | head -c 16)"
|
||||
echo "user=$DEBUG_USER pass=$DEBUG_PASS"
|
||||
```
|
||||
|
||||
Register and save **`token`** from the JSON response (not the derived login secret):
|
||||
|
||||
```bash
|
||||
REGISTER_JSON=$(curl -sS -X POST "http://localhost:8301/api/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${DEBUG_USER}\",\"display_name\":\"DecryptDebug\",\"password\":\"${DEBUG_PASS}\",\"confirm_password\":\"${DEBUG_PASS}\"}")
|
||||
echo "$REGISTER_JSON"
|
||||
DEBUG_TOKEN=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' <<< "$REGISTER_JSON")
|
||||
echo "token saved (length ${#DEBUG_TOKEN})"
|
||||
```
|
||||
|
||||
Alternatively export for the CLI: `export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"`
|
||||
|
||||
Save `user.id` from the same JSON if needed for debugging.
|
||||
|
||||
## Step 2 — Temporary compliance extract permission (marker blocks)
|
||||
|
||||
`GET /api/dm/compliance/extract/{message_id}` is restricted to **user id 1** in:
|
||||
|
||||
`backend/services/main/routes/envelope_messaging.py` (function `extract_message_for_compliance`).
|
||||
|
||||
Add a **temporary** bypass between searchable markers (replace `DEBUG_USERNAME` with the user from step 1):
|
||||
|
||||
```python
|
||||
# TEMP_COMPLIANCE_DEBUG_START — remove after decrypt debug run
|
||||
_TEMP_COMPLIANCE_DEBUG_USERNAMES = {"DEBUG_USERNAME"}
|
||||
# TEMP_COMPLIANCE_DEBUG_END
|
||||
|
||||
# Security check: only user ID 1 can access this
|
||||
if current_user.id != 1 and current_user.username not in _TEMP_COMPLIANCE_DEBUG_USERNAMES:
|
||||
```
|
||||
|
||||
Backend will auto-reload after this change, you don't need to do anything.
|
||||
|
||||
To remove later: search the repo for `TEMP_COMPLIANCE_DEBUG` and delete the marker block + revert the `if` to only `current_user.id != 1`.
|
||||
|
||||
## Step 3 — Extract bundle (online)
|
||||
|
||||
From Web repo root, set variables and run (user supplies `MESSAGE_ID`):
|
||||
|
||||
```bash
|
||||
MESSAGE_ID=<message_id>
|
||||
RANDOM_FOLDER="run_$(openssl rand -hex 4)"
|
||||
BUNDLE_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}"
|
||||
DECRYPT_DIR="/tmp/debug_decrypt/${RANDOM_FOLDER}_dec"
|
||||
mkdir -p /tmp/debug_decrypt
|
||||
|
||||
cd /path/to/Web
|
||||
python scripts/compliance/decryption/main.py extract \
|
||||
--server localhost:8301 \
|
||||
--http \
|
||||
--token "${DEBUG_TOKEN}" \
|
||||
--message-ids "${MESSAGE_ID}" \
|
||||
--out-dir "${BUNDLE_DIR}"
|
||||
```
|
||||
|
||||
Equivalent using env (no `--token` flag):
|
||||
|
||||
```bash
|
||||
export FROMCHAT_API_TOKEN="${DEBUG_TOKEN}"
|
||||
python scripts/compliance/decryption/main.py extract \
|
||||
--server localhost:8301 \
|
||||
--http \
|
||||
--message-ids "${MESSAGE_ID}" \
|
||||
--out-dir "${BUNDLE_DIR}"
|
||||
```
|
||||
|
||||
Confirm `${BUNDLE_DIR}/bundle.json` exists.
|
||||
|
||||
**Auth flags:** use `--token` (Bearer from register/login), or `--username` + `--password` (CLI derives the login secret). Do not pass both.
|
||||
|
||||
## Step 4 — Decrypt bundle (offline)
|
||||
|
||||
Still from Web repo root (`compliance_keypair.txt` must resolve):
|
||||
|
||||
```bash
|
||||
python scripts/compliance/decryption/main.py decrypt \
|
||||
--bundle-dir "${BUNDLE_DIR}" \
|
||||
--output-dir "${DECRYPT_DIR}"
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
- `${DECRYPT_DIR}/messages/<message_id>/message.decrypted.txt` — message plaintext
|
||||
- `${DECRYPT_DIR}/messages/<message_id>/files/` — decrypted attachments
|
||||
- `${DECRYPT_DIR}/index.html` — HTML report
|
||||
|
||||
## Step 5 — Use decrypted output (conversation-driven)
|
||||
|
||||
Follow the **user’s request in the current chat** for what to do next (e.g. compare hashes, inspect text, verify a specific attachment). This skill stops at producing `${DECRYPT_DIR}`; do not assume a fixed post-decrypt task.
|
||||
|
||||
## Step 6 — Cleanup (required)
|
||||
|
||||
1. **Remove decrypt dirs:**
|
||||
```bash
|
||||
rm -rf "${BUNDLE_DIR}" "${DECRYPT_DIR}"
|
||||
```
|
||||
|
||||
2. **Delete temp user** (local SQLite default DB):
|
||||
```bash
|
||||
sqlite3 backend/data/database.db "DELETE FROM users WHERE username='${DEBUG_USER}';"
|
||||
```
|
||||
If your deployment uses another DB, delete the same username there.
|
||||
|
||||
3. **Remove temporary permission:** search `TEMP_COMPLIANCE_DEBUG` in the repo, delete the marker block, restore the original `if current_user.id != 1:` check, restart backend.
|
||||
|
||||
4. Do **not** commit `compliance_keypair.txt` or any decrypt output.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Read compliance_keypair.txt
|
||||
- [ ] Register DEBUG_USER; save DEBUG_TOKEN from response
|
||||
- [ ] Add TEMP_COMPLIANCE_DEBUG_* bypass; restart backend
|
||||
- [ ] extract --token → bundle.json present
|
||||
- [ ] decrypt → output under DECRYPT_DIR
|
||||
- [ ] Post-decrypt work per conversation context
|
||||
- [ ] rm -rf BUNDLE_DIR and DECRYPT_DIR
|
||||
- [ ] DELETE temp user from DB
|
||||
- [ ] Remove TEMP_COMPLIANCE_DEBUG markers; restart backend
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
- Extract API: `GET /api/dm/compliance/extract/{message_id}` (see `bundle_extract.py`).
|
||||
- Decrypt implementation: `scripts/compliance/decryption/bundle_decrypt.py`, `crypto.py`.
|
||||
- Extract auth: `--token` (Bearer), env `FROMCHAT_API_TOKEN` / `FROMCHAT_TOKEN`, or `--username` + plain `--password` (CLI calls `derive_auth_secret` for login only).
|
||||
|
||||
## Rules
|
||||
- NEVER use sleep in any command.
|
||||
- Set a timeout on EVERY command that may request user input.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Shared build context ignore (used by deployment images with context: ..)
|
||||
|
||||
# VCS / editor
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.DS_Store
|
||||
|
||||
# Secrets / local env
|
||||
.env
|
||||
deployment/.env
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.vite
|
||||
dist
|
||||
build
|
||||
out
|
||||
coverage
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ipynb_checkpoints
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# App runtime data/logs (mounted, not baked)
|
||||
backend/data
|
||||
backend/files
|
||||
data
|
||||
logs
|
||||
**/logs
|
||||
**/logs/**
|
||||
*.log
|
||||
|
||||
# Deploy cache (hashes)
|
||||
.deploy-cache
|
||||
|
||||
# Firebase cert: bind-mounted at runtime; do not send to docker build context
|
||||
firebase-cert.json
|
||||
@@ -0,0 +1,3 @@
|
||||
# HTTP API host.
|
||||
# Example for local backend: http://localhost:8300
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
@@ -1,97 +0,0 @@
|
||||
name: Build Electron Apps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["main", "electron"]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'frontend/electron/**'
|
||||
- '.github/workflows/build.yml'
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force npm to use Bash
|
||||
if: runner.os == 'Windows'
|
||||
run: npm config set script-shell "C:\Program Files\Git\bin\bash.exe"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache npm (Linux/macOS)
|
||||
if: runner.os != 'Windows'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-node-24-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
npm-${{ runner.os }}-node-24-
|
||||
|
||||
- name: Cache npm (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~\AppData\Local\npm-cache
|
||||
key: npm-${{ runner.os }}-node-24-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
npm-${{ runner.os }}-node-24-
|
||||
|
||||
- name: Install root deps (no scripts)
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Install Electron Forge deps
|
||||
run: npm run frontend:electron:dependencies
|
||||
|
||||
- name: Cache Electron downloads (Linux/macOS)
|
||||
if: runner.os != 'Windows'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
key: electron-${{ runner.os }}-${{ hashFiles('package.json', 'frontend/electron/forge/package.json') }}
|
||||
restore-keys: |
|
||||
electron-${{ runner.os }}-
|
||||
|
||||
- name: Cache Electron downloads (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-${{ runner.os }}-${{ hashFiles('package.json', 'frontend/electron/forge/package.json') }}
|
||||
restore-keys: |
|
||||
electron-${{ runner.os }}-
|
||||
|
||||
- name: Build Electron app
|
||||
shell: bash
|
||||
run: npm run build:electron
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fromchat-${{ runner.os }}
|
||||
path: |
|
||||
frontend/electron/forge/out/**
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Simple workflow for deploying to the self-hosted server
|
||||
name: Deploy to server
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
env:
|
||||
HOME: "/root"
|
||||
environment:
|
||||
name: production
|
||||
url: https://fromchat.toolbox-io.ru
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: |
|
||||
mkdir -p deployment
|
||||
touch deployment/.env
|
||||
cat > deployment/.env << EOF
|
||||
JWT_SECRET=${{ secrets.JWT_SECRET }}
|
||||
EOF
|
||||
- name: Build container
|
||||
run: |
|
||||
cd deployment
|
||||
docker compose build
|
||||
- name: Set up the service
|
||||
run: |
|
||||
cp -f deployment/fromchat.service /etc/systemd/system/fromchat.service
|
||||
systemctl daemon-reload
|
||||
- name: Start the server
|
||||
run: |
|
||||
if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then
|
||||
journalctl --no-pager -xeu fromchat
|
||||
exit 1
|
||||
fi
|
||||
+225
-5
@@ -1,6 +1,6 @@
|
||||
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,macos,node,osx
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,macos,node,osx
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,osx,node,macos,react,reactnative
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,osx,node,macos,react,reactnative
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
@@ -35,6 +35,9 @@ Temporary Items
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### FromChat local tools (downloaded LiveKit server binary) ###
|
||||
.tools/
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
@@ -112,11 +115,18 @@ web_modules/
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.prod
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# Firebase service account JSON (backend/firebase-cert.json; bind-mounted in docker-compose)
|
||||
firebase-cert.json
|
||||
**/firebase-cert.json
|
||||
firebase-adminsdk.json
|
||||
**/firebase-adminsdk.json
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
@@ -203,6 +213,8 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -353,6 +365,194 @@ poetry.toml
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### react ###
|
||||
.DS_*
|
||||
**/*.backup.*
|
||||
**/*.back.*
|
||||
|
||||
node_modules
|
||||
|
||||
*.sublime*
|
||||
|
||||
psd
|
||||
thumb
|
||||
sketch
|
||||
|
||||
### ReactNative ###
|
||||
# React Native Stack Base
|
||||
|
||||
.expo
|
||||
__generated__
|
||||
|
||||
### ReactNative.macOS Stack ###
|
||||
# General
|
||||
|
||||
# Icon must end with two \r
|
||||
|
||||
# Thumbnails
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
|
||||
### ReactNative.Android Stack ###
|
||||
# Gradle files
|
||||
.gradle/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
### ReactNative.Gradle Stack ###
|
||||
.gradle
|
||||
**/build/
|
||||
!src/**/build/
|
||||
|
||||
# Ignore Gradle GUI config
|
||||
gradle-app.setting
|
||||
|
||||
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
||||
!gradle-wrapper.jar
|
||||
|
||||
# Avoid ignore Gradle wrappper properties
|
||||
!gradle-wrapper.properties
|
||||
|
||||
# Cache of project
|
||||
.gradletasknamecache
|
||||
|
||||
# Eclipse Gradle plugin generated files
|
||||
# Eclipse Core
|
||||
.project
|
||||
# JDT-specific (Eclipse Java Development Tools)
|
||||
.classpath
|
||||
|
||||
### ReactNative.Xcode Stack ###
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Xcode 8 and earlier
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
|
||||
### ReactNative.Linux Stack ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### ReactNative.Node Stack ###
|
||||
# Logs
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
# Runtime data
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
# Dependency directories
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
# Next.js build output
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
# Gatsby files
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
# Serverless directories
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
# TernJS port file
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
# yarn v2
|
||||
|
||||
### ReactNative.Buck Stack ###
|
||||
buck-out/
|
||||
.buckconfig.local
|
||||
.buckd/
|
||||
.buckversion
|
||||
.fakebuckversion
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
@@ -372,12 +572,32 @@ pyrightconfig.json
|
||||
.history
|
||||
.ionide
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,macos,node,osx
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,osx,node,macos,react,reactnative
|
||||
|
||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||
|
||||
data
|
||||
backend/services/main/.fromchat_instance_id
|
||||
backend/data
|
||||
backend/files
|
||||
.vite
|
||||
*.db
|
||||
package-lock.json
|
||||
dist-electron
|
||||
backend/alembic/**
|
||||
!backend/alembic/env.py
|
||||
!backend/alembic/script.py.mako
|
||||
!frontend/src/css/lib
|
||||
**/*.module.scss.d.ts
|
||||
|
||||
.cursor/plans
|
||||
tmp
|
||||
compliance_keypair.txt
|
||||
backend/files
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
.deploy-cache
|
||||
|
||||
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
|
||||
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
|
||||
|
||||
*.module.d.scss.ts
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/bin/sh
|
||||
# Post-push hook: opens deploy command in system's native terminal
|
||||
# Cross-platform support: macOS, Linux, Windows, WSL
|
||||
|
||||
# Get the project root directory
|
||||
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$PROJECT_ROOT" || exit 1
|
||||
|
||||
# Command to run in terminal (deploy.sh will load .env from project root)
|
||||
COMMAND="npm run -s deploy"
|
||||
|
||||
# Detect OS and open appropriate terminal
|
||||
detect_and_open_terminal() {
|
||||
# Detect WSL
|
||||
if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -f /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
# WSL detected - try to open Windows Terminal, fallback to Linux terminals
|
||||
if command -v wt.exe >/dev/null 2>&1; then
|
||||
# Windows Terminal (preferred for WSL)
|
||||
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||
wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v wsl.exe >/dev/null 2>&1; then
|
||||
# Fallback: use wsl.exe to open cmd
|
||||
WINDOWS_PATH=$(wslpath -w "$PROJECT_ROOT" 2>/dev/null || echo "$PROJECT_ROOT")
|
||||
cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\""
|
||||
else
|
||||
# Fallback to Linux terminal
|
||||
open_linux_terminal
|
||||
fi
|
||||
# macOS
|
||||
elif [ "$(uname)" = "Darwin" ]; then
|
||||
# macOS - use .command file with open command
|
||||
# Clean up old script files and create a new one
|
||||
rm -f /tmp/post-push-deploy-*.command 2>/dev/null
|
||||
SCRIPT_FILE=$(mktemp /tmp/post-push-deploy-XXXXXX.command 2>/dev/null)
|
||||
if [ -z "$SCRIPT_FILE" ] || [ ! -f "$SCRIPT_FILE" ]; then
|
||||
# Fallback if mktemp fails
|
||||
SCRIPT_FILE="/tmp/post-push-deploy-$$.command"
|
||||
fi
|
||||
{
|
||||
echo "#!/bin/bash"
|
||||
echo "clear"
|
||||
echo "cd '$PROJECT_ROOT'"
|
||||
echo "export PS1=''"
|
||||
echo "set +x"
|
||||
# Export DEPLOYMENT_SERVER if it was set in the hook environment
|
||||
if [ -n "$DEPLOYMENT_SERVER_VALUE" ]; then
|
||||
echo "export DEPLOYMENT_SERVER='$DEPLOYMENT_SERVER_VALUE'"
|
||||
fi
|
||||
echo "$COMMAND"
|
||||
echo "echo ''"
|
||||
echo "echo 'Press Enter to close...'"
|
||||
echo "read -r"
|
||||
echo "osascript -e 'tell application \"Terminal\" to close front window' &"
|
||||
} > "$SCRIPT_FILE"
|
||||
chmod +x "$SCRIPT_FILE"
|
||||
# Use open command to launch .command file - opens only one Terminal window
|
||||
open "$SCRIPT_FILE"
|
||||
# Windows (Git Bash or similar)
|
||||
elif [ -n "${MSYSTEM:-}" ] || [ -n "${MINGW64:-}" ] || [ -n "${MINGW32:-}" ]; then
|
||||
# Git Bash on Windows
|
||||
if command -v wt.exe >/dev/null 2>&1; then
|
||||
# Windows Terminal
|
||||
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||
wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v cmd.exe >/dev/null 2>&1; then
|
||||
# Command Prompt - convert path to Windows format
|
||||
WINDOWS_PATH=$(echo "$PROJECT_ROOT" | sed 's|^/\([a-z]\)|\1:|' | sed 's|/|\\|g' | sed 's|\\|\\\\|g')
|
||||
cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\""
|
||||
else
|
||||
# Fallback
|
||||
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||
start "Deploy" bash -c "cd '$ESCAPED_PATH' && $COMMAND; exec bash"
|
||||
fi
|
||||
# Linux
|
||||
else
|
||||
open_linux_terminal
|
||||
fi
|
||||
}
|
||||
|
||||
open_linux_terminal() {
|
||||
# Escape path for use in shell commands
|
||||
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||
|
||||
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||
# Try different Linux terminal emulators
|
||||
if command -v gnome-terminal >/dev/null 2>&1; then
|
||||
gnome-terminal -- bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v x-terminal-emulator >/dev/null 2>&1; then
|
||||
x-terminal-emulator -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v konsole >/dev/null 2>&1; then
|
||||
konsole -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v xterm >/dev/null 2>&1; then
|
||||
xterm -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v alacritty >/dev/null 2>&1; then
|
||||
alacritty -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v kitty >/dev/null 2>&1; then
|
||||
kitty bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v tilix >/dev/null 2>&1; then
|
||||
tilix -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
else
|
||||
# Last resort: try to find any terminal
|
||||
TERMINAL=$(command -v x-terminal-emulator gnome-terminal konsole xterm alacritty kitty tilix 2>/dev/null | head -1)
|
||||
if [ -n "$TERMINAL" ]; then
|
||||
"$TERMINAL" -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
else
|
||||
echo "Could not find a terminal emulator. Please run manually: cd '$PROJECT_ROOT' && $COMMAND"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Run in background so git push doesn't wait
|
||||
# Add a small delay to ensure git push completes first
|
||||
(sleep 0.5 && detect_and_open_terminal) &
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
npm run frontend:typecheck
|
||||
Vendored
+16
-2
@@ -1,6 +1,20 @@
|
||||
{
|
||||
"npm.autoDetect": "off",
|
||||
"files.exclude": {
|
||||
".husky": true,
|
||||
"build": true
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true,
|
||||
"**/package-lock.json": true
|
||||
}
|
||||
"**/package-lock.json": true,
|
||||
"**/*.module.scss.d.ts": true,
|
||||
"**/.husky/_": true,
|
||||
"**/.venv": true,
|
||||
"**/node_modules": true
|
||||
},
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.terminal.activateEnvironment": false
|
||||
}
|
||||
Vendored
+10
-12
@@ -2,9 +2,9 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Run",
|
||||
"label": "Frontend (Web)",
|
||||
"type": "shell",
|
||||
"command": "npm run dev",
|
||||
"command": "npm run frontend:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
@@ -16,24 +16,22 @@
|
||||
},
|
||||
"group": {
|
||||
"kind": "build"
|
||||
}
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "Run (Electron)",
|
||||
"type": "shell",
|
||||
"command": "npm run dev:electron",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
"label": "Web",
|
||||
"dependsOn": ["Frontend (Web)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build"
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# 1. Frontend production build
|
||||
FROM node:24 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 1.1. Install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Copy sources and configure
|
||||
COPY . .
|
||||
|
||||
WORKDIR /app
|
||||
ARG NODE_ENV=production
|
||||
# Overridable at build time. Must be a real http(s) URL — never a compose ${...} stub.
|
||||
ARG VITE_API_BASE_URL=https://api.fromchat.ru
|
||||
|
||||
# Fail the build if the arg is not a real URL (e.g. unexpanded Compose ${...}).
|
||||
RUN if ! printf "%s" "${VITE_API_BASE_URL}" | grep -Eq '^https?://'; then \
|
||||
echo "ERROR: VITE_API_BASE_URL must be an http(s) URL, got: ${VITE_API_BASE_URL}" >&2; \
|
||||
exit 1; \
|
||||
fi && \
|
||||
export NODE_ENV=production VITE_API_BASE_URL && \
|
||||
npm run frontend:build
|
||||
|
||||
|
||||
# 2. Production web static server
|
||||
FROM joseluisq/static-web-server:latest AS production
|
||||
|
||||
# 2.1. Copy files to server root (/var/public -> /home/sws/public)
|
||||
COPY --from=builder /app/build/normal/dist /var/public
|
||||
|
||||
# 2.2. Configure (defaults serve the image's built-in landing page instead of our app)
|
||||
ENV SERVER_ROOT=/var/public
|
||||
ENV SERVER_FALLBACK_PAGE=/var/public/index.html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,5 +1,5 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# FromChat Web Client — Messaging Web App
|
||||
|
||||
[Читать на других языках: Русский](./README.md)
|
||||
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/fromchat-messenger/android/main/app/android/src/main/ic_launcher-playstore.png" width="120" alt="FromChat Logo" />
|
||||
|
||||
**Web client for FromChat messenger**
|
||||
|
||||
[🌐 Web Client](https://github.com/fromchat-messenger/web) • [🖥️ Backend](https://github.com/fromchat-messenger/backend) • [📱 Android / Desktop](https://github.com/fromchat-messenger/android) • [🌍 Website](https://github.com/fromchat-messenger/site)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description
|
||||
|
||||
FromChat Web is a React/TypeScript browser client for the FromChat server. The desktop client is Compose Multiplatform in the [Android/KMP repository](https://github.com/fromchat-messenger/android).
|
||||
|
||||
**Note:** Landing pages and legal documents live in [fromchat-messenger/site](https://github.com/fromchat-messenger/site).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Client Comparison
|
||||
|
||||
| Feature | Android | Web | iOS |
|
||||
|---|---|---|---|
|
||||
| **Messaging & profiles** | ✅ | ✅ | ❌ |
|
||||
| **Voice/video calls** | ✅ | ✅ | ❌ |
|
||||
| **Screen sharing** | ✅ | ✅ | ❌ |
|
||||
| **Message reactions** | ❌ | ✅ | ❌ |
|
||||
| **Rich attachment support** | ✅ | ❌ | ❌ |
|
||||
|
||||
⚠️ **iOS is temporarily not supported.**
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Protected DMs (legal encryption scheme)
|
||||
- Voice/video calls and screen sharing
|
||||
- Message reactions
|
||||
- Public chats and profiles
|
||||
- Device management
|
||||
- WebSocket real-time updates
|
||||
- Dark mode
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
| Component | Notes |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | strict typing |
|
||||
| Vite 7 | dev server & build |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | state |
|
||||
| Motion | animations |
|
||||
| TweetNaCl.js | cryptography |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 20+ (Docker image uses Node 24)
|
||||
- npm
|
||||
- Backend API on `http://localhost:8300` (proxied as `/api`)
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fromchat-messenger/web.git
|
||||
cd web
|
||||
npm install
|
||||
cp .env.example .env # if needed; install may copy it for you
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
Open `http://localhost:8301`.
|
||||
|
||||
`.env`:
|
||||
|
||||
```env
|
||||
# HTTP API host (Vite proxy target for /api)
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
```
|
||||
|
||||
In the browser the client uses same-origin `/api` (HTTP and WebSocket, e.g. `/api/chat/ws`).
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Vite on :8301
|
||||
npm run frontend:typecheck # TypeScript
|
||||
npm run frontend:build # typecheck + production build → build/normal
|
||||
npm run frontend:preview # preview built frontend
|
||||
```
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React app (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ └── protocol/ # shared protocol (@fromchat/protocol)
|
||||
├── plugins/ # Vite plugins
|
||||
├── vite.config.ts
|
||||
├── compose.yml # production web image (:8301→80)
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
docker build -t fromchat-web:latest .
|
||||
# or via compose:
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
Container listens on port **8301** (static server on 80 inside).
|
||||
|
||||
Production edge (Caddy/HAProxy) is configured via the deployment repo / backend `compose.prod.yml`.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Branch for your change
|
||||
2. Open a PR with a description
|
||||
3. Ensure `npm run frontend:typecheck` passes
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0 — see [LICENSE](./LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Repositories
|
||||
|
||||
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||
- [Android / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: How do I run locally?**
|
||||
A: Start the backend on `:8300`, then `npm run frontend:dev` and open `http://localhost:8301`.
|
||||
|
||||
**Q: Which browsers?**
|
||||
A: Current Chrome, Firefox, Safari, Edge.
|
||||
|
||||
**Q: Do calls work on web?**
|
||||
A: Yes — voice/video and screen share (server needs LiveKit).
|
||||
|
||||
**Q: How do I report a bug?**
|
||||
A: GitHub Issues with reproduction steps.
|
||||
|
||||
---
|
||||
|
||||
**[⬆ back to top](#fromchat-web-client--messaging-web-app)**
|
||||
@@ -1,30 +1,112 @@
|
||||
# FromChat
|
||||
# FromChat Web Client — веб-приложение для обмена сообщениями
|
||||
|
||||
FromChat - полностью открытый мессенджер.
|
||||
[Read in other languages: English](./README.en.md)
|
||||
|
||||
Демо версию можно попробовать на [сайте](http://95.165.0.162:8301).
|
||||
_Написано ИИ. Могут быть ошибки._
|
||||
|
||||
## Содержание:
|
||||
- [Основные моменты](#highlights)
|
||||
- [Использование](#usage)
|
||||
- [Часто задаваемые вопросы](#faq)
|
||||
- [Внос вклада](#contributing)
|
||||
## 📝 Описание
|
||||
|
||||
## Основные моменты
|
||||
- Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд).
|
||||
- 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя.
|
||||
Веб-клиент FromChat — React/TypeScript приложение для браузера. Десктоп-клиент — Compose Multiplatform в [Android/KMP репозитории](https://github.com/fromchat-messenger/android).
|
||||
|
||||
## Использование
|
||||
_В разработке._
|
||||
## Развернуть в 1 клик
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
<!--
|
||||
Вопрос: __Чему равно 2+2?__
|
||||
Ответ: __4__
|
||||
Вопрос: __Какая цитата Джейсона Стетхема на ваш взгляд является лучшей?__
|
||||
Ответ: __"Одна ошибка, и ты ошибся."__
|
||||
-->
|
||||
_В разработке._
|
||||
```bash
|
||||
docker run -d --restart always -p 8301:80 fromchat/web:latest
|
||||
```
|
||||
|
||||
## Внос вклада
|
||||
Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev).
|
||||
## ✨ Возможности
|
||||
|
||||
- Защищённые личные сообщения (легальная схема шифрования)
|
||||
- Голосовые/видеозвонки и демонстрация экрана
|
||||
- Реакции на сообщения
|
||||
- Публичные чаты и профили
|
||||
- Управление устройствами
|
||||
- WebSocket для реал-тайма
|
||||
- Тёмный режим
|
||||
|
||||
## 🏗️ Технологический стек
|
||||
|
||||
| Компонент | Примечание |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | строгая типизация |
|
||||
| Vite 7 | dev-сервер и сборка |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | состояние |
|
||||
| Motion | анимации |
|
||||
| TweetNaCl.js | криптография |
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Требования
|
||||
|
||||
- Node.js 20+ (для Docker-образа используется Node 24)
|
||||
- npm
|
||||
- Backend API на `http://localhost:8300` (проксируется через `/api`)
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fromchat-messenger/web.git
|
||||
cd web
|
||||
npm install
|
||||
cp .env.example .env # при необходимости; install может скопировать сам
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:8301`.
|
||||
|
||||
### Команды
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Vite на :8301
|
||||
npm run frontend:typecheck # TypeScript
|
||||
npm run frontend:build # typecheck + production build → build/normal
|
||||
npm run frontend:preview # preview собранного фронта
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React-приложение (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ └── protocol/ # общий протокол (@fromchat/protocol)
|
||||
├── plugins/ # Vite-плагины
|
||||
├── vite.config.ts
|
||||
├── compose.yml # production-образ веба (:8301→80)
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Контейнер слушает порт **8301** (внутри nginx/static-server на 80).
|
||||
|
||||
## 🤝 Внесение вклада
|
||||
|
||||
1. Создайте ветку под изменение
|
||||
2. Отправьте PR с описанием
|
||||
3. Проверьте типы: `npm run frontend:typecheck`
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
GNU Affero General Public License v3.0 — см. [LICENSE](./LICENSE).
|
||||
|
||||
## 🔗 Связанные репозитории
|
||||
|
||||
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||
- [Android / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from routes import account, messaging, profile
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(title="PixelChat")
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # В продакшене замените на нужные домены
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routes
|
||||
app.include_router(account.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
@@ -1,11 +0,0 @@
|
||||
import os
|
||||
|
||||
|
||||
DATABASE_URL = "sqlite:///./data/database.db"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
OWNER_USERNAME = "denis0001-dev"
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
|
||||
|
||||
if not JWT_SECRET_KEY:
|
||||
raise ValueError("JWT secret key empty")
|
||||
@@ -1,10 +0,0 @@
|
||||
import os
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
@@ -1,38 +0,0 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from utils import *
|
||||
from models import *
|
||||
from db import SessionLocal
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# Зависимость для получения сессии БД
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Зависимость для получения текущего пользователя
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
@@ -1,7 +0,0 @@
|
||||
from constants import *
|
||||
from db import *
|
||||
from models import *
|
||||
from validation import *
|
||||
from utils import *
|
||||
from dependencies import *
|
||||
from app import *
|
||||
@@ -1,101 +0,0 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from db import engine
|
||||
from pydantic import BaseModel
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Модели базы данных
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(200), nullable=False)
|
||||
profile_picture = Column(String(255), nullable=True)
|
||||
bio = Column(Text, nullable=True)
|
||||
online = Column(Boolean, default=False)
|
||||
last_seen = Column(DateTime, default=datetime.now)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
messages = relationship("Message", back_populates="author", lazy="select")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "message"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
content = Column(Text, nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
is_read = Column(Boolean, default=False)
|
||||
reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True)
|
||||
is_edited = Column(Boolean, default=False)
|
||||
|
||||
author = relationship("User", back_populates="messages")
|
||||
reply_to = relationship("Message", remote_side=[id])
|
||||
|
||||
|
||||
# Pydantic модели
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
confirm_password: str
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class EditMessageRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class ReplyMessageRequest(BaseModel):
|
||||
content: str
|
||||
reply_to_id: int
|
||||
|
||||
|
||||
class DeleteMessageRequest(BaseModel):
|
||||
message_id: int
|
||||
|
||||
|
||||
class UpdateBioRequest(BaseModel):
|
||||
bio: str
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
profile_picture: str | None
|
||||
bio: str | None
|
||||
online: bool
|
||||
last_seen: datetime
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
timestamp: datetime
|
||||
is_author: bool
|
||||
is_read: bool
|
||||
username: str
|
||||
profile_picture: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Создание таблиц
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -1,8 +0,0 @@
|
||||
PyJWT>=2.8.0
|
||||
fastapi[standard]>=0.116.1
|
||||
pydantic>=2.11.7
|
||||
sqlalchemy>=2.0.43
|
||||
bcrypt>=4.3.0
|
||||
websockets>=15.0.1
|
||||
Pillow>=10.0.0
|
||||
python-multipart>=0.0.6
|
||||
@@ -1,163 +0,0 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from routes.messaging import convert_message
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import LoginRequest, RegisterRequest, User
|
||||
from utils import create_token, get_password_hash, verify_password
|
||||
from validation import is_valid_password, is_valid_username
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def convert_user(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_seen": user.last_seen.isoformat(),
|
||||
"online": user.online,
|
||||
"username": user.username,
|
||||
"admin": user.username == OWNER_USERNAME
|
||||
}
|
||||
|
||||
@router.get("/check_auth")
|
||||
def check_auth(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"authenticated": True,
|
||||
"username": current_user.username,
|
||||
"admin": current_user.username == OWNER_USERNAME
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == request.username.strip()).first()
|
||||
|
||||
if not user or not verify_password(request.password.strip(), user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверное имя пользователя или пароль"
|
||||
)
|
||||
|
||||
user.online = True
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
token = create_token(user.id, user.username)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Login successful",
|
||||
"token": token,
|
||||
"user": convert_user(user)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||
username = request.username.strip()
|
||||
password = request.password.strip()
|
||||
confirm_password = request.confirm_password.strip()
|
||||
|
||||
# Determine if owner already exists
|
||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
||||
|
||||
# If owner not yet registered, only allow the owner to register
|
||||
if not owner_exists and username != OWNER_USERNAME:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Регистрация временно закрыта до регистрации владельца"
|
||||
)
|
||||
|
||||
# Validate input
|
||||
if not is_valid_username(username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Имя пользователя должно быть от 3 до 20 символов и не содержать пробелов"
|
||||
)
|
||||
|
||||
if not is_valid_password(password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов"
|
||||
)
|
||||
|
||||
if password != confirm_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пароли не совпадают"
|
||||
)
|
||||
|
||||
# After owner exists, disallow registering the reserved owner username via public registration
|
||||
if owner_exists and username == OWNER_USERNAME:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Это имя пользователя зарезервировано"
|
||||
)
|
||||
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Это имя пользователя уже занято"
|
||||
)
|
||||
|
||||
hashed_password = get_password_hash(password)
|
||||
new_user = User(
|
||||
username=username,
|
||||
password_hash=hashed_password,
|
||||
online=True,
|
||||
last_seen=datetime.now()
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Регистрация прошла успешно Теперь вы можете войти."
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/admin/user/{user_id}")
|
||||
def delete_user_as_owner(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Only owner can delete users
|
||||
if current_user.username != OWNER_USERNAME:
|
||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent deleting the owner account via API
|
||||
if user.username == OWNER_USERNAME:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
||||
|
||||
# Manually delete user's messages to satisfy FK constraints
|
||||
from models import Message # local import to avoid circular
|
||||
db.query(Message).filter(Message.user_id == user.id).delete()
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "deleted_user_id": user_id}
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
current_user.online = False
|
||||
current_user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from constants import OWNER_USERNAME
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
def convert_message(msg: Message) -> dict:
|
||||
return {
|
||||
"id": msg.id,
|
||||
"content": msg.content,
|
||||
"timestamp": msg.timestamp.isoformat(),
|
||||
"is_read": msg.is_read,
|
||||
"is_edited": msg.is_edited,
|
||||
"username": msg.author.username,
|
||||
"profile_picture": msg.author.profile_picture,
|
||||
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None
|
||||
}
|
||||
|
||||
|
||||
@router.post("/send_message")
|
||||
async def send_message(
|
||||
request: SendMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not request.content.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No content provided"
|
||||
)
|
||||
|
||||
if len(request.content.strip()) > 4096:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Message too long"
|
||||
)
|
||||
|
||||
new_message = Message(
|
||||
content=request.content.strip(),
|
||||
user_id=current_user.id,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
db.add(new_message)
|
||||
db.commit()
|
||||
db.refresh(new_message)
|
||||
|
||||
return {"status": "success", "message": convert_message(new_message)}
|
||||
|
||||
|
||||
@router.get("/get_messages")
|
||||
async def get_messages(db: Session = Depends(get_db)):
|
||||
messages = db.query(Message).order_by(Message.timestamp.asc()).all()
|
||||
|
||||
messages_data = []
|
||||
for msg in messages:
|
||||
messages_data.append(convert_message(msg))
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"messages": messages_data
|
||||
}
|
||||
|
||||
|
||||
@router.put("/edit_message/{message_id}")
|
||||
async def edit_message(
|
||||
message_id: int,
|
||||
request: EditMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
message = db.query(Message).filter(Message.id == message_id).first()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
if message.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
||||
|
||||
if not request.content.strip():
|
||||
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
||||
|
||||
message.content = request.content.strip()
|
||||
message.is_edited = True
|
||||
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
return {"status": "success", "message": convert_message(message)}
|
||||
|
||||
|
||||
@router.delete("/delete_message/{message_id}")
|
||||
async def delete_message(
|
||||
message_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
message = db.query(Message).filter(Message.id == message_id).first()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# Allow owner to delete any message
|
||||
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
||||
|
||||
db.delete(message)
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message_id": message_id}
|
||||
|
||||
|
||||
@router.post("/reply_message")
|
||||
async def reply_message(
|
||||
request: ReplyMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check if the message being replied to exists
|
||||
original_message = db.query(Message).filter(Message.id == request.reply_to_id).first()
|
||||
if not original_message:
|
||||
raise HTTPException(status_code=404, detail="Original message not found")
|
||||
|
||||
if not request.content.strip():
|
||||
raise HTTPException(status_code=400, detail="No content provided")
|
||||
|
||||
new_message = Message(
|
||||
content=request.content.strip(),
|
||||
user_id=current_user.id,
|
||||
timestamp=datetime.now(),
|
||||
reply_to_id=request.reply_to_id
|
||||
)
|
||||
|
||||
db.add(new_message)
|
||||
db.commit()
|
||||
db.refresh(new_message)
|
||||
|
||||
return {"status": "success", "message": convert_message(new_message)}
|
||||
|
||||
|
||||
class MessaggingSocketManager:
|
||||
def __init__(self) -> None:
|
||||
self.connections: list[WebSocket] = []
|
||||
|
||||
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException):
|
||||
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
|
||||
|
||||
async def handle_connection(self, websocket: WebSocket, db: Session):
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
type = data["type"]
|
||||
|
||||
def get_current_user_inner() -> User | None:
|
||||
if data["credentials"]:
|
||||
return get_current_user(
|
||||
HTTPAuthorizationCredentials(
|
||||
scheme=data["credentials"]["scheme"],
|
||||
credentials=data["credentials"]["credentials"]
|
||||
),
|
||||
db
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
if type == "ping":
|
||||
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
|
||||
elif type == "getMessages":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "sendMessage":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
|
||||
|
||||
response = await send_message(request, current_user, db)
|
||||
await self.broadcast({
|
||||
"type": "newMessage",
|
||||
"data": response["message"]
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "editMessage":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
message_id = data["data"]["message_id"]
|
||||
request: EditMessageRequest = EditMessageRequest.model_validate(data["data"])
|
||||
|
||||
response = await edit_message(message_id, request, current_user, db)
|
||||
await self.broadcast({
|
||||
"type": "messageEdited",
|
||||
"data": response["message"]
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "deleteMessage":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
message_id = data["data"]["message_id"]
|
||||
response = await delete_message(message_id, current_user, db)
|
||||
await self.broadcast({
|
||||
"type": "messageDeleted",
|
||||
"data": {"message_id": message_id}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "replyMessage":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
request: ReplyMessageRequest = ReplyMessageRequest.model_validate(data["data"])
|
||||
response = await reply_message(request, current_user, db)
|
||||
await self.broadcast({
|
||||
"type": "newMessage",
|
||||
"data": response["message"]
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
else:
|
||||
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None):
|
||||
try:
|
||||
await websocket.close(code=code, reason=message)
|
||||
finally:
|
||||
self.connections.remove(websocket)
|
||||
|
||||
async def connect(self, websocket: WebSocket, db: Session):
|
||||
await websocket.accept()
|
||||
self.connections.append(websocket)
|
||||
try:
|
||||
await self.handle_connection(websocket, db)
|
||||
except WebSocketDisconnect as e:
|
||||
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
|
||||
finally:
|
||||
self.connections.remove(websocket)
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for websocket in self.connections:
|
||||
await websocket.send_json(message)
|
||||
|
||||
messagingManager = MessaggingSocketManager()
|
||||
|
||||
@router.websocket("/chat/ws")
|
||||
async def chat_websocket(
|
||||
websocket: WebSocket,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
await messagingManager.connect(websocket, db)
|
||||
@@ -1,144 +0,0 @@
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from PIL import Image
|
||||
import os
|
||||
import uuid
|
||||
import io
|
||||
|
||||
from dependencies import get_db, get_current_user
|
||||
from models import User, UpdateBioRequest, UserProfileResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Create uploads directory if it doesn't exist
|
||||
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
|
||||
|
||||
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
|
||||
|
||||
@router.post("/upload-profile-picture")
|
||||
async def upload_profile_picture(
|
||||
profile_picture: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload and process a profile picture
|
||||
"""
|
||||
# Validate file type
|
||||
if not profile_picture.content_type.startswith('image/'):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
# Validate file size (max 5MB)
|
||||
if profile_picture.size > 5 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="File size must be less than 5MB")
|
||||
|
||||
try:
|
||||
# Read and process the image
|
||||
image_data = await profile_picture.read()
|
||||
|
||||
# Open image with PIL
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if necessary
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Resize to a reasonable size (200x200)
|
||||
image.thumbnail((200, 200), Image.Resampling.LANCZOS)
|
||||
|
||||
# Generate unique filename
|
||||
filename = f"{current_user.id}_{uuid.uuid4().hex}.jpg"
|
||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
||||
|
||||
# Save the processed image
|
||||
image.save(filepath, 'JPEG', quality=85)
|
||||
|
||||
# Update user's profile picture in database
|
||||
profile_picture_url = f"/api/profile-picture/{filename}"
|
||||
current_user.profile_picture = profile_picture_url
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Profile picture uploaded successfully",
|
||||
"profile_picture_url": profile_picture_url
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error processing image: {str(e)}")
|
||||
|
||||
@router.get("/profile-picture/{filename}")
|
||||
async def get_profile_picture(filename: str):
|
||||
"""
|
||||
Serve profile picture files
|
||||
"""
|
||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Profile picture not found")
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(filepath, media_type="image/jpeg")
|
||||
|
||||
@router.get("/user/profile")
|
||||
async def get_user_profile(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get current user's profile information
|
||||
"""
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"profile_picture": current_user.profile_picture,
|
||||
"bio": current_user.bio,
|
||||
"online": current_user.online,
|
||||
"last_seen": current_user.last_seen,
|
||||
"created_at": current_user.created_at
|
||||
}
|
||||
|
||||
|
||||
@router.put("/user/bio")
|
||||
async def update_user_bio(
|
||||
request: UpdateBioRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update current user's bio
|
||||
"""
|
||||
if len(request.bio) > 500: # Limit bio to 500 characters
|
||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
||||
|
||||
current_user.bio = request.bio.strip()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Bio updated successfully",
|
||||
"bio": current_user.bio
|
||||
}
|
||||
|
||||
|
||||
@router.get("/user/{username}")
|
||||
async def get_user_by_username(
|
||||
username: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get user profile by username
|
||||
"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return UserProfileResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
profile_picture=user.profile_picture,
|
||||
bio=user.bio,
|
||||
online=user.online,
|
||||
last_seen=user.last_seen,
|
||||
created_at=user.created_at
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
import jwt
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
|
||||
from constants import *
|
||||
|
||||
# JWT Helper Functions
|
||||
def create_token(user_id: int, username: str) -> str:
|
||||
expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"exp": expire
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
@@ -1,16 +0,0 @@
|
||||
import re
|
||||
|
||||
def is_valid_username(username: str) -> bool:
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
return False
|
||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', username):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_password(password: str) -> bool:
|
||||
if len(password) < 5 or len(password) > 50:
|
||||
return False
|
||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
||||
return False
|
||||
return True
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://api.fromchat.ru}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8301:80"
|
||||
restart: always
|
||||
@@ -1,33 +0,0 @@
|
||||
# Node.js
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ipynb_checkpoints
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Common
|
||||
# Exclude editor and OS files, as well as test results
|
||||
dist
|
||||
dist-electron
|
||||
build
|
||||
coverage
|
||||
test_results/
|
||||
out
|
||||
|
||||
data
|
||||
@@ -1 +0,0 @@
|
||||
JWT_SECRET="jwt-secret-change-in-production"
|
||||
@@ -1,25 +0,0 @@
|
||||
# 1. Install pip dependencies
|
||||
FROM python:3.12 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
RUN python3 -m venv .venv
|
||||
COPY backend/requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
./.venv/bin/pip3 install -r requirements.txt
|
||||
|
||||
# 2. Runtime stage
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
# 2.1. Non-root user
|
||||
WORKDIR /app
|
||||
RUN useradd -u 1000 app && \
|
||||
chown -R app /app
|
||||
USER app
|
||||
|
||||
# 2.2. Copy content and create dirs
|
||||
COPY --chown=app backend .
|
||||
COPY --from=builder --chown=app /app/.venv .venv
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# 3. Final command
|
||||
ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py
|
||||
@@ -1,52 +0,0 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
dockerfile: deployment/Dockerfile.backend
|
||||
context: ..
|
||||
environment:
|
||||
PORT: 8300
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
volumes:
|
||||
- "data:/app/data"
|
||||
develop:
|
||||
watch:
|
||||
- action: sync+restart
|
||||
path: ../backend
|
||||
target: /app
|
||||
- action: rebuild
|
||||
path: ../backend/requirements.txt
|
||||
networks:
|
||||
- main
|
||||
|
||||
frontend:
|
||||
build:
|
||||
dockerfile: deployment/frontend/Dockerfile
|
||||
context: ..
|
||||
environment:
|
||||
PORT: 8301
|
||||
BACKEND_HOST: http://backend:8300
|
||||
ports:
|
||||
- "8301:8301"
|
||||
depends_on:
|
||||
- backend
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ../frontend
|
||||
- action: sync+restart
|
||||
path: server.js
|
||||
target: /server/server.js
|
||||
- action: rebuild
|
||||
path: package.json
|
||||
networks:
|
||||
- main
|
||||
- default
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: fromchat-data
|
||||
|
||||
networks:
|
||||
main:
|
||||
driver: bridge
|
||||
internal: true # isolate from the outside world
|
||||
@@ -1,32 +0,0 @@
|
||||
[Unit]
|
||||
Description=FromChat server
|
||||
After=multi-user.target
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=/bin/docker compose up
|
||||
ExecStop=/bin/docker compose down
|
||||
WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/log
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
StandardInput=tty-force
|
||||
SyslogIdentifier=fromchat
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,47 +0,0 @@
|
||||
# 1. Build the frontend
|
||||
FROM node:24 AS frontend
|
||||
|
||||
# 1.1. Install npm dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Build
|
||||
COPY frontend frontend
|
||||
RUN npm run frontend:build
|
||||
|
||||
# 2. Build the static file server
|
||||
FROM node:24 AS server
|
||||
|
||||
# 2.1. Install npm dependencies
|
||||
WORKDIR /server
|
||||
COPY deployment/frontend/package.json .
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install
|
||||
|
||||
# 2.2. Copy the code
|
||||
COPY deployment/frontend/ .
|
||||
|
||||
# 3. Put it all together
|
||||
FROM node:24-slim
|
||||
|
||||
# 3.1. Non-root user
|
||||
RUN useradd -u 1001 app && \
|
||||
mkdir -p /app && \
|
||||
chown -R app /app && \
|
||||
mkdir /server && \
|
||||
chown -R app /server
|
||||
USER app
|
||||
|
||||
# 3.1. Frontend static files
|
||||
WORKDIR /app
|
||||
COPY --from=frontend --chown=app /app/frontend/dist .
|
||||
|
||||
# 3.2. Static file server
|
||||
WORKDIR /server
|
||||
COPY --from=server --chown=app /server .
|
||||
|
||||
# 4. Final command
|
||||
ENV STATIC_FILE_PATH=/app
|
||||
ENTRYPOINT ["npm", "run", "start"]
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "frontend-server",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// server.js
|
||||
const express = require('express');
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
||||
const file_path = process.env.STATIC_FILE_PATH || ".";
|
||||
|
||||
app.use('/api', createProxyMiddleware({
|
||||
target: backendHost,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
ws: true
|
||||
}));
|
||||
|
||||
app.use(express.static(path.resolve(file_path)));
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server launched на http://localhost:${port}`);
|
||||
});
|
||||
Vendored
-12
@@ -1,12 +0,0 @@
|
||||
export type Platform = "win32" | "darwin" | "linux"
|
||||
|
||||
export interface ElectronInterface {
|
||||
desktop: true,
|
||||
platform: Platform
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronInterface: ElectronInterface
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
asar: true
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-squirrel',
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
config: {},
|
||||
platforms: ['darwin'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
config: {},
|
||||
},
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
// at package time, before code signing the application
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Hello World!</title>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<h1>💖 Hello World!</h1>
|
||||
<p>Welcome to your Electron application.</p>
|
||||
<script type="module" src="/src/renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "FromChat",
|
||||
"productName": "FromChat",
|
||||
"version": "1.0.0",
|
||||
"description": "A 100% Open Source Messenger",
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": {
|
||||
"name": "denis0001-dev",
|
||||
"email": "denis0001.dev@ya.ru"
|
||||
},
|
||||
"license": "GPL-2.0",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.8.3",
|
||||
"@electron-forge/maker-deb": "^7.8.3",
|
||||
"@electron-forge/maker-rpm": "^7.8.3",
|
||||
"@electron-forge/maker-squirrel": "^7.8.3",
|
||||
"@electron-forge/maker-zip": "^7.8.3",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.8.3",
|
||||
"@electron-forge/plugin-fuses": "^7.8.3",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"electron": "37.3.1",
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "^1.0.1"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import path from "node:path";
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const win = new BrowserWindow({
|
||||
title: 'Main window',
|
||||
minWidth: 650,
|
||||
minHeight: 420,
|
||||
webPreferences: {
|
||||
preload: path.join(import.meta.dirname, "preload.mjs")
|
||||
},
|
||||
titleBarStyle: "hidden",
|
||||
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
|
||||
trafficLightPosition: {
|
||||
x: 16 - 4,
|
||||
y: 16 - 4
|
||||
},
|
||||
titleBarOverlay: process.platform !== "darwin"
|
||||
})
|
||||
|
||||
// You can use `process.env.VITE_DEV_SERVER_URL` when the vite command is called `serve`
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(process.env.VITE_DEV_SERVER_URL)
|
||||
} else {
|
||||
// Load your file
|
||||
win.loadFile('dist/index.html');
|
||||
}
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import { contextBridge } from "electron";
|
||||
import type { ElectronInterface, Platform } from "../electron";
|
||||
|
||||
const electronInterface: ElectronInterface = {
|
||||
desktop: true,
|
||||
platform: process.platform as Platform
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronInterface", electronInterface);
|
||||
@@ -1,420 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" class="mdui-theme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Loading...</title>
|
||||
<link rel="icon" href="./src/images/logo.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="electron-title-bar">
|
||||
<div class="macos-padding"></div>
|
||||
<div id="window-title"></div>
|
||||
<!-- <div class="window-controls">
|
||||
<mdui-button-icon icon="remove" id="window-minimize"></mdui-button-icon>
|
||||
<mdui-button-icon icon="stack--outlined" id="window-restore" class="hidden"></mdui-button-icon>
|
||||
<mdui-button-icon icon="ad--outlined" id="window-maximize"></mdui-button-icon>
|
||||
<mdui-button-icon icon="close" id="window-close"></mdui-button-icon>
|
||||
</div> -->
|
||||
</div>
|
||||
<div id="main-wrapper">
|
||||
<!-- Login Form -->
|
||||
<div id="login-form" class="auth-container">
|
||||
<div class="auth-card fade-in">
|
||||
<div class="auth-header">
|
||||
<h2>
|
||||
<span class="material-symbols filled large">login</span>
|
||||
Добро пожаловать!
|
||||
</h2>
|
||||
<p>Войдите в свой аккаунт</p>
|
||||
</div>
|
||||
<div class="auth-body">
|
||||
<div id="login-alerts"></div>
|
||||
|
||||
<form id="login-form-element">
|
||||
<mdui-text-field
|
||||
label="Имя пользователя"
|
||||
id="login-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
required>
|
||||
</mdui-text-field>
|
||||
<mdui-text-field
|
||||
label="Пароль"
|
||||
id="login-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="current-password"
|
||||
required>
|
||||
</mdui-text-field>
|
||||
|
||||
<mdui-button type="submit">Войти</mdui-button>
|
||||
</form>
|
||||
|
||||
<div class="text-center">
|
||||
<p>Ещё нет аккаунта? <a href="#" id="register-link" class="link">Зарегистрируйтесь</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<div id="register-form" class="auth-container" style="display: none;">
|
||||
<div class="auth-card fade-in">
|
||||
<div class="auth-header">
|
||||
<h2>
|
||||
<span class="material-symbols filled large">person_add</span>
|
||||
Регистрация
|
||||
</h2>
|
||||
<p>Создайте новый аккаунт</p>
|
||||
</div>
|
||||
<div class="auth-body">
|
||||
<div id="register-alerts"></div>
|
||||
|
||||
<form id="register-form-element">
|
||||
<mdui-text-field
|
||||
label="Имя пользователя"
|
||||
id="register-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
maxlength="20"
|
||||
counter
|
||||
required>
|
||||
</mdui-text-field>
|
||||
<mdui-text-field
|
||||
label="Пароль"
|
||||
id="register-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required>
|
||||
</mdui-text-field>
|
||||
<mdui-text-field
|
||||
label="Подтвердите пароль"
|
||||
id="register-confirm-password"
|
||||
name="confirm_password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required>
|
||||
</mdui-text-field>
|
||||
|
||||
<mdui-button type="submit">Зарегистрироваться</mdui-button>
|
||||
</form>
|
||||
|
||||
<div class="text-center">
|
||||
<p>
|
||||
Уже есть аккаунт?
|
||||
<a href="#" id="login-link" class="link">Войдите</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Interface -->
|
||||
<div id="chat-interface" style="display: none;">
|
||||
<div class="all-container">
|
||||
<div class="chat-list" id="chat-list">
|
||||
<header class="chat-header-left">
|
||||
<div id="productname">Loading...</div>
|
||||
<div class="profile">
|
||||
<a href="#" id="profile-open">
|
||||
<img src="./src/images/default-avatar.png" alt="" id="preview1" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="chat-tabs">
|
||||
<mdui-tabs value="chats" full-width>
|
||||
<mdui-tab value="chats">
|
||||
Чаты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="channels">
|
||||
Каналы
|
||||
</mdui-tab>
|
||||
<mdui-tab value="contacts">
|
||||
Контакты
|
||||
</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
<mdui-list-item headline="Общий чат" description="Вы: Последнее сообщение" id="chat-list-chat-1">
|
||||
<img src="./src/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item headline="Общий чат 2" description="Вы: Последнее сообщение" id="chat-list-chat-2">
|
||||
<img src="./src/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
<mdui-bottom-app-bar>
|
||||
<mdui-button-icon icon="settings--filled" id="settings-open"></mdui-button-icon>
|
||||
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
|
||||
<div style="flex-grow: 1"></div>
|
||||
<mdui-fab icon="edit--filled"></mdui-fab>
|
||||
</mdui-bottom-app-bar>
|
||||
</div>
|
||||
<div class="chat-container">
|
||||
<div class="chat-main" id="chat-inner">
|
||||
<div class="chat-header">
|
||||
<img src="src/images/default-avatar.png" alt="Avatar" class="chat-header-avatar">
|
||||
<div class="chat-header-info">
|
||||
<div class="info-chat">
|
||||
<h4 id="chat-name">Общий чат</h4>
|
||||
<p>
|
||||
<span class="online-status"></span>
|
||||
Онлайн
|
||||
</p>
|
||||
</div>
|
||||
<a href="#" id="hide-chat">Свернуть чат</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<!-- Messages will be loaded here dynamically -->
|
||||
</div>
|
||||
|
||||
<div class="chat-input-wrapper">
|
||||
<div class="chat-input">
|
||||
<form class="input-group" id="message-form">
|
||||
<input type="text" class="message-input" id="message-input" placeholder="Напишите сообщение..." autocomplete="off">
|
||||
<button type="submit" class="send-btn">
|
||||
<span class="material-symbols filled">send</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mdui-dialog id="profile-dialog" close-on-overlay-click close-on-esc>
|
||||
<div class="content">
|
||||
<div class="header-top">
|
||||
<div class="profile-picture-container">
|
||||
<img id="profile-picture" src="./src/images/default-avatar.png" alt="Ваше фото" />
|
||||
<mdui-button-icon icon="camera_alt--filled" id="upload-pfp-btn" class="upload-overlay" variant="filled"></mdui-button-icon>
|
||||
<input type="file" id="pfp-file-input" accept="image/*" style="display: none;">
|
||||
</div>
|
||||
<mdui-text-field id="username-field" label="Имя пользователя" variant="outlined" value="user123" autocomplete="username"></mdui-text-field>
|
||||
</div>
|
||||
|
||||
<form id="profile-form">
|
||||
<mdui-text-field
|
||||
id="description-field"
|
||||
label="О себе"
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows="3"
|
||||
placeholder="Расскажите о себе..."
|
||||
autocomplete="none"></mdui-text-field>
|
||||
<div class="dialog-actions">
|
||||
<mdui-button type="submit" id="profile-submit">Сохранить изменения</mdui-button>
|
||||
<mdui-button id="profile-dialog-close" variant="outlined">Закрыть</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
|
||||
<!-- Profile Picture Cropper Dialog -->
|
||||
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
|
||||
<div class="cropper-dialog-content">
|
||||
<div class="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
|
||||
</div>
|
||||
<div class="cropper-container">
|
||||
<div id="cropper-area"></div>
|
||||
</div>
|
||||
<div class="cropper-actions">
|
||||
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
|
||||
<mdui-button id="crop-save">Сохранить</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
|
||||
<mdui-dialog id="settings-dialog" close-on-overlay-click close-on-esc fullscreen>
|
||||
<div class="fullscreen-wrapper">
|
||||
<div id="settings-dialog-inner">
|
||||
<div class="header">
|
||||
<mdui-button-icon icon="close" id="settings-close"></mdui-button-icon>
|
||||
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
|
||||
</div>
|
||||
<div id="settings-menu">
|
||||
<mdui-list>
|
||||
<mdui-list-item icon="notifications--filled" rounded active>Уведомления</mdui-list-item>
|
||||
<mdui-list-item icon="palette--filled" rounded>Внешний вид</mdui-list-item>
|
||||
<mdui-list-item icon="security--filled" rounded>Безопасность</mdui-list-item>
|
||||
<mdui-list-item icon="language--filled" rounded>Язык</mdui-list-item>
|
||||
<mdui-list-item icon="storage--filled" rounded>Хранилище</mdui-list-item>
|
||||
<mdui-list-item icon="help--filled" rounded>Помощь</mdui-list-item>
|
||||
<mdui-list-item icon="info--filled" rounded>О приложении</mdui-list-item>
|
||||
</mdui-list>
|
||||
<div class="screen">
|
||||
<div id="notifications-settings" class="settings-panel active">
|
||||
<h3>Уведомления</h3>
|
||||
<mdui-switch checked>Новые сообщения</mdui-switch>
|
||||
<mdui-switch checked>Звуковые уведомления</mdui-switch>
|
||||
<mdui-switch>Уведомления о статусе</mdui-switch>
|
||||
<mdui-switch checked>Email уведомления</mdui-switch>
|
||||
</div>
|
||||
|
||||
<div id="appearance-settings" class="settings-panel">
|
||||
<h3>Внешний вид</h3>
|
||||
<mdui-select label="Тема" variant="outlined">
|
||||
<mdui-menu-item value="dark">Тёмная</mdui-menu-item>
|
||||
<mdui-menu-item value="light">Светлая</mdui-menu-item>
|
||||
<mdui-menu-item value="auto">Авто</mdui-menu-item>
|
||||
</mdui-select>
|
||||
<mdui-select label="Размер шрифта" variant="outlined">
|
||||
<mdui-menu-item value="small">Маленький</mdui-menu-item>
|
||||
<mdui-menu-item value="medium">Средний</mdui-menu-item>
|
||||
<mdui-menu-item value="large">Большой</mdui-menu-item>
|
||||
</mdui-select>
|
||||
</div>
|
||||
|
||||
<div id="security-settings" class="settings-panel">
|
||||
<h3>Безопасность</h3>
|
||||
<mdui-button variant="outlined">Изменить пароль</mdui-button>
|
||||
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
|
||||
<mdui-switch>Автоматический выход</mdui-switch>
|
||||
</div>
|
||||
|
||||
<div id="language-settings" class="settings-panel">
|
||||
<h3>Язык</h3>
|
||||
<mdui-select label="Выберите язык" variant="outlined">
|
||||
<mdui-menu-item value="ru">Русский</mdui-menu-item>
|
||||
<mdui-menu-item value="en">English</mdui-menu-item>
|
||||
<mdui-menu-item value="es">Español</mdui-menu-item>
|
||||
</mdui-select>
|
||||
</div>
|
||||
|
||||
<div id="storage-settings" class="settings-panel">
|
||||
<h3>Хранилище</h3>
|
||||
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
|
||||
<mdui-linear-progress value="25"></mdui-linear-progress>
|
||||
<mdui-button variant="outlined">Очистить кэш</mdui-button>
|
||||
</div>
|
||||
|
||||
<div id="help-settings" class="settings-panel">
|
||||
<h3>Помощь</h3>
|
||||
<mdui-button variant="outlined">Руководство пользователя</mdui-button>
|
||||
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button>
|
||||
<mdui-button variant="outlined">FAQ</mdui-button>
|
||||
</div>
|
||||
|
||||
<div id="about-settings" class="settings-panel">
|
||||
<h3>О приложении</h3>
|
||||
<p>Версия: 1.0.0</p>
|
||||
<p>© 2024 From Chat. Все права защищены.</p>
|
||||
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
|
||||
<mdui-button variant="outlined">Условия использования</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
|
||||
<div id="message-context-menu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="reply">
|
||||
<span class="material-symbols">reply</span>
|
||||
Reply
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="edit">
|
||||
<span class="material-symbols">edit</span>
|
||||
Edit
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="delete">
|
||||
<span class="material-symbols">delete</span>
|
||||
Delete
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mdui-dialog id="edit-message-dialog" close-on-overlay-click close-on-esc>
|
||||
<div class="dialog-content">
|
||||
<h3>Edit Message</h3>
|
||||
<mdui-text-field
|
||||
id="edit-message-input"
|
||||
label="Edit Message"
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows="4"
|
||||
placeholder="Edit your message..."
|
||||
maxlength="1000">
|
||||
</mdui-text-field>
|
||||
<div class="dialog-actions">
|
||||
<mdui-button id="edit-cancel" variant="outlined">Cancel</mdui-button>
|
||||
<mdui-button id="edit-save">Save</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
|
||||
<mdui-dialog id="reply-message-dialog" close-on-overlay-click close-on-esc>
|
||||
<div class="dialog-content">
|
||||
<h3>Reply to Message</h3>
|
||||
<div class="reply-preview" id="reply-preview"></div>
|
||||
<mdui-text-field
|
||||
id="reply-message-input"
|
||||
label="Reply"
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows="4"
|
||||
placeholder="Type your reply..."
|
||||
maxlength="1000">
|
||||
</mdui-text-field>
|
||||
<div class="dialog-actions">
|
||||
<mdui-button id="reply-cancel" variant="outlined">Cancel</mdui-button>
|
||||
<mdui-button id="reply-send">Send Reply</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
|
||||
<mdui-dialog id="user-profile-dialog" close-on-overlay-click close-on-esc>
|
||||
<div class="content">
|
||||
<div class="profile-picture-section">
|
||||
<img class="profile-picture" src="" alt="Profile Picture">
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<div class="username-section">
|
||||
<h4 class="username"></h4>
|
||||
<div class="online-status"></div>
|
||||
</div>
|
||||
<div class="bio-section">
|
||||
<label>Bio:</label>
|
||||
<div class="bio-display"></div>
|
||||
</div>
|
||||
<div class="profile-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Member since:</span>
|
||||
<span class="stat-value member-since"></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Last seen:</span>
|
||||
<span class="stat-value last-seen"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
|
||||
<script src="src/main.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Authentication system implementation
|
||||
* @description Handles user authentication, registration, and session management
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { loadMessages } from "./chat";
|
||||
import { initializeProfile } from "./profile";
|
||||
import type { Headers, ErrorResponse, User, LoginResponse, LoginRequest, RegisterRequest } from "./types";
|
||||
import { API_BASE_URL } from "./config";
|
||||
|
||||
/**
|
||||
* Current authenticated user information
|
||||
* @type {User | null}
|
||||
*/
|
||||
export let currentUser: User | null = null;
|
||||
|
||||
/**
|
||||
* JWT authentication token
|
||||
* @type {string | null}
|
||||
*/
|
||||
export let authToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {boolean} json - Whether to include JSON content type header
|
||||
* @returns {Headers} Headers object with authentication and content type
|
||||
* @function getAuthHeaders
|
||||
* @example
|
||||
* const headers = getAuthHeaders();
|
||||
* fetch('/api/endpoint', { headers });
|
||||
*/
|
||||
export function getAuthHeaders(json: boolean = true): Headers {
|
||||
const headers: Headers = {};
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the login form and hides other interfaces
|
||||
* @function showLogin
|
||||
* @example
|
||||
* showLogin();
|
||||
*/
|
||||
export function showLogin(): void {
|
||||
document.getElementById('login-form')!.style.display = 'flex';
|
||||
document.getElementById('register-form')!.style.display = 'none';
|
||||
document.getElementById('chat-interface')!.style.display = 'none';
|
||||
clearAlerts();
|
||||
document.getElementById("electron-title-bar")!.classList.add("color-surface");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the registration form and hides other interfaces
|
||||
* @function showRegister
|
||||
* @example
|
||||
* showRegister();
|
||||
*/
|
||||
export function showRegister(): void {
|
||||
document.getElementById('login-form')!.style.display = 'none';
|
||||
document.getElementById('register-form')!.style.display = 'flex';
|
||||
document.getElementById('chat-interface')!.style.display = 'none';
|
||||
clearAlerts();
|
||||
document.getElementById("electron-title-bar")!.classList.add("color-surface");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the chat interface and hides authentication forms
|
||||
* @function showChat
|
||||
* @example
|
||||
* showChat();
|
||||
*/
|
||||
export function showChat(): void {
|
||||
document.getElementById('login-form')!.style.display = 'none';
|
||||
document.getElementById('register-form')!.style.display = 'none';
|
||||
document.getElementById('chat-interface')!.style.display = 'block';
|
||||
loadMessages();
|
||||
document.getElementById("electron-title-bar")!.classList.remove("color-surface");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all alert messages from authentication forms
|
||||
* @function clearAlerts
|
||||
* @private
|
||||
*/
|
||||
export function clearAlerts(): void {
|
||||
document.getElementById('login-alerts')!.innerHTML = '';
|
||||
document.getElementById('register-alerts')!.innerHTML = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an alert message in the specified container
|
||||
* @param {string} containerId - ID of the container to show the alert in
|
||||
* @param {string} message - Alert message to display
|
||||
* @param {'success' | 'danger'} type - Type of alert (success or danger)
|
||||
* @function showAlert
|
||||
* @example
|
||||
* showAlert('login-alerts', 'Login successful!', 'success');
|
||||
*/
|
||||
export function showAlert(containerId: string, message: string, type: "success" | "danger" = 'danger'): void {
|
||||
const container = document.getElementById(containerId)!;
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type}`;
|
||||
alertDiv.textContent = message;
|
||||
container.appendChild(alertDiv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles login form submission
|
||||
* @async
|
||||
* @function handleLogin
|
||||
* @param {Event} e - Form submission event
|
||||
* @private
|
||||
*/
|
||||
async function handleLogin(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
|
||||
const usernameElement = document.getElementById('login-username') as HTMLInputElement;
|
||||
const passwordElement = document.getElementById('login-password') as HTMLInputElement;
|
||||
|
||||
const username = usernameElement.value.trim();
|
||||
const password = passwordElement.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showAlert('login-alerts', 'Пожалуйста, заполните все поля', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: LoginRequest = {
|
||||
username: username,
|
||||
password: password
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token
|
||||
authToken = data.token;
|
||||
currentUser = data.user;
|
||||
showChat();
|
||||
loadMessages(); // Start loading messages
|
||||
initializeProfile(); // Initialize profile after login
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert('login-alerts', data.message || 'Неверное имя пользователя или пароль', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('login-alerts', 'Ошибка соединения с сервером', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles registration form submission
|
||||
* @async
|
||||
* @function handleRegister
|
||||
* @param {Event} e - Form submission event
|
||||
* @private
|
||||
*/
|
||||
async function handleRegister(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
|
||||
const usernameElement = document.getElementById('register-username') as HTMLInputElement;
|
||||
const passwordElement = document.getElementById('register-password') as HTMLInputElement;
|
||||
const confirmPasswordElement = document.getElementById('register-confirm-password') as HTMLInputElement;
|
||||
|
||||
const username = usernameElement.value.trim();
|
||||
const password = passwordElement.value.trim();
|
||||
const confirmPassword = confirmPasswordElement.value.trim();
|
||||
|
||||
if (!username || !password || !confirmPassword) {
|
||||
showAlert('register-alerts', 'Пожалуйста, заполните все поля', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showAlert('register-alerts', 'Пароли не совпадают', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showAlert('register-alerts', 'Имя пользователя должно быть от 3 до 20 символов', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 5 || password.length > 50) {
|
||||
showAlert('register-alerts', 'Пароль должен быть от 5 до 50 символов', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: RegisterRequest = {
|
||||
username: username,
|
||||
password: password,
|
||||
confirm_password: confirmPassword
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Registration successful
|
||||
showAlert('register-alerts', 'Регистрация прошла успешно! Теперь вы можете войти.', 'success');
|
||||
setTimeout(() => {
|
||||
showLogin();
|
||||
}, 2000);
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert('register-alerts', data.message || 'Ошибка при регистрации', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('register-alerts', 'Ошибка соединения с сервером', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the current user and clears session data
|
||||
* @async
|
||||
* @function logout
|
||||
* @example
|
||||
* await logout();
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/logout`, {
|
||||
method: 'GET',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
|
||||
currentUser = null;
|
||||
authToken = null;
|
||||
showLogin();
|
||||
clearAlerts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the chat interface and initializes messaging
|
||||
* @function loadChat
|
||||
* @example
|
||||
* loadChat();
|
||||
*/
|
||||
export function loadChat(): void {
|
||||
showChat();
|
||||
loadMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks authentication status on page load
|
||||
* @async
|
||||
* @function checkAuthStatus
|
||||
* @example
|
||||
* await checkAuthStatus();
|
||||
*/
|
||||
export async function checkAuthStatus(): Promise<void> {
|
||||
// For JWT, we don't have a persistent token on page load
|
||||
// So we'll just show the login form
|
||||
showLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up authentication form event listeners
|
||||
* @function setupAuthForms
|
||||
* @private
|
||||
*/
|
||||
function setupAuthForms(): void {
|
||||
document.getElementById('login-form-element')!.addEventListener('submit', handleLogin);
|
||||
document.getElementById('register-form-element')!.addEventListener('submit', handleRegister);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes links
|
||||
* @function setupLinks
|
||||
* @private
|
||||
*/
|
||||
function setupLinks(): void {
|
||||
document.getElementById("login-link")!.addEventListener("click", showLogin);
|
||||
document.getElementById("register-link")!.addEventListener("click", showRegister);
|
||||
}
|
||||
|
||||
// Initialize authentication forms
|
||||
setupAuthForms();
|
||||
setupLinks();
|
||||
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Chat functionality and message management
|
||||
* @description Handles message display, loading, sending, and real-time updates
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { getAuthHeaders, currentUser, authToken } from "./auth";
|
||||
import { API_BASE_URL } from "./config";
|
||||
import { websocket } from "./websocket";
|
||||
import type { Message, Messages, WebSocketMessage } from "./types";
|
||||
import { formatTime } from "./utils/utils";
|
||||
import { show as showContextMenu } from "./message-context-menu";
|
||||
import { show as showUserProfileDialog } from "./user-profile-dialog";
|
||||
import defaultAvatar from "./images/default-avatar.png";
|
||||
|
||||
/**
|
||||
* Adds a new message to the chat interface
|
||||
* @param {Message} message - Message object to display
|
||||
* @param {boolean} isAuthor - Whether the current user is the message author
|
||||
* @function addMessage
|
||||
* @example
|
||||
* addMessage(messageData, messageData.username === currentUser.username);
|
||||
*/
|
||||
export function addMessage(message: Message, isAuthor: boolean): void {
|
||||
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.classList.add("message");
|
||||
if (isAuthor) {
|
||||
messageDiv.classList.add("sent");
|
||||
} else {
|
||||
messageDiv.classList.add("received");
|
||||
}
|
||||
messageDiv.dataset.id = `${message.id}`;
|
||||
|
||||
const messageInner = document.createElement('div');
|
||||
messageInner.classList.add('message-inner');
|
||||
|
||||
// Add profile picture for received messages
|
||||
if (!isAuthor) {
|
||||
const profilePicDiv = document.createElement('div');
|
||||
profilePicDiv.classList.add('message-profile-pic');
|
||||
|
||||
const profileImg = document.createElement('img');
|
||||
profileImg.src = message.profile_picture || defaultAvatar;
|
||||
profileImg.alt = message.username;
|
||||
|
||||
let errorLock = false;
|
||||
|
||||
profileImg.addEventListener("error", () => {
|
||||
if (!errorLock) {
|
||||
profileImg.src = defaultAvatar;
|
||||
errorLock = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Add click handler to profile picture
|
||||
profileImg.style.cursor = 'pointer';
|
||||
profileImg.addEventListener('click', () => {
|
||||
showUserProfileDialog(message.username);
|
||||
});
|
||||
|
||||
profilePicDiv.appendChild(profileImg);
|
||||
messageDiv.appendChild(profilePicDiv);
|
||||
}
|
||||
|
||||
if (!isAuthor) {
|
||||
const usernameDiv = document.createElement('div');
|
||||
usernameDiv.classList.add('message-username');
|
||||
usernameDiv.textContent = message.username;
|
||||
|
||||
// Add click handler to username
|
||||
usernameDiv.style.cursor = 'pointer';
|
||||
usernameDiv.addEventListener('click', () => {
|
||||
showUserProfileDialog(message.username);
|
||||
});
|
||||
|
||||
messageInner.appendChild(usernameDiv);
|
||||
}
|
||||
|
||||
// Add reply preview if this is a reply
|
||||
if (message.reply_to) {
|
||||
const replyDiv = document.createElement('div');
|
||||
replyDiv.classList.add('message-reply');
|
||||
replyDiv.innerHTML = `
|
||||
<div class="reply-content">
|
||||
<span class="reply-username">${message.reply_to.username}</span>
|
||||
<span class="reply-text">${message.reply_to.content}</span>
|
||||
</div>
|
||||
`;
|
||||
messageInner.appendChild(replyDiv);
|
||||
}
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.classList.add('message-content');
|
||||
contentDiv.textContent = message.content;
|
||||
messageInner.appendChild(contentDiv);
|
||||
|
||||
const timeDiv = document.createElement('div');
|
||||
timeDiv.classList.add('message-time');
|
||||
|
||||
let timeText = formatTime(message.timestamp);
|
||||
if (message.is_edited) {
|
||||
timeText += ' (edited)';
|
||||
}
|
||||
timeDiv.textContent = timeText;
|
||||
|
||||
if (isAuthor && message.is_read) {
|
||||
const checkIcon = document.createElement('span');
|
||||
checkIcon.classList.add("material-symbols", "outlined");
|
||||
timeDiv.appendChild(checkIcon);
|
||||
}
|
||||
|
||||
messageInner.appendChild(timeDiv);
|
||||
messageDiv.appendChild(messageInner);
|
||||
messagesContainer.appendChild(messageDiv);
|
||||
|
||||
// Add right-click context menu
|
||||
messageDiv.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(message, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// Прокрутка к новому сообщению
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads chat messages from the server
|
||||
* @function loadMessages
|
||||
* @example
|
||||
* loadMessages();
|
||||
*/
|
||||
export function loadMessages(): void {
|
||||
fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then((data: Messages) => {
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
|
||||
|
||||
const lastMessage = messagesContainer.lastElementChild as HTMLElement
|
||||
let lastMessageId: number = 0
|
||||
if (lastMessage) {
|
||||
lastMessageId = Number(lastMessage.dataset.id)
|
||||
}
|
||||
|
||||
// Добавляем только новые сообщения
|
||||
data.messages.forEach(msg => {
|
||||
if (msg.id > lastMessageId) {
|
||||
addMessage(msg, msg.username == currentUser!.username);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message via WebSocket
|
||||
* @function sendMessage
|
||||
* @example
|
||||
* sendMessage();
|
||||
*/
|
||||
export function sendMessage(): void {
|
||||
const input = document.querySelector('.message-input') as HTMLInputElement;
|
||||
const message = input.value.trim();
|
||||
|
||||
if (message) {
|
||||
const payload: WebSocketMessage = {
|
||||
data: {
|
||||
content: message
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken!
|
||||
},
|
||||
type: "sendMessage"
|
||||
}
|
||||
|
||||
let callback: ((e: MessageEvent) => void) | null = null
|
||||
callback = (e) => {
|
||||
websocket.removeEventListener("message", callback!);
|
||||
const response: WebSocketMessage = JSON.parse(e.data)
|
||||
console.log(response)
|
||||
if (!response.error) {
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
websocket.addEventListener("message", callback);
|
||||
|
||||
websocket.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.getElementById('message-form')!.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates an existing message in the chat interface
|
||||
* @param {Message} message - Updated message object
|
||||
* @function updateMessage
|
||||
*/
|
||||
export function updateMessage(message: Message): void {
|
||||
const messageElement = document.querySelector(`[data-id="${message.id}"]`) as HTMLElement;
|
||||
if (!messageElement) return;
|
||||
|
||||
const contentDiv = messageElement.querySelector('.message-content') as HTMLElement;
|
||||
const timeDiv = messageElement.querySelector('.message-time') as HTMLElement;
|
||||
|
||||
if (contentDiv) {
|
||||
contentDiv.textContent = message.content;
|
||||
}
|
||||
|
||||
if (timeDiv) {
|
||||
let timeText = formatTime(message.timestamp);
|
||||
if (message.is_edited) {
|
||||
timeText += ' (edited)';
|
||||
}
|
||||
timeDiv.textContent = timeText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a message from the chat interface
|
||||
* @param {number} messageId - ID of the message to remove
|
||||
* @function removeMessage
|
||||
*/
|
||||
export function removeMessage(messageId: number): void {
|
||||
const messageElement = document.querySelector(`[data-id="${messageId}"]`) as HTMLElement;
|
||||
if (messageElement) {
|
||||
messageElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles WebSocket message updates
|
||||
* @param {WebSocketMessage} response - WebSocket response
|
||||
* @function handleWebSocketMessage
|
||||
*/
|
||||
export function handleWebSocketMessage(response: WebSocketMessage): void {
|
||||
switch (response.type) {
|
||||
case 'messageEdited':
|
||||
if (response.data) {
|
||||
updateMessage(response.data);
|
||||
}
|
||||
break;
|
||||
case 'messageDeleted':
|
||||
if (response.data && response.data.message_id) {
|
||||
removeMessage(response.data.message_id);
|
||||
}
|
||||
break;
|
||||
case 'newMessage':
|
||||
if (response.data) {
|
||||
const isAuthor = response.data.username === currentUser?.username;
|
||||
addMessage(response.data, isAuthor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Application configuration constants
|
||||
* @description Contains all configuration values used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base API endpoint for all backend requests
|
||||
* @type {string}
|
||||
* @constant
|
||||
*/
|
||||
export const API_BASE_URL: string = '/api';
|
||||
|
||||
/**
|
||||
* Full API URL including hostname and port for WebSocket connections
|
||||
* @type {string}
|
||||
* @constant
|
||||
*/
|
||||
export const API_FULL_BASE_URL: string = `${location.host}/api`;
|
||||
|
||||
/**
|
||||
* Application name displayed in UI and document title
|
||||
* @type {string}
|
||||
* @constant
|
||||
*/
|
||||
export const PRODUCT_NAME: string = "FromChat";
|
||||
@@ -1,50 +0,0 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
background-color: $color-dark-surface;
|
||||
|
||||
.auth-card {
|
||||
background-color: $color-dark-surface-container;
|
||||
color: $color-dark-on-surface;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-body {
|
||||
padding: 25px;
|
||||
padding-bottom: 16px;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
|
||||
#chat-interface {
|
||||
height: 100%;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
background-color: $color-dark-surface-container;
|
||||
color: white;
|
||||
padding: 16px 16px;
|
||||
justify-content: end;
|
||||
width: fit-content;
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#logouts {
|
||||
display: none;
|
||||
list-style: none;
|
||||
gap: 10px;
|
||||
|
||||
li {
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
.chat-main {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.chat-header {
|
||||
padding: 16px;
|
||||
background: $color-dark-surface-container;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: black 0 0 20px;
|
||||
|
||||
.chat-header-avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 20%;
|
||||
object-fit: cover;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
display: flex;
|
||||
|
||||
.info-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h4 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #718096;
|
||||
}
|
||||
}
|
||||
|
||||
.online-status {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: $success;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
justify-content: end;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
top: 2%;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1rem;
|
||||
max-width: 70%;
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
.message-profile-pic {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 4px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-inner {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
|
||||
.message-content {
|
||||
word-wrap: break-word;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.message-reply {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-left: 3px solid $color-dark-primary;
|
||||
|
||||
.reply-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
|
||||
.reply-username {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
font-size: 0.85rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
margin-top: 0.3rem;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&.received .message-inner {
|
||||
background-color: $color-dark-surface-container;
|
||||
border-top-left-radius: 5px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&.sent {
|
||||
margin-left: auto;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-inner {
|
||||
background-color: $color-dark-primary-container;
|
||||
color: $color-dark-on-primary-container;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: $color-dark-on-primary-container;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-username {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $color-dark-primary;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
$height: 20px;
|
||||
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -$height;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: $height;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
$color-dark-surface,
|
||||
rgba(255, 255, 255, 0),
|
||||
);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
margin: 0 20px 20px 20px;
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 40px;
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
background-color: inherit;
|
||||
caret-color: $color-dark-primary;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
margin: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: $color-dark-primary;
|
||||
color: $color-dark-on-primary;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
|
||||
@include hoverStateLayer($background: $color-dark-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reply preview styles
|
||||
.reply-preview {
|
||||
background-color: $color-dark-surface;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 3px solid $color-dark-primary;
|
||||
|
||||
.reply-preview-content {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
|
||||
strong {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
@use "common/material" as *;
|
||||
|
||||
#electron-title-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.electron {
|
||||
#electron-title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
background-color: $color-dark-surface-container;
|
||||
width: 100%;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
transition: background-color 0.5s ease;
|
||||
|
||||
&.color-surface {
|
||||
background-color: $color-dark-surface;
|
||||
}
|
||||
}
|
||||
|
||||
#window-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.platform-darwin .macos-padding {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
// .window-controls {
|
||||
// -webkit-app-region: no-drag;
|
||||
|
||||
// .hidden {
|
||||
// display: none;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
|
||||
|
||||
// контейнер чата и панели с чатами
|
||||
.all-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#profile {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
z-index: 2000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
width: 27%;
|
||||
background-color: $color-dark-surface-container;
|
||||
position: relative;
|
||||
|
||||
.profileheader {
|
||||
display: flex;
|
||||
gap: 200px;
|
||||
|
||||
p {
|
||||
color: white;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
border: solid 2px $color-dark-on-surface-variant;
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.241);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#chat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 0;
|
||||
width: 40%;
|
||||
background-color: $color-dark-surface-container;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
|
||||
.chat-header-left {
|
||||
display: flex;
|
||||
color: white;
|
||||
font-size: 25px;
|
||||
background-color: $color-dark-surface-container;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
#productname {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.profile {
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
#closeprofile a {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
border: solid 2px $color-dark-on-surface-variant;
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.241);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
$size: 45px;
|
||||
display: flex;
|
||||
border-radius: 50%;
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-tabs {
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
--mdui-color-surface: $color-dark-surface-container;
|
||||
--mdui-color-surface-variant: transparent;
|
||||
|
||||
img {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 20%;
|
||||
object-fit: cover;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
mdui-bottom-app-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
|
||||
#profile-dialog .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-width: 400px;
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $color-dark-outline;
|
||||
|
||||
.profile-picture-container {
|
||||
position: relative;
|
||||
$size: 70px;
|
||||
width: $size;
|
||||
height: $size;
|
||||
flex-shrink: 0;
|
||||
|
||||
#profile-picture {
|
||||
width: $size;
|
||||
height: $size;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
mdui-text-field {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
mdui-text-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $color-dark-outline;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User profile dialog content styles
|
||||
#user-profile-dialog .content {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
||||
.profile-picture-section {
|
||||
flex-shrink: 0;
|
||||
|
||||
.profile-picture {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid $color-dark-outline;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.username-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.username {
|
||||
margin: 0;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.online-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.online {
|
||||
color: $success;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
|
||||
.online-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
&.offline {
|
||||
color: $color-dark-on-surface-variant;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
|
||||
.offline-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bio-section {
|
||||
label {
|
||||
display: block;
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bio-display {
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
padding: 0.75rem;
|
||||
background-color: $color-dark-surface;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $color-dark-outline;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.bio-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
.stat-label {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cropper Dialog Styles
|
||||
#cropper-dialog {
|
||||
.cropper-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.cropper-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $color-dark-outline;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
background: $color-dark-surface-container;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
#cropper-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $color-dark-outline;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
|
||||
#settings-dialog {
|
||||
.fullscreen-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100vh - (1.5rem * 2));
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
#settings-dialog-inner {
|
||||
max-width: 1200px;
|
||||
max-height: 1000px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#settings-menu {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
|
||||
mdui-list {
|
||||
max-width: 280px;
|
||||
padding-right: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.screen {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s ease;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
|
||||
mdui-text-field,
|
||||
mdui-select,
|
||||
mdui-switch,
|
||||
mdui-button {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
mdui-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid $color-dark-outline;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
|
||||
mdui-linear-progress {
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
@use "material" as *;
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&.alert-success {
|
||||
background-color: #C6F6D5;
|
||||
color: #22543D;
|
||||
}
|
||||
|
||||
&.alert-danger {
|
||||
background-color: #FED7D7;
|
||||
color: #742A2A;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: $color-dark-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
min-width: 150px;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: $color-dark-on-surface;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.pos-top-left {
|
||||
transform-origin: top right;
|
||||
}
|
||||
&.pos-top-right {
|
||||
transform-origin: top left;
|
||||
}
|
||||
&.pos-bottom-left {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
&.pos-bottom-right {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
&.open {
|
||||
animation: context-menu-open 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes context-menu-open {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog content styles
|
||||
.dialog-content {
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
@use "auth";
|
||||
@use "chat";
|
||||
@use "profile";
|
||||
@use "settings";
|
||||
@use "panelchat";
|
||||
@use "common/animations";
|
||||
@use "common/components";
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
@use "electron";
|
||||
|
||||
@use "lib/fonts/montserrat";
|
||||
@use "lib/fonts/material-symbols";
|
||||
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background-color: $color-dark-surface;
|
||||
color: $color-dark-on-surface;
|
||||
line-height: 1.6;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
#main-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
mdui-dialog {
|
||||
> *:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import "../electron.d.ts";
|
||||
import { PRODUCT_NAME } from "./config";
|
||||
|
||||
if (window.electronInterface !== undefined) {
|
||||
console.log("Running in Electron");
|
||||
document.documentElement.classList.add("electron", `platform-${window.electronInterface.platform}`);
|
||||
document.getElementById("window-title")!.textContent = PRODUCT_NAME;
|
||||
} else {
|
||||
console.log("Running in normal browser");
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Application initialization logic
|
||||
* @description Handles initial application setup and state
|
||||
* @author FromChat Team
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { showLogin } from "./auth";
|
||||
import { PRODUCT_NAME } from "./config";
|
||||
|
||||
showLogin();
|
||||
document.getElementById("productname")!.textContent = PRODUCT_NAME;
|
||||
document.title = PRODUCT_NAME;
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Left panel UI controls and interactions
|
||||
* @description Handles chat collapse/expand, chat switching, and profile dialog
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import type { Dialog } from "mdui/components/dialog";
|
||||
import { loadProfilePicture } from "./profile/upload";
|
||||
|
||||
// сварачивание и разворачивание чата
|
||||
const chatCollapseBtn = document.getElementById('hide-chat')!;
|
||||
const chat1 = document.getElementById('chat-list-chat-1')!;
|
||||
const chat2 = document.getElementById('chat-list-chat-2')!;
|
||||
const chatInner = document.getElementById('chat-inner')!;
|
||||
const chatName = document.getElementById('chat-name')!;
|
||||
const profileButton = document.getElementById('profile-open')!;
|
||||
const dialog = document.getElementById("profile-dialog") as Dialog;
|
||||
const dialogClose = document.getElementById("profile-dialog-close")!;
|
||||
|
||||
/**
|
||||
* Sets up chat collapse functionality
|
||||
* @function setupChatCollapse
|
||||
* @private
|
||||
*/
|
||||
function setupChatCollapse(): void {
|
||||
chatCollapseBtn.addEventListener('click', () => {
|
||||
chatCollapseBtn.style.display = 'none';
|
||||
chatInner.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up chat switching functionality
|
||||
* @function setupChatSwitching
|
||||
* @private
|
||||
*/
|
||||
function setupChatSwitching(): void {
|
||||
chat1.addEventListener('click', () => {
|
||||
chatCollapseBtn.style.display = 'flex';
|
||||
chatInner.style.display = 'flex';
|
||||
chatName.textContent = 'общий чат';
|
||||
});
|
||||
|
||||
chat2.addEventListener('click', () => {
|
||||
chatCollapseBtn.style.display = 'flex';
|
||||
chatInner.style.display = 'flex';
|
||||
chatName.textContent = 'общий чат 2';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up profile dialog functionality
|
||||
* @function setupProfileDialog
|
||||
* @private
|
||||
*/
|
||||
function setupProfileDialog(): void {
|
||||
profileButton.addEventListener('click', () => {
|
||||
dialog.open = true;
|
||||
loadProfilePicture();
|
||||
});
|
||||
|
||||
dialogClose.addEventListener("click", () => {
|
||||
dialog.open = false;
|
||||
});
|
||||
}
|
||||
|
||||
setupChatCollapse();
|
||||
setupChatSwitching();
|
||||
setupProfileDialog();
|
||||
@@ -1,338 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Message context menu functionality
|
||||
* @description Handles right-click context menu for message actions (edit, delete, reply)
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { currentUser, authToken } from "./auth";
|
||||
import { websocket } from "./websocket";
|
||||
import type { Message, WebSocketMessage } from "./types";
|
||||
import { showSuccess, showError } from "./utils/notification";
|
||||
import { delay } from "./utils/utils";
|
||||
import type { Dialog } from "mdui/components/dialog";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
|
||||
|
||||
let menu = document.getElementById("message-context-menu")!;
|
||||
let editDialog = document.getElementById("edit-message-dialog") as Dialog;
|
||||
let replyDialog = document.getElementById("reply-message-dialog") as Dialog;
|
||||
let currentMessage: Message | null = null;
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds event listeners
|
||||
* @private
|
||||
*/
|
||||
function bindEvents(): void {
|
||||
// Context menu events
|
||||
menu?.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const action = target.closest('.context-menu-item')?.getAttribute('data-action');
|
||||
|
||||
if (action && currentMessage) {
|
||||
handleAction(action, currentMessage);
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu?.contains(e.target as Node)) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Edit dialog events
|
||||
const editCancelBtn = editDialog?.querySelector('#edit-cancel');
|
||||
const editSaveBtn = editDialog?.querySelector('#edit-save');
|
||||
|
||||
editCancelBtn?.addEventListener('click', () => hideEditDialog());
|
||||
editSaveBtn?.addEventListener('click', () => saveEdit());
|
||||
|
||||
// Reply dialog events
|
||||
const replyCancelBtn = replyDialog?.querySelector('#reply-cancel');
|
||||
const replySendBtn = replyDialog?.querySelector('#reply-send');
|
||||
|
||||
replyCancelBtn?.addEventListener('click', () => hideReplyDialog());
|
||||
replySendBtn?.addEventListener('click', () => sendReply());
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
hide();
|
||||
hideEditDialog();
|
||||
hideReplyDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the context menu at the specified position
|
||||
* @param {Message} message - The message to show menu for
|
||||
* @param {number} x - X coordinate
|
||||
* @param {number} y - Y coordinate
|
||||
*/
|
||||
export function show(message: Message, x: number, y: number): void {
|
||||
currentMessage = message;
|
||||
|
||||
// Show delete for own messages and for owner on any message
|
||||
const editItem = menu.querySelector('[data-action="edit"]') as HTMLElement;
|
||||
const deleteItem = menu.querySelector('[data-action="delete"]') as HTMLElement;
|
||||
|
||||
const isAuthor = message.username === currentUser?.username;
|
||||
const isOwner = !!currentUser?.admin;
|
||||
|
||||
editItem.style.display = isAuthor ? 'flex' : 'none';
|
||||
deleteItem.style.display = (isAuthor || isOwner) ? 'flex' : 'none';
|
||||
|
||||
// Position the menu properly
|
||||
menu.style.display = 'block';
|
||||
|
||||
let menuWidth = menu.offsetWidth;
|
||||
let menuHeight = menu.offsetHeight;
|
||||
|
||||
let adjustedX = x;
|
||||
let adjustedY = y;
|
||||
let vertical = "top";
|
||||
let horizontal = "right";
|
||||
|
||||
// Adjust horizontal position if menu would go off-screen
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
adjustedX = x - menuWidth;
|
||||
horizontal = "left";
|
||||
}
|
||||
|
||||
// Adjust vertical position if menu would go off-screen
|
||||
if (y + menuHeight > window.innerHeight) {
|
||||
adjustedY = y - menuHeight;
|
||||
vertical = "bottom";
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off the left or top edges
|
||||
adjustedX = Math.max(0, adjustedX);
|
||||
adjustedY = Math.max(0, adjustedY);
|
||||
|
||||
menu.style.left = `${adjustedX}px`;
|
||||
menu.style.top = `${adjustedY}px`;
|
||||
menu.classList.add(`pos-${vertical}-${horizontal}`, "open");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the context menu
|
||||
*/
|
||||
export function hide(): void {
|
||||
menu.style.display = 'none';
|
||||
menu.classList.forEach((name) => {
|
||||
if (name.match(/pos-\w+-\w+/)) {
|
||||
menu.classList.remove(name);
|
||||
}
|
||||
})
|
||||
currentMessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles context menu actions
|
||||
* @param {string} action - The action to perform
|
||||
* @param {Message} message - The message to act on
|
||||
* @private
|
||||
*/
|
||||
function handleAction(action: string, message: Message): void {
|
||||
hide();
|
||||
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
showEditDialog(message);
|
||||
break;
|
||||
case 'delete':
|
||||
deleteMessage(message);
|
||||
break;
|
||||
case 'reply':
|
||||
showReplyDialog(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the edit dialog
|
||||
* @param {Message} message - The message to edit
|
||||
* @private
|
||||
*/
|
||||
async function showEditDialog(message: Message): Promise<void> {
|
||||
const textField = editDialog.querySelector('#edit-message-input') as TextField;
|
||||
textField.value = message.content;
|
||||
|
||||
currentMessage = message;
|
||||
editDialog.open = true;
|
||||
|
||||
// Focus the text field
|
||||
await delay(100);
|
||||
textField?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the edit dialog
|
||||
* @private
|
||||
*/
|
||||
function hideEditDialog(): void {
|
||||
editDialog.open = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the edited message
|
||||
* @private
|
||||
*/
|
||||
function saveEdit(): void {
|
||||
if (!currentMessage) return;
|
||||
|
||||
const textField = editDialog.querySelector('#edit-message-input') as TextField;
|
||||
const newContent = textField?.value?.trim() || '';
|
||||
|
||||
if (!newContent) {
|
||||
showError('Message cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: WebSocketMessage = {
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: currentMessage.id,
|
||||
content: newContent
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken!
|
||||
}
|
||||
};
|
||||
|
||||
let callback: ((e: MessageEvent) => void) | null = null;
|
||||
callback = (e) => {
|
||||
websocket.removeEventListener("message", callback!);
|
||||
const response: WebSocketMessage = JSON.parse(e.data);
|
||||
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Message edited successfully');
|
||||
hideEditDialog();
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("message", callback);
|
||||
websocket.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the reply dialog
|
||||
* @param {Message} message - The message to reply to
|
||||
* @private
|
||||
*/
|
||||
async function showReplyDialog(message: Message): Promise<void> {
|
||||
const preview = replyDialog.querySelector('#reply-preview') as HTMLElement;
|
||||
preview.innerHTML = `
|
||||
<div class="reply-preview-content">
|
||||
<strong>${message.username}</strong>: ${message.content}
|
||||
</div>
|
||||
`;
|
||||
|
||||
currentMessage = message;
|
||||
replyDialog.open = true;
|
||||
|
||||
// Focus the text field
|
||||
await delay(100);
|
||||
const textField = replyDialog?.querySelector('#reply-message-input') as TextField;
|
||||
textField?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the reply dialog
|
||||
* @private
|
||||
*/
|
||||
function hideReplyDialog(): void {
|
||||
replyDialog.open = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the reply message
|
||||
* @private
|
||||
*/
|
||||
function sendReply(): void {
|
||||
if (!currentMessage) return;
|
||||
|
||||
const textField = replyDialog.querySelector('#reply-message-input') as TextField;
|
||||
const content = textField?.value?.trim() || '';
|
||||
|
||||
if (!content) {
|
||||
showError('Reply cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: WebSocketMessage = {
|
||||
type: "replyMessage",
|
||||
data: {
|
||||
content: content,
|
||||
reply_to_id: currentMessage.id
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken!
|
||||
}
|
||||
};
|
||||
|
||||
let callback: ((e: MessageEvent) => void) | null = null;
|
||||
callback = (e) => {
|
||||
websocket.removeEventListener("message", callback!);
|
||||
const response: WebSocketMessage = JSON.parse(e.data);
|
||||
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Reply sent successfully');
|
||||
hideReplyDialog();
|
||||
if (textField) {
|
||||
textField.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("message", callback);
|
||||
websocket.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a message
|
||||
* @param {Message} message - The message to delete
|
||||
* @private
|
||||
*/
|
||||
function deleteMessage(message: Message): void {
|
||||
if (!confirm('Are you sure you want to delete this message?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: WebSocketMessage = {
|
||||
type: "deleteMessage",
|
||||
data: {
|
||||
message_id: message.id
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken!
|
||||
}
|
||||
};
|
||||
|
||||
let callback: ((e: MessageEvent) => void) | null = null;
|
||||
callback = (e) => {
|
||||
websocket.removeEventListener("message", callback!);
|
||||
const response: WebSocketMessage = JSON.parse(e.data);
|
||||
|
||||
if (response.error) {
|
||||
showError(response.error.detail);
|
||||
} else {
|
||||
showSuccess('Message deleted successfully');
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("message", callback);
|
||||
websocket.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Profile module entry point and initialization
|
||||
* @description Coordinates profile system initialization and form handling
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import type { Dialog } from "mdui/components/dialog";
|
||||
import { loadProfileData } from './profile/editor';
|
||||
import { loadProfilePicture, initializeProfileUpload } from "./profile/upload";
|
||||
import { initializeProfileEditor } from './profile/editor';
|
||||
|
||||
// Handle profile form submission
|
||||
const form = document.getElementById("profile-form")!;
|
||||
const dialog = document.getElementById("profile-dialog") as Dialog;
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// TODO: Process form data if needed
|
||||
// For now, just close the dialog
|
||||
dialog.open = false;
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes profile functionality after user login
|
||||
* @function initializeProfile
|
||||
* @example
|
||||
* // Called after successful authentication
|
||||
* initializeProfile();
|
||||
*/
|
||||
export function initializeProfile(): void {
|
||||
// Initialize profile modules
|
||||
initializeProfileUpload();
|
||||
initializeProfileEditor();
|
||||
|
||||
// Load profile data
|
||||
Promise.all([
|
||||
loadProfilePicture(),
|
||||
loadProfileData()
|
||||
]).catch(error => {
|
||||
console.error('Error initializing profile:', error);
|
||||
});
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Profile-related API calls
|
||||
* @description Handles all profile-related HTTP requests to the backend
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { getAuthHeaders } from '../auth';
|
||||
import type { ProfileData, UploadResponse } from './types';
|
||||
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
* @async
|
||||
* @function loadProfile
|
||||
* @returns {Promise<ProfileData | null>} User profile data or null if failed
|
||||
* @example
|
||||
* const profile = await loadProfile();
|
||||
* if (profile) {
|
||||
* console.log('User nickname:', profile.nickname);
|
||||
* }
|
||||
*/
|
||||
export async function loadProfile(): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch('/api/user/profile', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile picture to the server
|
||||
* @async
|
||||
* @function uploadProfilePicture
|
||||
* @param {Blob} file - The image file to upload
|
||||
* @returns {Promise<UploadResponse | null>} Upload response with URL or null if failed
|
||||
* @example
|
||||
* const fileInput = document.getElementById('file-input');
|
||||
* const file = fileInput.files[0];
|
||||
* const result = await uploadProfilePicture(file);
|
||||
* if (result) {
|
||||
* console.log('Uploaded to:', result.profile_picture_url);
|
||||
* }
|
||||
*/
|
||||
export async function uploadProfilePicture(file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||
|
||||
const response = await fetch('/api/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: getAuthHeaders(false)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user profile information
|
||||
* @async
|
||||
* @function updateProfile
|
||||
* @param {Partial<ProfileData>} data - Profile data to update
|
||||
* @returns {Promise<boolean>} True if update was successful, false otherwise
|
||||
* @example
|
||||
* const success = await updateProfile({
|
||||
* nickname: 'New Name',
|
||||
* description: 'Updated bio'
|
||||
* });
|
||||
* if (success) {
|
||||
* console.log('Profile updated successfully');
|
||||
* }
|
||||
*/
|
||||
export async function updateProfile(data: Partial<ProfileData>): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user bio
|
||||
* @async
|
||||
* @function updateBio
|
||||
* @param {string} bio - New bio text
|
||||
* @returns {Promise<boolean>} True if update was successful, false otherwise
|
||||
* @example
|
||||
* const success = await updateBio('My new bio text');
|
||||
* if (success) {
|
||||
* console.log('Bio updated successfully');
|
||||
* }
|
||||
*/
|
||||
export async function updateBio(bio: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/api/user/bio', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Profile editing functionality
|
||||
* @description Handles profile form editing and MDUI text field integration
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { updateProfile } from './api';
|
||||
import { loadProfile } from './api';
|
||||
import { showSuccess, showError } from '../utils/notification';
|
||||
import type { TextField } from 'mdui/components/text-field';
|
||||
|
||||
let profileForm = document.getElementById('profile-form')!;
|
||||
let nicknameField = document.getElementById('username-field') as unknown as TextField;
|
||||
let descriptionField = document.getElementById('description-field') as unknown as TextField;
|
||||
|
||||
/**
|
||||
* Initialization state flag
|
||||
* @type {boolean}
|
||||
*/
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* Sets the username field value
|
||||
* @param {string} value - The username value to set
|
||||
* @function setUsernameValue
|
||||
* @example
|
||||
* setUsernameValue('John Doe');
|
||||
*/
|
||||
export function setUsernameValue(value: string): void {
|
||||
if (nicknameField && nicknameField.value !== undefined) {
|
||||
nicknameField.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the description field value
|
||||
* @param {string} value - The description value to set
|
||||
* @function setDescriptionValue
|
||||
* @example
|
||||
* setDescriptionValue('Software Developer');
|
||||
*/
|
||||
export function setDescriptionValue(value: string): void {
|
||||
if (descriptionField && descriptionField.value !== undefined) {
|
||||
descriptionField.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current username field value
|
||||
* @returns {string} The current username value
|
||||
* @function getUsernameValue
|
||||
* @example
|
||||
* const username = getUsernameValue();
|
||||
* console.log('Current username:', username);
|
||||
*/
|
||||
export function getUsernameValue(): string {
|
||||
if (nicknameField && nicknameField.value !== undefined) {
|
||||
return nicknameField.value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current description field value
|
||||
* @returns {string} The current description value
|
||||
* @function getDescriptionValue
|
||||
* @example
|
||||
* const description = getDescriptionValue();
|
||||
* console.log('Current description:', description);
|
||||
*/
|
||||
export function getDescriptionValue(): string {
|
||||
if (descriptionField && descriptionField.value !== undefined) {
|
||||
return descriptionField.value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads profile data from the server and populates the form fields
|
||||
* @async
|
||||
* @function loadProfileData
|
||||
* @example
|
||||
* await loadProfileData();
|
||||
*/
|
||||
export async function loadProfileData(): Promise<void> {
|
||||
const userData = await loadProfile();
|
||||
if (userData) {
|
||||
if (userData.nickname) {
|
||||
setUsernameValue(userData.nickname);
|
||||
}
|
||||
if (userData.description) {
|
||||
setDescriptionValue(userData.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles profile form submission
|
||||
* @async
|
||||
* @function handleFormSubmission
|
||||
* @param {Event} e - Form submission event
|
||||
* @private
|
||||
*/
|
||||
async function handleFormSubmission(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
|
||||
const nickname = getUsernameValue();
|
||||
const description = getDescriptionValue();
|
||||
|
||||
if (nickname || description) {
|
||||
const success = await updateProfile({
|
||||
nickname: nickname || undefined,
|
||||
description: description || undefined
|
||||
});
|
||||
|
||||
if (success) {
|
||||
showSuccess('Профиль обновлен!');
|
||||
} else {
|
||||
showError('Ошибка при обновлении профиля');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up form submission handler
|
||||
* @function setupFormHandler
|
||||
* @private
|
||||
*/
|
||||
function setupFormHandler(): void {
|
||||
if (isInitialized) return;
|
||||
|
||||
// Get DOM elements
|
||||
profileForm = document.getElementById('profile-form')!;
|
||||
nicknameField = document.getElementById('username-field') as any;
|
||||
descriptionField = document.getElementById('description-field') as any;
|
||||
|
||||
profileForm.addEventListener('submit', handleFormSubmission);
|
||||
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes profile editor functionality
|
||||
* @function initializeProfileEditor
|
||||
* @example
|
||||
* initializeProfileEditor();
|
||||
*/
|
||||
export function initializeProfileEditor(): void {
|
||||
setupFormHandler();
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Canvas-based image cropping component
|
||||
* @description Provides circular image cropping functionality with drag support
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import type { Size2D } from "../types";
|
||||
|
||||
/**
|
||||
* Image cropper class for circular profile picture cropping
|
||||
* @class ImageCropper
|
||||
*/
|
||||
export class ImageCropper {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
|
||||
/**
|
||||
* Image element to be cropped
|
||||
* @type {HTMLImageElement}
|
||||
* @private
|
||||
*/
|
||||
private image!: HTMLImageElement;
|
||||
|
||||
/**
|
||||
* Size of the crop area (diameter)
|
||||
* @type {number}
|
||||
* @private
|
||||
*/
|
||||
private cropSize: number = 200;
|
||||
private isDragging: boolean = false;
|
||||
|
||||
/**
|
||||
* Starting position of the drag operation
|
||||
* @type {Size2D}
|
||||
* @private
|
||||
*/
|
||||
private dragStart: Size2D = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* Current position of the crop area
|
||||
* @type {Size2D}
|
||||
* @private
|
||||
*/
|
||||
private cropPosition: Size2D = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* Creates a new ImageCropper instance
|
||||
* @param {HTMLElement} container - Container element to append the canvas to
|
||||
* @constructor
|
||||
* @example
|
||||
* const cropper = new ImageCropper(document.getElementById('cropper-area'));
|
||||
*/
|
||||
constructor(container: HTMLElement) {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.width = this.cropSize;
|
||||
this.canvas.height = this.cropSize;
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
|
||||
container.appendChild(this.canvas);
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up mouse and touch event listeners
|
||||
* @function setupEventListeners
|
||||
* @private
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
|
||||
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
|
||||
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
|
||||
this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this));
|
||||
this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this));
|
||||
this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mouse down events
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
* @function onMouseDown
|
||||
* @private
|
||||
*/
|
||||
private onMouseDown(e: MouseEvent): void {
|
||||
this.isDragging = true;
|
||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mouse move events during dragging
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
* @function onMouseMove
|
||||
* @private
|
||||
*/
|
||||
private onMouseMove(e: MouseEvent): void {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const deltaX = e.clientX - this.dragStart.x;
|
||||
const deltaY = e.clientY - this.dragStart.y;
|
||||
|
||||
this.cropPosition.x += deltaX;
|
||||
this.cropPosition.y += deltaY;
|
||||
|
||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mouse up events
|
||||
* @function onMouseUp
|
||||
* @private
|
||||
*/
|
||||
private onMouseUp(): void {
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles touch start events
|
||||
* @param {TouchEvent} e - Touch event
|
||||
* @function onTouchStart
|
||||
* @private
|
||||
*/
|
||||
private onTouchStart(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.isDragging = true;
|
||||
this.dragStart = { x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles touch move events during dragging
|
||||
* @param {TouchEvent} e - Touch event
|
||||
* @function onTouchMove
|
||||
* @private
|
||||
*/
|
||||
private onTouchMove(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const deltaX = touch.clientX - this.dragStart.x;
|
||||
const deltaY = touch.clientY - this.dragStart.y;
|
||||
|
||||
this.cropPosition.x += deltaX;
|
||||
this.cropPosition.y += deltaY;
|
||||
|
||||
this.dragStart = { x: touch.clientX, y: touch.clientY };
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles touch end events
|
||||
* @function onTouchEnd
|
||||
* @private
|
||||
*/
|
||||
private onTouchEnd(): void {
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an image file for cropping
|
||||
* @param {File} file - Image file to load
|
||||
* @returns {Promise<void>} Promise that resolves when image is loaded
|
||||
* @async
|
||||
* @example
|
||||
* await cropper.loadImage(fileInput.files[0]);
|
||||
*/
|
||||
loadImage(file: File): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.image = new Image();
|
||||
this.image.onload = () => {
|
||||
this.render();
|
||||
resolve();
|
||||
};
|
||||
this.image.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the image with circular crop overlay
|
||||
* @function render
|
||||
* @private
|
||||
*/
|
||||
private render(): void {
|
||||
if (!this.image) return;
|
||||
|
||||
// Clear canvas
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Calculate crop area
|
||||
const scale = Math.max(this.cropSize / this.image.width, this.cropSize / this.image.height);
|
||||
const scaledWidth = this.image.width * scale;
|
||||
const scaledHeight = this.image.height * scale;
|
||||
|
||||
// Draw image
|
||||
this.ctx.save();
|
||||
this.ctx.globalCompositeOperation = 'source-over';
|
||||
this.ctx.drawImage(
|
||||
this.image,
|
||||
this.cropPosition.x,
|
||||
this.cropPosition.y,
|
||||
scaledWidth,
|
||||
scaledHeight
|
||||
);
|
||||
this.ctx.restore();
|
||||
|
||||
// Draw crop overlay
|
||||
this.ctx.save();
|
||||
this.ctx.globalCompositeOperation = 'destination-in';
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(this.cropSize / 2, this.cropSize / 2, this.cropSize / 2, 0, 2 * Math.PI);
|
||||
this.ctx.fill();
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cropped image as a data URL
|
||||
* @returns {string} Data URL of the cropped image
|
||||
* @example
|
||||
* const croppedImage = cropper.getCroppedImage();
|
||||
* // Use croppedImage as src for an img element
|
||||
*/
|
||||
getCroppedImage(): string {
|
||||
return this.canvas.toDataURL('image/jpeg', 0.8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the cropper and removes the canvas from DOM
|
||||
* @function destroy
|
||||
* @example
|
||||
* cropper.destroy();
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.canvas.parentNode) {
|
||||
this.canvas.parentNode.removeChild(this.canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Profile-specific type definitions
|
||||
* @description Contains type definitions for profile-related functionality
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* User profile data structure
|
||||
* @interface ProfileData
|
||||
* @property {string} [profile_picture] - URL to user's profile picture
|
||||
* @property {string} [nickname] - User's display name
|
||||
* @property {string} [description] - User's bio or description
|
||||
*/
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
nickname?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile picture upload response structure
|
||||
* @interface UploadResponse
|
||||
* @property {string} profile_picture_url - URL to the uploaded profile picture
|
||||
*/
|
||||
export interface UploadResponse {
|
||||
profile_picture_url: string;
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Profile picture upload functionality
|
||||
* @description Handles file selection, image cropping, and profile picture upload
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import type { Dialog } from "mdui/components/dialog";
|
||||
import { ImageCropper } from './image-cropper';
|
||||
import { uploadProfilePicture } from './api';
|
||||
import { loadProfile } from './api';
|
||||
import { showSuccess, showError } from '../utils/notification';
|
||||
|
||||
/**
|
||||
* Global image cropper instance
|
||||
* @type {ImageCropper | null}
|
||||
*/
|
||||
let cropper: ImageCropper | null = null;
|
||||
|
||||
/**
|
||||
* Initialization state flag
|
||||
* @type {boolean}
|
||||
*/
|
||||
let isInitialized = false;
|
||||
|
||||
let cropperDialog = document.getElementById('cropper-dialog') as Dialog;
|
||||
let fileInput = document.getElementById('pfp-file-input') as HTMLInputElement;
|
||||
let uploadBtn = document.getElementById('upload-pfp-btn')!;
|
||||
let cropSaveBtn = document.getElementById('crop-save')!;
|
||||
let cropCancelBtn = document.getElementById('crop-cancel')!;
|
||||
let cropperCloseBtn = document.getElementById('cropper-close')!;
|
||||
let cropperArea = document.getElementById('cropper-area')!;
|
||||
|
||||
/**
|
||||
* Opens the image cropper with the selected file
|
||||
* @async
|
||||
* @function openCropper
|
||||
* @param {File} file - The image file to crop
|
||||
* @private
|
||||
*/
|
||||
async function openCropper(file: File): Promise<void> {
|
||||
// Clear previous cropper
|
||||
cropperArea.innerHTML = '';
|
||||
|
||||
// Create new cropper
|
||||
cropper = new ImageCropper(cropperArea);
|
||||
|
||||
// Load image
|
||||
await cropper.loadImage(file);
|
||||
|
||||
// Open dialog
|
||||
cropperDialog.open = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the image cropper and cleans up resources
|
||||
* @function closeCropper
|
||||
* @private
|
||||
*/
|
||||
function closeCropper(): void {
|
||||
cropperDialog.open = false;
|
||||
cropperArea.innerHTML = '';
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
cropper = null;
|
||||
}
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the cropped image and uploads it to the server
|
||||
* @async
|
||||
* @function saveCroppedImage
|
||||
* @private
|
||||
*/
|
||||
async function saveCroppedImage(): Promise<void> {
|
||||
if (!cropper) return;
|
||||
|
||||
const croppedImageData = cropper.getCroppedImage();
|
||||
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(croppedImageData);
|
||||
const blob = await response.blob();
|
||||
|
||||
const result = await uploadProfilePicture(blob);
|
||||
|
||||
if (result) {
|
||||
// Update profile picture display
|
||||
const profilePicture = document.getElementById('profile-picture') as HTMLImageElement;
|
||||
profilePicture.src = result.profile_picture_url + '?t=' + Date.now(); // Cache bust
|
||||
|
||||
// Close cropper
|
||||
closeCropper();
|
||||
|
||||
// Show success message
|
||||
showSuccess('Фото профиля обновлено!');
|
||||
} else {
|
||||
showError('Ошибка при загрузке фото');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners for upload functionality
|
||||
* @function setupEventListeners
|
||||
* @private
|
||||
*/
|
||||
function setupEventListeners(): void {
|
||||
if (isInitialized) return;
|
||||
|
||||
uploadBtn.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
openCropper(file);
|
||||
}
|
||||
});
|
||||
|
||||
cropSaveBtn.addEventListener('click', () => {
|
||||
saveCroppedImage();
|
||||
});
|
||||
|
||||
cropCancelBtn.addEventListener('click', () => {
|
||||
closeCropper();
|
||||
});
|
||||
|
||||
cropperCloseBtn.addEventListener('click', () => {
|
||||
closeCropper();
|
||||
});
|
||||
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and displays the user's profile picture
|
||||
* @async
|
||||
* @function loadProfilePicture
|
||||
* @example
|
||||
* await loadProfilePicture();
|
||||
*/
|
||||
export async function loadProfilePicture(): Promise<void> {
|
||||
const userData = await loadProfile();
|
||||
if (userData?.profile_picture) {
|
||||
const url = `${userData.profile_picture}?t=${Date.now()}`;
|
||||
|
||||
const profilePicture = document.getElementById('profile-picture') as HTMLImageElement;
|
||||
const profilePicture2 = document.getElementById("preview1") as HTMLImageElement;
|
||||
profilePicture.src = url;
|
||||
profilePicture2.src = url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes profile upload functionality
|
||||
* @function initializeProfileUpload
|
||||
* @example
|
||||
* initializeProfileUpload();
|
||||
*/
|
||||
export function initializeProfileUpload(): void {
|
||||
setupEventListeners();
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Settings dialog management and panel navigation
|
||||
* @description Handles settings dialog functionality and dynamic panel switching
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import type { Dialog } from "mdui/components/dialog";
|
||||
|
||||
const dialog = document.getElementById('settings-dialog') as Dialog;
|
||||
const openButton = document.getElementById('settings-open')!;
|
||||
const closeButton = document.getElementById('settings-close')!;
|
||||
|
||||
// Settings panel management
|
||||
const settingsList = document.querySelector('#settings-menu mdui-list')!;
|
||||
const settingsPanels = document.querySelectorAll('.settings-panel');
|
||||
|
||||
/**
|
||||
* Mapping between list item text and their corresponding panel IDs
|
||||
* @type {Object.<string, string>}
|
||||
*/
|
||||
const panelMapping = {
|
||||
'Уведомления': 'notifications-settings',
|
||||
'Внешний вид': 'appearance-settings',
|
||||
'Безопасность': 'security-settings',
|
||||
'Язык': 'language-settings',
|
||||
'Хранилище': 'storage-settings',
|
||||
'Помощь': 'help-settings',
|
||||
'О приложении': 'about-settings'
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles click events on settings list items
|
||||
* @param {Element} item - The clicked list item element
|
||||
* @function handleListItemClick
|
||||
* @private
|
||||
*/
|
||||
function handleListItemClick(item: Element): void {
|
||||
// Remove active class from all items and panels
|
||||
const listItems = settingsList.querySelectorAll('mdui-list-item');
|
||||
listItems.forEach(li => li.removeAttribute('active'));
|
||||
settingsPanels.forEach(panel => panel.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked item
|
||||
item.setAttribute('active', '');
|
||||
|
||||
// Show corresponding panel using the mapping
|
||||
const itemText = item.textContent?.trim();
|
||||
const panelId = panelMapping[itemText as keyof typeof panelMapping];
|
||||
|
||||
if (panelId) {
|
||||
const targetPanel = document.getElementById(panelId);
|
||||
if (targetPanel) {
|
||||
targetPanel.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up click listeners for all settings list items
|
||||
* @function setupSettingsNavigation
|
||||
* @private
|
||||
*/
|
||||
function setupSettingsNavigation(): void {
|
||||
const listItems = settingsList.querySelectorAll('mdui-list-item');
|
||||
listItems.forEach((item) => {
|
||||
item.addEventListener('click', () => handleListItemClick(item));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets settings dialog to show the first panel
|
||||
* @function resetToFirstPanel
|
||||
* @private
|
||||
*/
|
||||
function resetToFirstPanel(): void {
|
||||
const firstItem = settingsList.querySelector('mdui-list-item');
|
||||
const firstPanel = document.querySelector('.settings-panel');
|
||||
if (firstItem && firstPanel) {
|
||||
settingsList.querySelectorAll('mdui-list-item').forEach(li => li.removeAttribute('active'));
|
||||
settingsPanels.forEach(panel => panel.classList.remove('active'));
|
||||
firstItem.setAttribute('active', '');
|
||||
firstPanel.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up dialog event listeners
|
||||
* @function setupDialogListeners
|
||||
* @private
|
||||
*/
|
||||
function setupDialogListeners(): void {
|
||||
openButton.addEventListener('click', () => {
|
||||
dialog.open = true;
|
||||
resetToFirstPanel();
|
||||
});
|
||||
|
||||
closeButton.addEventListener('click', () => {
|
||||
dialog.open = false;
|
||||
});
|
||||
}
|
||||
|
||||
setupSettingsNavigation();
|
||||
setupDialogListeners();
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Global TypeScript type definitions
|
||||
* @description Contains all type definitions used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTTP headers object type
|
||||
* @typedef {Object.<string, string>} Headers
|
||||
*/
|
||||
export type Headers = {[x: string]: string}
|
||||
|
||||
/**
|
||||
* API error response structure
|
||||
* @interface ErrorResponse
|
||||
* @property {string} message - Error message from the server
|
||||
*/
|
||||
export interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D coordinate structure
|
||||
* @interface Size2D
|
||||
* @property {number} x - X coordinate
|
||||
* @property {number} y - Y coordinate
|
||||
*/
|
||||
export interface Size2D {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// App types
|
||||
|
||||
/**
|
||||
* Chat message structure
|
||||
* @interface Message
|
||||
* @property {number} id - Unique message identifier
|
||||
* @property {string} username - Username of the message sender
|
||||
* @property {string} content - Message content
|
||||
* @property {boolean} is_read - Whether the message has been read
|
||||
* @property {boolean} is_edited - Whether the message has been edited
|
||||
* @property {string} timestamp - ISO timestamp of the message
|
||||
* @property {string} [profile_picture] - URL to sender's profile picture
|
||||
* @property {Message} [reply_to] - The message this is replying to
|
||||
*/
|
||||
export interface Message {
|
||||
id: number;
|
||||
username: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
is_edited: boolean;
|
||||
timestamp: string;
|
||||
profile_picture?: string;
|
||||
reply_to?: Message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of messages
|
||||
* @interface Messages
|
||||
* @property {Message[]} messages - Array of message objects
|
||||
*/
|
||||
export interface Messages {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* User information structure
|
||||
* @interface User
|
||||
* @property {number} id - Unique user identifier
|
||||
* @property {string} created_at - ISO timestamp of account creation
|
||||
* @property {string} last_seen - ISO timestamp of last activity
|
||||
* @property {boolean} online - Whether the user is currently online
|
||||
* @property {string} username - Username
|
||||
* @property {string} [bio] - User biography
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
created_at: string;
|
||||
last_seen: string;
|
||||
online: boolean;
|
||||
username: string;
|
||||
admin?: boolean;
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User profile response structure
|
||||
* @interface UserProfile
|
||||
* @property {number} id - Unique user identifier
|
||||
* @property {string} username - Username
|
||||
* @property {string} [profile_picture] - URL to user's profile picture
|
||||
* @property {string} [bio] - User biography
|
||||
* @property {boolean} online - Whether the user is currently online
|
||||
* @property {string} last_seen - ISO timestamp of last activity
|
||||
* @property {string} created_at - ISO timestamp of account creation
|
||||
*/
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
profile_picture?: string;
|
||||
bio?: string;
|
||||
online: boolean;
|
||||
last_seen: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ----------
|
||||
// API models
|
||||
// ----------
|
||||
|
||||
// Requests
|
||||
|
||||
/**
|
||||
* Login request structure
|
||||
* @interface LoginRequest
|
||||
* @property {string} username - Username for authentication
|
||||
* @property {string} password - Password for authentication
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration request structure
|
||||
* @interface RegisterRequest
|
||||
* @property {string} username - Desired username
|
||||
* @property {string} password - Desired password
|
||||
* @property {string} confirm_password - Password confirmation
|
||||
*/
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
// Responses
|
||||
|
||||
/**
|
||||
* Login response structure
|
||||
* @interface LoginResponse
|
||||
* @property {User} user - User information
|
||||
* @property {string} token - JWT authentication token
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// ---------------
|
||||
// WebSocket types
|
||||
// ---------------
|
||||
|
||||
/**
|
||||
* WebSocket message structure
|
||||
* @interface WebSocketMessage
|
||||
* @property {string} type - Message type identifier
|
||||
* @property {WebSocketCredentials} [credentials] - Authentication credentials
|
||||
* @property {any} [data] - Message payload data
|
||||
* @property {WebSocketError} [error] - Error information if applicable
|
||||
*/
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
credentials?: WebSocketCredentials;
|
||||
data?: any;
|
||||
error?: WebSocketError;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket error structure
|
||||
* @interface WebSocketError
|
||||
* @property {number} code - Error code
|
||||
* @property {string} detail - Error detail message
|
||||
*/
|
||||
export interface WebSocketError {
|
||||
code: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket authentication credentials
|
||||
* @interface WebSocketCredentials
|
||||
* @property {string} scheme - Authentication scheme (e.g., "Bearer")
|
||||
* @property {string} credentials - Authentication token or credentials
|
||||
*/
|
||||
export interface WebSocketCredentials {
|
||||
scheme: string;
|
||||
credentials: string;
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* @fileoverview User profile dialog functionality
|
||||
* @description Handles displaying user profiles in a modal dialog
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { getAuthHeaders, currentUser } from "./auth";
|
||||
import { API_BASE_URL } from "./config";
|
||||
import type { UserProfile } from "./types";
|
||||
import { showError, showSuccess } from "./utils/notification";
|
||||
import { formatTime } from "./utils/utils";
|
||||
import defaultAvatar from "./images/default-avatar.png";
|
||||
|
||||
|
||||
let dialog = document.getElementById("user-profile-dialog")!;
|
||||
let currentProfile: UserProfile | null = null;
|
||||
let isOwnProfile: boolean = false;
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds event listeners
|
||||
* @private
|
||||
*/
|
||||
function bindEvents(): void {
|
||||
// Edit bio events
|
||||
const editBioBtn = dialog?.querySelector('#edit-bio-btn');
|
||||
const saveBioBtn = dialog?.querySelector('#save-bio-btn');
|
||||
const cancelBioBtn = dialog?.querySelector('#cancel-bio-btn');
|
||||
|
||||
editBioBtn?.addEventListener('click', () => startEditBio());
|
||||
saveBioBtn?.addEventListener('click', () => saveBio());
|
||||
cancelBioBtn?.addEventListener('click', () => cancelEditBio());
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && dialog?.getAttribute('open') !== null) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the profile dialog for a specific user
|
||||
* @param {string} username - Username to show profile for
|
||||
*/
|
||||
export async function show(username: string): Promise<void> {
|
||||
if (!dialog) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load user profile');
|
||||
}
|
||||
|
||||
const profile: UserProfile = await response.json();
|
||||
currentProfile = profile;
|
||||
isOwnProfile = profile.username === currentUser?.username;
|
||||
populateDialog(profile);
|
||||
(dialog as any).open = true;
|
||||
|
||||
} catch (error) {
|
||||
showError('Failed to load user profile');
|
||||
console.error('Error loading user profile:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the dialog with user data
|
||||
* @param {UserProfile} profile - User profile data
|
||||
* @private
|
||||
*/
|
||||
function populateDialog(profile: UserProfile): void {
|
||||
if (!dialog) return;
|
||||
|
||||
// Profile picture
|
||||
const profilePic = dialog.querySelector('.profile-picture') as HTMLImageElement;
|
||||
profilePic.src = profile.profile_picture || defaultAvatar;
|
||||
|
||||
let errorLock = false
|
||||
|
||||
profilePic.addEventListener("error", () => {
|
||||
if (!errorLock) {
|
||||
profilePic.src = defaultAvatar;
|
||||
errorLock = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Username
|
||||
const usernameEl = dialog.querySelector('.username') as HTMLElement;
|
||||
usernameEl.textContent = profile.username;
|
||||
|
||||
// Online status
|
||||
const onlineStatus = dialog.querySelector('.online-status') as HTMLElement;
|
||||
if (profile.online) {
|
||||
onlineStatus.innerHTML = '<span class="online-indicator"></span> Online';
|
||||
onlineStatus.classList.add("online-status", "online");
|
||||
} else {
|
||||
onlineStatus.innerHTML = `<span class="offline-indicator"></span> Last seen ${formatTime(profile.last_seen)}`;
|
||||
onlineStatus.classList.add("online-status", "offline");
|
||||
}
|
||||
|
||||
// Bio
|
||||
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
|
||||
const bioEdit = dialog.querySelector('#bio-edit-field') as any;
|
||||
|
||||
if (profile.bio) {
|
||||
bioDisplay.textContent = profile.bio;
|
||||
} else {
|
||||
bioDisplay.textContent = isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.';
|
||||
}
|
||||
|
||||
if (bioEdit) {
|
||||
bioEdit.value = profile.bio || '';
|
||||
}
|
||||
|
||||
// Stats
|
||||
const memberSince = dialog.querySelector('.member-since') as HTMLElement;
|
||||
const lastSeen = dialog.querySelector('.last-seen') as HTMLElement;
|
||||
|
||||
memberSince.textContent = formatTime(profile.created_at);
|
||||
lastSeen.textContent = formatTime(profile.last_seen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts editing the bio
|
||||
* @private
|
||||
*/
|
||||
function startEditBio(): void {
|
||||
if (!dialog) return;
|
||||
|
||||
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
|
||||
const bioEdit = dialog.querySelector('#bio-edit-field') as any;
|
||||
const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
|
||||
const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
|
||||
|
||||
bioDisplay.style.display = 'none';
|
||||
if (bioEdit) bioEdit.style.display = 'block';
|
||||
bioActions.style.display = 'flex';
|
||||
editBioBtn.style.display = 'none';
|
||||
|
||||
if (bioEdit) {
|
||||
bioEdit.focus();
|
||||
bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the bio
|
||||
* @private
|
||||
*/
|
||||
export async function saveBio(): Promise<void> {
|
||||
if (!dialog || !currentProfile) return;
|
||||
|
||||
const bioEdit = dialog.querySelector('#bio-edit-field') as any;
|
||||
const newBio = bioEdit?.value?.trim() || '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ bio: newBio })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update bio');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
currentProfile.bio = result.bio;
|
||||
populateDialog(currentProfile);
|
||||
cancelEditBio();
|
||||
showSuccess('Bio updated successfully');
|
||||
|
||||
} catch (error) {
|
||||
showError('Failed to update bio');
|
||||
console.error('Error updating bio:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels bio editing
|
||||
* @private
|
||||
*/
|
||||
function cancelEditBio(): void {
|
||||
if (!dialog) return;
|
||||
|
||||
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
|
||||
const bioEdit = dialog.querySelector('#bio-edit-field') as any;
|
||||
const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
|
||||
const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
|
||||
|
||||
bioDisplay.style.display = 'block';
|
||||
if (bioEdit) bioEdit.style.display = 'none';
|
||||
bioActions.style.display = 'none';
|
||||
editBioBtn.style.display = isOwnProfile ? 'block' : 'none';
|
||||
|
||||
// Reset bio edit to current value
|
||||
if (bioEdit) {
|
||||
bioEdit.value = currentProfile?.bio || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the dialog
|
||||
*/
|
||||
export function hide(): void {
|
||||
if (dialog) {
|
||||
(dialog as any).open = false;
|
||||
}
|
||||
currentProfile = null;
|
||||
isOwnProfile = false;
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* @fileoverview MDUI component imports and configuration
|
||||
* @description Imports all required MDUI components and sets up the theme
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import 'mdui/components/tabs';
|
||||
import 'mdui/components/tab';
|
||||
import 'mdui/components/tab-panel';
|
||||
import 'mdui/components/list';
|
||||
import 'mdui/components/list-item';
|
||||
import 'mdui/components/bottom-app-bar';
|
||||
import 'mdui/components/button-icon';
|
||||
import 'mdui/components/fab';
|
||||
import 'mdui/components/dialog';
|
||||
import 'mdui/components/button';
|
||||
import 'mdui/components/text-field';
|
||||
import 'mdui/components/button-icon';
|
||||
import 'mdui/components/top-app-bar';
|
||||
import 'mdui/components/top-app-bar-title';
|
||||
import 'mdui/components/dropdown.js';
|
||||
import 'mdui/components/menu.js';
|
||||
import 'mdui/components/menu-item.js';
|
||||
|
||||
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
||||
|
||||
setColorScheme("#91cef4");
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Utility functions used throughout the application
|
||||
* @description Contains helper functions for common operations
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats a timestamp string to HH:MM format
|
||||
* @param {string} dateString - ISO timestamp string to format
|
||||
* @returns {string} Formatted time string in HH:MM format
|
||||
* @example
|
||||
* formatTime('2024-01-15T14:30:00Z'); // Returns "14:30"
|
||||
*/
|
||||
export function formatTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
let hours = date.getHours();
|
||||
let minutes = date.getMinutes();
|
||||
const hoursString = hours < 10 ? '0' + hours : hours;
|
||||
const minutesString = minutes < 10 ? '0' + minutes : minutes;
|
||||
return hoursString + ':' + minutesString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise that resolves after a specified delay
|
||||
* @param {number} ms - Delay time in milliseconds
|
||||
* @returns {Promise<void>} Promise that resolves after the delay
|
||||
* @example
|
||||
* await delay(1000); // Wait for 1 second
|
||||
*/
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* @fileoverview WebSocket connection management for real-time chat
|
||||
* @description Handles WebSocket connections, message processing, and auto-reconnection
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { handleWebSocketMessage } from "./chat";
|
||||
import { API_FULL_BASE_URL } from "./config";
|
||||
import type { WebSocketMessage } from "./types";
|
||||
import { delay } from "./utils/utils";
|
||||
|
||||
/**
|
||||
* Creates a new WebSocket connection to the chat server
|
||||
* @function create
|
||||
* @returns {WebSocket} New WebSocket instance
|
||||
* @private
|
||||
*/
|
||||
function create(): WebSocket {
|
||||
let prefix = "ws://";
|
||||
if (location.protocol.includes("https")) {
|
||||
prefix = "wss://";
|
||||
}
|
||||
|
||||
return new WebSocket(`${prefix}${API_FULL_BASE_URL}/chat/ws`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global WebSocket instance
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
export let websocket: WebSocket = create();
|
||||
|
||||
// --------------
|
||||
// Initialization
|
||||
// --------------
|
||||
|
||||
websocket.addEventListener("message", (e) => {
|
||||
const message: WebSocketMessage = JSON.parse(e.data);
|
||||
handleWebSocketMessage(message);
|
||||
});
|
||||
|
||||
websocket.addEventListener("error", async () => {
|
||||
console.warn("WebSocket disconnected, retrying in 3 seconds...");
|
||||
await delay(3000);
|
||||
websocket = create();
|
||||
|
||||
let listener: () => void | null;
|
||||
listener = () => {
|
||||
console.log("WebSocket successfully reconnected!");
|
||||
websocket.removeEventListener("open", listener);
|
||||
}
|
||||
|
||||
websocket.addEventListener("open", listener);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { defineConfig, PluginOption } from 'vite';
|
||||
import { createHtmlPlugin } from 'vite-plugin-html';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import electron from 'vite-plugin-electron/simple';
|
||||
|
||||
const plugins: PluginOption[] = [
|
||||
createHtmlPlugin({
|
||||
minify: {
|
||||
collapseWhitespace: true,
|
||||
removeComments: true,
|
||||
removeRedundantAttributes: true,
|
||||
removeScriptTypeAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
useShortDoctype: true,
|
||||
minifyCSS: true,
|
||||
minifyJS: true
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
if (process.env.VITE_ELECTRON) {
|
||||
plugins.push(
|
||||
electron({
|
||||
main: {
|
||||
entry: "electron/main.ts",
|
||||
},
|
||||
preload: {
|
||||
input: "frontend/electron/preload.ts"
|
||||
},
|
||||
renderer: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: plugins,
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8301,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8300/",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
ws: true
|
||||
}
|
||||
},
|
||||
},
|
||||
appType: "mpa",
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [autoprefixer()],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true
|
||||
},
|
||||
format: {
|
||||
comments: false
|
||||
}
|
||||
},
|
||||
cssMinify: true,
|
||||
assetsInlineLimit: 0
|
||||
}
|
||||
});
|
||||
+40
-31
@@ -1,46 +1,55 @@
|
||||
{
|
||||
"name": "pixelchat",
|
||||
"name": "fromchat",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "frontend/dist-electron/main.js",
|
||||
"description": "A 100% Open Source Messenger",
|
||||
"license": "GPL-3.0",
|
||||
"authors": "denis0001-dev",
|
||||
"scripts": {
|
||||
"backend:run": "cd backend && dotenv -e ../deployment/.env -- ../.venv/bin/fastapi dev --port 8300 main.py",
|
||||
"backend:dependencies": "python3 -m venv .venv && ./.venv/bin/pip3 install -r backend/requirements.txt",
|
||||
"backend:reinstall": "rm -rf .venv && npm run backend:dependencies",
|
||||
"backend:clean": "rm -rf backend/data",
|
||||
"frontend:dev": "vite frontend",
|
||||
"frontend:build": "tsc --project frontend && vite build frontend",
|
||||
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
|
||||
"frontend:electron:dependencies": "cd frontend/electron/forge && npm install",
|
||||
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && cp -r frontend/dist frontend/dist-electron frontend/electron/forge && cd frontend/electron/forge && npm run make",
|
||||
"frontend:preview": "vite preview frontend",
|
||||
"frontend:dev": "vite",
|
||||
"frontend:typecheck": "tsc --project tsconfig.json",
|
||||
"frontend:build": "npm run frontend:typecheck && vite build",
|
||||
"frontend:preview": "vite preview",
|
||||
"frontend:dependencies": "npm install --ignore-scripts",
|
||||
"frontend:clean": "rm -rf frontend/dist",
|
||||
"dev": "concurrently 'npm run frontend:dev' 'npm run backend:run'",
|
||||
"dev:electron": "concurrently 'npm run frontend:electron:dev' 'npm run backend:run'",
|
||||
"build:electron": "npm run frontend:electron:build",
|
||||
"build": "npm run frontend:build && npm run build:electron",
|
||||
"preview": "cd deployment && docker compose up --build --watch",
|
||||
"preview:clean": "cd deployment && docker compose down -v",
|
||||
"clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean",
|
||||
"install": "npm run backend:dependencies && npm run frontend:electron:dependencies && cp deployment/.env.example deployment/.env"
|
||||
"frontend:clean": "rm -rf build",
|
||||
"build": "npm run frontend:build",
|
||||
"preview": "docker compose -f docker-compose.yml up --build --watch",
|
||||
"preview:clean": "docker compose -f docker-compose.yml down -v --remove-orphans",
|
||||
"prepare": "husky",
|
||||
"install": "if [ ! -e .env ]; then cp .env.example .env; fi"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^9.2.0",
|
||||
"electron": "^37.3.1",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"postcss": "^8.5.6",
|
||||
"sass-embedded": "^1.90.0",
|
||||
"terser": "^5.43.1",
|
||||
"rollup-plugin-visualizer": "^6.0.4",
|
||||
"sass-embedded": "^1.93.0",
|
||||
"svgo": "^4.0.0",
|
||||
"terser": "^5.44.0",
|
||||
"typescript": "~5.9.2",
|
||||
"vite": "^7.1.2",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vite-plugin-html": "^3.2.2"
|
||||
"vite": "^7.1.6",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-sass-dts": "^1.3.34"
|
||||
},
|
||||
"dependencies": {
|
||||
"mdui": "^2.1.4"
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"he": "^1.2.0",
|
||||
"idb": "^8.0.3",
|
||||
"marked": "^18.0.9",
|
||||
"mdui": "^2.1.4",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"use-immer": "^0.11.0",
|
||||
"zustand": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Plugin, UserConfig } from 'vite';
|
||||
|
||||
const DEFAULT_DICTIONARY = '_-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
function counter(dictionary: string = DEFAULT_DICTIONARY) {
|
||||
const sequence: string[] = [dictionary[0]];
|
||||
return () => {
|
||||
const str = sequence.join('');
|
||||
let carry = 0;
|
||||
for (let i = 0; i < sequence.length; i++) {
|
||||
const index = dictionary.indexOf(sequence[i]) + carry + 1;
|
||||
if (index < dictionary.length) {
|
||||
sequence[i] = dictionary[index];
|
||||
/**
|
||||
* Make sure the following rules are not violated:
|
||||
* 1. The first character cannot be a number
|
||||
* 2. The second character cannot be a number if the first is a dash
|
||||
* 3. The dash cannot be the only character
|
||||
*
|
||||
* https://www.w3.org/TR/CSS21/syndata.html#characters
|
||||
*/
|
||||
const [c1, c2] = sequence;
|
||||
if (
|
||||
(c1 >= '0' && c1 <= '9') ||
|
||||
(c1 === '-' && (c2 >= '0' && c2 <= '9')) ||
|
||||
(c1 === '-' && sequence.length === 1)
|
||||
) {
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
carry = 0;
|
||||
break;
|
||||
} else {
|
||||
sequence[i] = dictionary[0];
|
||||
carry = 1;
|
||||
}
|
||||
}
|
||||
if (carry) {
|
||||
sequence.push(dictionary[0]);
|
||||
}
|
||||
return str;
|
||||
};
|
||||
};
|
||||
|
||||
export interface OptimizeCssModuleOptions {
|
||||
dictionary?: string;
|
||||
}
|
||||
|
||||
export function optimizeCssModules(options?: OptimizeCssModuleOptions): Plugin {
|
||||
const next = counter(options?.dictionary);
|
||||
const map: Map<string, string> = new Map();
|
||||
|
||||
return {
|
||||
name: 'optimize-css-modules',
|
||||
apply: 'build',
|
||||
config: () => ({
|
||||
css: {
|
||||
modules: {
|
||||
generateScopedName: (name: string, fileName: string) => {
|
||||
const key = fileName + name;
|
||||
let hash = map.get(key);
|
||||
if (!hash) {
|
||||
map.set(key, (hash = next()));
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Plugin } from 'vite';
|
||||
import { optimize } from 'svgo';
|
||||
|
||||
export interface OptimizeSvgOptions {
|
||||
/**
|
||||
* Whether to enable SVG optimization
|
||||
* @default true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const svgoConfig: Parameters<typeof optimize>[1] = {
|
||||
multipass: true,
|
||||
plugins: [
|
||||
{
|
||||
name: 'preset-default',
|
||||
params: {
|
||||
overrides: {
|
||||
// Keep IDs if they might be referenced (minify instead of remove)
|
||||
cleanupIds: {
|
||||
remove: false,
|
||||
minify: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Optimizes SVG files during build by:
|
||||
* - Minifying SVG code
|
||||
* - Removing metadata and comments
|
||||
* - Removing unnecessary attributes
|
||||
* - Optimizing paths and shapes
|
||||
*/
|
||||
export function optimizeSvg(options?: OptimizeSvgOptions): Plugin {
|
||||
const enabled = options?.enabled !== false;
|
||||
|
||||
return {
|
||||
name: 'optimize-svg',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
async generateBundle(options, bundle) {
|
||||
if (!enabled) return;
|
||||
|
||||
// Optimize SVGs in the bundle
|
||||
for (const [fileName, chunk] of Object.entries(bundle)) {
|
||||
if (fileName.endsWith('.svg') && chunk.type === 'asset') {
|
||||
try {
|
||||
const svgContent = typeof chunk.source === 'string'
|
||||
? chunk.source
|
||||
: Buffer.from(chunk.source).toString('utf-8');
|
||||
|
||||
const result = optimize(svgContent, svgoConfig);
|
||||
|
||||
if (result.data && result.data !== svgContent) {
|
||||
chunk.source = result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to optimize SVG ${fileName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" class="mdui-theme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Loading...</title>
|
||||
<link rel="icon" href="./images/logo.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="main/main.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useUserStore } from "./state/user";
|
||||
import { lazy, useEffect, useRef, useState } from "react";
|
||||
import { parseProfileLink } from "./core/profileLinks";
|
||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
|
||||
import { AlertDialogProvider } from "./core/components/AlertDialog";
|
||||
import { delay } from "./utils/utils";
|
||||
|
||||
// Lazy load route components
|
||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
|
||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||
const PrivacyPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.PrivacyPage })));
|
||||
const TermsPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.TermsPage })));
|
||||
|
||||
const routeConfig: RouteObject[] = [
|
||||
{ path: "/", element: <HomePage /> },
|
||||
{ path: "/auth", element: <AuthPage /> },
|
||||
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
||||
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
||||
{ path: "/download-app", element: <DownloadAppPage /> },
|
||||
{ path: "/privacy", element: <PrivacyPage /> },
|
||||
{ path: "/terms", element: <TermsPage /> },
|
||||
{
|
||||
path: "/chat",
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<ChatPage />
|
||||
</ProtectedRoute>
|
||||
)
|
||||
},
|
||||
{ path: "*", element: <SmartCatchAll /> }
|
||||
];
|
||||
|
||||
function SmartCatchAll() {
|
||||
const navigate = useNavigate();
|
||||
const [showNotFound, setShowNotFound] = useState(false);
|
||||
|
||||
function isValidRoute(path: string): boolean {
|
||||
const validRoutes = routeConfig.filter(route => route.path !== "*");
|
||||
const matches = matchRoutes(validRoutes, path);
|
||||
|
||||
return Boolean(matches && matches.length > 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidRoute(location.pathname)) {
|
||||
setShowNotFound(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileInfo = parseProfileLink(); // No URL specified intentionally to let it use the current URL
|
||||
|
||||
if (profileInfo) {
|
||||
setShowNotFound(false);
|
||||
navigate("/chat", {
|
||||
replace: true,
|
||||
state: { profileInfo }
|
||||
});
|
||||
} else {
|
||||
setShowNotFound(true);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// Show 404 page
|
||||
if (showNotFound) {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
}
|
||||
|
||||
function AnimatedRoutes() {
|
||||
const location = useLocation();
|
||||
const prevPathnameRef = useRef(location.pathname);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="sync" initial={false}>
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
onAnimationStart={() => {
|
||||
if (prevPathnameRef.current !== location.pathname) {
|
||||
prevPathnameRef.current = location.pathname;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
}}
|
||||
onAnimationComplete={async () => {
|
||||
await delay(500);
|
||||
document.body.style.overflow = "";
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 1, scale: 1.1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
mass: 0.8
|
||||
}}
|
||||
style={{
|
||||
transformOrigin: "center center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
}}
|
||||
>
|
||||
<Routes location={location}>
|
||||
{routeConfig.map((route, index) => (
|
||||
<Route key={index} path={route.path} element={route.element} />
|
||||
))}
|
||||
</Routes>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { restoreFromStorage, user } = useUserStore();
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
restoreFromStorage().finally(() => {
|
||||
setAuthReady(true);
|
||||
});
|
||||
}, [restoreFromStorage]);
|
||||
|
||||
return authReady && (
|
||||
<BrowserRouter>
|
||||
<AlertDialogProvider />
|
||||
<div id="main-wrapper">
|
||||
<AnimatedRoutes />
|
||||
</div>
|
||||
{user.isSuspended && (
|
||||
<SuspensionDialog
|
||||
reason={user.suspensionReason || "No reason provided"}
|
||||
open={true}
|
||||
onOpenChange={() => {}} // Suspended users can't close the dialog
|
||||
/>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
import { avatarGradientFromUserId } from "@/core/avatarGradient";
|
||||
import styles from "@/pages/chat/css/deleted-user-avatar.module.scss";
|
||||
|
||||
interface DeletedUserAvatarProps {
|
||||
userId: number;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export function DeletedUserAvatar({ userId, className, iconClassName }: DeletedUserAvatarProps) {
|
||||
return (
|
||||
<div
|
||||
className={className ?? styles.deletedUserAvatar}
|
||||
style={{ background: avatarGradientFromUserId(userId) }}
|
||||
>
|
||||
<MaterialIcon
|
||||
name="account_circle_off--outlined"
|
||||
className={iconClassName ?? styles.deletedUserAvatarIcon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./index";
|
||||
|
||||
export interface DeviceInfo {
|
||||
session_id: string;
|
||||
device_name?: string;
|
||||
device_type?: string;
|
||||
os_name?: string;
|
||||
os_version?: string;
|
||||
browser_name?: string;
|
||||
browser_version?: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
created_at?: string;
|
||||
last_seen?: string;
|
||||
revoked?: boolean;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export async function listDevices(token: string): Promise<DeviceInfo[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to fetch devices");
|
||||
const data = await res.json();
|
||||
return data.devices as DeviceInfo[];
|
||||
}
|
||||
|
||||
export async function revokeDevice(token: string, sessionId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to revoke device");
|
||||
}
|
||||
|
||||
export async function logoutAllOtherDevices(token: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to logout all devices");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
||||
import type { Headers } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {string | null} token - Authentication token
|
||||
* @param {boolean} json - Whether to include JSON content type header
|
||||
* @returns {Headers} Headers object with authentication and content type
|
||||
*/
|
||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||
const headers: Headers = {};
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export interface CheckAuthResponse {
|
||||
authenticated: boolean;
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UserKeyPairMemory {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveKeys(
|
||||
publicKey: Uint8Array<ArrayBufferLike>,
|
||||
privateKey: Uint8Array<ArrayBufferLike>
|
||||
) {
|
||||
const encodedPublicKey = b64(publicKey);
|
||||
const encodedPrivateKey = b64(privateKey);
|
||||
|
||||
localStorage.setItem("publicKey", encodedPublicKey);
|
||||
localStorage.setItem("privateKey", encodedPrivateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current user is authenticated
|
||||
*/
|
||||
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/check_auth`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to check auth");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in a user with username and password (step-based auth).
|
||||
*/
|
||||
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/auth/steps/password`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
||||
throw new Error(error.detail || "Login failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.status === "needs_register") {
|
||||
throw new Error("Account not found");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new user (step-based auth; Yandex proof required when enabled on server).
|
||||
*/
|
||||
export async function register(request: RegisterRequest & { registration_proof?: string }): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/auth/steps/register/confirm`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify({
|
||||
display_name: request.display_name,
|
||||
username: request.username,
|
||||
password: request.password,
|
||||
confirm_password: request.confirm_password,
|
||||
bio: request.bio,
|
||||
registration_proof: request.registration_proof,
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
||||
throw new Error(error.detail || "Registration failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the current user
|
||||
*/
|
||||
export async function logout(token: string): Promise<LogoutResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/logout`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to logout");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a client-side authentication secret so the raw password never leaves the client.
|
||||
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
||||
*/
|
||||
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
|
||||
// Use per-user salt derived from username; in future we can fetch a server-provided salt
|
||||
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
||||
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
||||
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
|
||||
return b64(derived);
|
||||
}
|
||||
|
||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||
// Try to restore from backup
|
||||
const blobJson = await fetchBackupBlob(token);
|
||||
if (blobJson) {
|
||||
const blob = decodeBlob(blobJson);
|
||||
const bundle = await decryptBackupWithPassword(password, blob);
|
||||
currentPrivateKey = bundle.privateKey;
|
||||
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
||||
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
||||
const serverPub = await fetchPublicKey(token);
|
||||
if (serverPub) {
|
||||
currentPublicKey = serverPub;
|
||||
} else {
|
||||
// We don't have the corresponding public key from server; regenerate pair to resync
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(pair.publicKey, token);
|
||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(newBlob), token);
|
||||
}
|
||||
|
||||
saveKeys(currentPublicKey!, currentPrivateKey!);
|
||||
|
||||
return {
|
||||
publicKey: currentPublicKey!,
|
||||
privateKey: currentPrivateKey!
|
||||
};
|
||||
}
|
||||
|
||||
// First-time setup: generate keys and upload
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(pair.publicKey, token);
|
||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||
|
||||
saveKeys(pair.publicKey, pair.privateKey);
|
||||
|
||||
return pair;
|
||||
}
|
||||
|
||||
export function restoreKeys() {
|
||||
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
||||
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem("authToken");
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the user's password
|
||||
*/
|
||||
export async function changePassword(
|
||||
token: string,
|
||||
username: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
logoutAllExceptCurrent: boolean
|
||||
): Promise<void> {
|
||||
const currentDerived = await deriveAuthSecret(username, currentPassword);
|
||||
const newDerived = await deriveAuthSecret(username, newPassword);
|
||||
const res = await fetch(`${API_BASE_URL}/change-password`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({
|
||||
currentPasswordDerived: currentDerived,
|
||||
newPasswordDerived: newDerived,
|
||||
logoutAllExceptCurrent
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to change password");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current user's account
|
||||
*/
|
||||
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
|
||||
const res = await fetch(`${API_BASE_URL}/account/delete`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
|
||||
throw new Error(error.detail || "Failed to delete account");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { getAuthHeaders } from ".";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
profile_picture_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
*/
|
||||
export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Map backend fields to frontend fields
|
||||
return {
|
||||
profile_picture: data.profile_picture,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.bio
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile picture to the server
|
||||
*/
|
||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user profile information
|
||||
*/
|
||||
export async function updateProfile(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||
try {
|
||||
// Map frontend fields to backend fields
|
||||
const backendData = {
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(token, true),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(backendData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user bio
|
||||
*/
|
||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by username
|
||||
*/
|
||||
export async function fetchUserProfile(token: string, username: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by user ID
|
||||
*/
|
||||
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles verification status for a user (owner only)
|
||||
*/
|
||||
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error verifying user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Suspends a user account (admin only)
|
||||
*/
|
||||
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error suspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuspends a user account (admin only)
|
||||
*/
|
||||
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error unsuspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user account (admin only)
|
||||
*/
|
||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import { getCurrentKeys } from "../user/auth";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "../crypto/identity";
|
||||
import { searchUsers } from "../user/search";
|
||||
import { deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import tweetnacl from "tweetnacl";
|
||||
|
||||
/**
|
||||
* Unwrap a MEK using the appropriate wrapping key for the current user
|
||||
*/
|
||||
export async function unwrapMek(wrappedMekB64: string, envelope: DmEnvelope, userId?: number): Promise<Uint8Array> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Determine context based on whether we're sender or recipient
|
||||
const currentUserId = userId || parseInt(localStorage.getItem('userId') || '0');
|
||||
const isRecipient = envelope.recipientId === currentUserId;
|
||||
const context = isRecipient ? "recipient_wrap_key" : "sender_wrap_key";
|
||||
|
||||
// Derive wrapping key from our public key
|
||||
const salt = new Uint8Array(16).fill(0); // 16 zero bytes salt
|
||||
const wrappingKeyRaw = await deriveWrappingKey(keys.publicKey, salt, new TextEncoder().encode(context));
|
||||
const wrappingKey = await importAesGcmKey(wrappingKeyRaw);
|
||||
|
||||
// Unwrap the MEK using AES-256-GCM
|
||||
const wrappedMekBytes = ub64(wrappedMekB64);
|
||||
const mekNonce = wrappedMekBytes.slice(0, 12);
|
||||
const mekCiphertext = wrappedMekBytes.slice(12);
|
||||
|
||||
return await aesGcmDecrypt(wrappingKey, mekNonce, mekCiphertext);
|
||||
}
|
||||
|
||||
export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<string> {
|
||||
try {
|
||||
// Use the wrapped MEK provided for this user
|
||||
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
||||
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
||||
|
||||
// Unwrap the MEK using shared logic
|
||||
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
||||
|
||||
// Decrypt the message using the unwrapped MEK
|
||||
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
||||
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
||||
const messageKey = await importAesGcmKey(mek);
|
||||
const messageNonce = ub64(envelope.iv_b64 || "");
|
||||
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
||||
|
||||
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
||||
const result = new TextDecoder().decode(plaintext);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to decrypt DM envelope:", error);
|
||||
console.error("Error details:", {
|
||||
envelope: envelope,
|
||||
userId: userId,
|
||||
localStorageUserId: localStorage.getItem('userId')
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the transport public key from the server
|
||||
*/
|
||||
async function getTransportPublicKey(): Promise<string> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch transport key: HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.public_key_b64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt message using transport key (client-side only)
|
||||
*/
|
||||
function encryptWithTransportKey(plaintext: string, transportPublicKeyB64: string): { client_public_key_b64: string; nonce_b64: string; ciphertext_b64: string } {
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
return {
|
||||
client_public_key_b64: btoa(String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])),
|
||||
nonce_b64: btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[])),
|
||||
ciphertext_b64: btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]))
|
||||
};
|
||||
}
|
||||
|
||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number, attachments?: Array<{name:string,path:string,wrapped_mek_b64?:string,nonce_b64?:string}>): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption only
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key (from current keys)
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send to server (server will handle envelope encryption)
|
||||
const bodyPayload: any = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId
|
||||
};
|
||||
if (attachments && attachments.length > 0) bodyPayload["files"] = attachments;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(bodyPayload)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function sendWithFiles(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
files: File[],
|
||||
plaintext: string,
|
||||
authToken: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error("No files provided");
|
||||
}
|
||||
|
||||
// Get transport key for encryption (shared across message + files)
|
||||
const transportKeyResponse = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!transportKeyResponse.ok) {
|
||||
throw new Error("Failed to get transport key");
|
||||
}
|
||||
const transportKeyData = await transportKeyResponse.json();
|
||||
const transportPublicKeyB64 = transportKeyData.public_key_b64;
|
||||
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKey = ub64(transportPublicKeyB64);
|
||||
|
||||
// One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const messagePlaintextBytes = new TextEncoder().encode(plaintext || "");
|
||||
const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const messageCiphertext = tweetnacl.box(
|
||||
messagePlaintextBytes,
|
||||
messageNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[]));
|
||||
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Base64 encode helper (chunked)
|
||||
const uint8ToB64 = (uint8: Uint8Array): string => {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||
binary += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + CHUNK)) as number[]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
// Transport-encrypt files; server will envelope-encrypt them with the SAME MEK as the message.
|
||||
const transport_files: Array<{ encrypted_file_data_b64: string; filename: string; file_size: number }> = [];
|
||||
for (const file of files) {
|
||||
const fileData = await file.arrayBuffer();
|
||||
const transportNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const transportEncrypted = tweetnacl.box(
|
||||
new Uint8Array(fileData),
|
||||
transportNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
|
||||
transportEncryptedWithNonce.set(transportNonce);
|
||||
transportEncryptedWithNonce.set(transportEncrypted, transportNonce.length);
|
||||
|
||||
transport_files.push({
|
||||
encrypted_file_data_b64: uint8ToB64(transportEncryptedWithNonce),
|
||||
filename: file.name,
|
||||
file_size: file.size
|
||||
});
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId,
|
||||
transport_files
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface ConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a DM as read
|
||||
*/
|
||||
export async function markRead(id: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmMarkRead",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMessage(
|
||||
messageId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintext: string,
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Get transport key for initial encryption
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption (same as sending)
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send transport-encrypted data to the edit endpoint (it will handle envelope encryption)
|
||||
const editResponse = await fetch(`${API_BASE_URL}/dm/edit/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64
|
||||
})
|
||||
});
|
||||
|
||||
if (!editResponse.ok) throw new Error(`Failed to edit DM: HTTP ${editResponse.status}`);
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { searchUsers, fetchUserPublicKey };
|
||||
@@ -0,0 +1,99 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import type { Message, Messages, SendMessageRequest } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
|
||||
/**
|
||||
* Fetches public chat messages
|
||||
*/
|
||||
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<{ messages: Message[]; has_more: boolean }> {
|
||||
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data: Messages & { has_more?: boolean } = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message via WebSocket
|
||||
*/
|
||||
export async function send(content: string, replyToId: number | null, authToken: string): Promise<void> {
|
||||
await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
} satisfies SendMessageRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message with files via HTTP
|
||||
*/
|
||||
export async function sendWithFiles(
|
||||
content: string,
|
||||
replyToId: number | null,
|
||||
files: File[],
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append("payload", JSON.stringify({
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
} satisfies SendMessageRequest["data"]));
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/send_message`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(authToken, false),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || "Failed to send message with files");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a public chat message
|
||||
*/
|
||||
export async function edit(messageId: number, newContent: string, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(authToken, true),
|
||||
body: JSON.stringify({ content: newContent })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to edit message");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a public chat message
|
||||
*/
|
||||
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(authToken, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete message");
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a message as read
|
||||
*/
|
||||
export async function markRead(messageId: number, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/messages/mark_read`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(authToken, true),
|
||||
body: JSON.stringify({ message_id: messageId })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to mark message as read");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Fetches the current user's public key
|
||||
*/
|
||||
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.publicKey) return null;
|
||||
return ub64(data.publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's public key
|
||||
*/
|
||||
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches another user's public key by user ID
|
||||
*/
|
||||
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's backup blob
|
||||
*/
|
||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
const response: BackupBlob = await res.json();
|
||||
return response.blob;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's backup blob
|
||||
*/
|
||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { BackupBlob } from "@/core/types";
|
||||
import api from "@/core/api";
|
||||
|
||||
/**
|
||||
* Fetches the current user's backup blob
|
||||
*/
|
||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
const response: BackupBlob = await res.json();
|
||||
return response.blob;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's backup blob
|
||||
*/
|
||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import type { UploadPublicKeyRequest } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Fetches the current user's public key
|
||||
*/
|
||||
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.publicKey) return null;
|
||||
return ub64(data.publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's public key
|
||||
*/
|
||||
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches another user's public key by user ID
|
||||
*/
|
||||
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.publicKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Placeholder for Signal Protocol pre-key management
|
||||
// Will be implemented when Signal Protocol is added
|
||||
|
||||
export async function upload(_bundle: unknown, _token: string): Promise<void> {
|
||||
// TODO: Implement Signal Protocol pre-key upload
|
||||
throw new Error("Not implemented yet");
|
||||
}
|
||||
|
||||
export async function fetch(_userId: number, _token: string): Promise<unknown> {
|
||||
// TODO: Implement Signal Protocol pre-key fetch
|
||||
throw new Error("Not implemented yet");
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user