mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
219 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,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:
|
||||
|
||||
@@ -4,17 +4,64 @@ alwaysApply: true
|
||||
|
||||
When working with this project, follow these rules:
|
||||
|
||||
## Core Behavior
|
||||
- NEVER do anything i didn't ask you for!
|
||||
- Use double quotes ("") for strings.
|
||||
- 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.
|
||||
|
||||
Do NOT execute other commands like "cd".
|
||||
- Do NOT "cd" to the project directory.
|
||||
- If possible, try to update files in a single edit.
|
||||
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
|
||||
make it async. The import is `<project>/frontend/src/utils/utils`.
|
||||
- When you complete your task, remove unused imports if there are any.
|
||||
## 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 frontend with Python FastAPI backend
|
||||
- Uses MDUI components for UI
|
||||
- Has Electron support for desktop app
|
||||
- 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,10 +1,13 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When you work with UI:
|
||||
|
||||
1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML.
|
||||
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.
|
||||
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,47 @@
|
||||
# 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
|
||||
dist-electron
|
||||
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,61 +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"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "frontend/**"
|
||||
- "deployment/**"
|
||||
- "**/package.json"
|
||||
- ".nvmrc"
|
||||
- ".github/workflows/deploy.yml"
|
||||
- "!frontend/electron/**"
|
||||
- "!**.d.ts"
|
||||
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
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
env:
|
||||
HOME: "/root"
|
||||
environment:
|
||||
name: production
|
||||
url: https://fromchat.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 }}
|
||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
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
|
||||
+223
-8
@@ -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,15 +572,30 @@ 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/migrations/**
|
||||
!backend/migrations/env.py
|
||||
!backend/migrations/script.py.mako
|
||||
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
|
||||
|
||||
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) &
|
||||
|
||||
Vendored
+15
-4
@@ -1,9 +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
|
||||
},
|
||||
"github-actions.workflows.pinned.workflows": [],
|
||||
"github-actions.workflows.pinned.workflows.ignore": true,
|
||||
"github-actions.workflows.pinned.workflows.ignoreContextAccess": true
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"python.terminal.activateEnvironment": false
|
||||
}
|
||||
Vendored
+8
-27
@@ -2,9 +2,9 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Backend",
|
||||
"type": "npm",
|
||||
"script": "backend:run",
|
||||
"label": "Frontend (Web)",
|
||||
"type": "shell",
|
||||
"command": "npm run frontend:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
@@ -19,28 +19,10 @@
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "Frontend (Web)",
|
||||
"type": "npm",
|
||||
"script": "frontend:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "Frontend (Electron)",
|
||||
"type": "npm",
|
||||
"script": "frontend:electron:dev",
|
||||
"type": "shell",
|
||||
"command": "npm run frontend:electron:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
@@ -58,11 +40,10 @@
|
||||
|
||||
{
|
||||
"label": "Web",
|
||||
"dependsOn": ["Backend", "Frontend (Web)"],
|
||||
"dependsOn": ["Frontend (Web)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
"kind": "build"
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
@@ -73,7 +54,7 @@
|
||||
},
|
||||
{
|
||||
"label": "Electron",
|
||||
"dependsOn": ["Backend", "Frontend (Electron)"],
|
||||
"dependsOn": ["Frontend (Electron)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build"
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# 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
|
||||
ARG VITE_API_BASE_URL=https://api.fromchat.ru
|
||||
ENV NODE_ENV=production
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
# 1.3. Build production assets
|
||||
RUN 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>.
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
# 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](https://github.com/fromchat-messenger/android) • [🌍 Website](https://github.com/fromchat-messenger/site)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description
|
||||
|
||||
FromChat Web is a React/TypeScript client (browser and Electron) for the FromChat server.
|
||||
|
||||
**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
|
||||
- Optional Electron desktop build
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 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 |
|
||||
| Electron | desktop (optional) |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 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
|
||||
npm run frontend:electron:dev # Electron + Vite
|
||||
npm run frontend:electron:build # Electron package
|
||||
```
|
||||
|
||||
### 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)
|
||||
│ ├── electron/ # Electron main/preload
|
||||
│ └── 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 Client](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,117 @@
|
||||
# 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 приложение (браузер и Electron) для работы с сервером FromChat.
|
||||
|
||||
## Использование
|
||||
_В разработке._
|
||||
## Развернуть в 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 для реал-тайма
|
||||
- Тёмный режим
|
||||
- Сборка Electron (опционально)
|
||||
|
||||
## 🏗️ Технологический стек
|
||||
|
||||
| Компонент | Примечание |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | строгая типизация |
|
||||
| Vite 7 | dev-сервер и сборка |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | состояние |
|
||||
| Motion | анимации |
|
||||
| TweetNaCl.js | криптография |
|
||||
| Electron | десктоп (опционально) |
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Требования
|
||||
|
||||
- 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 собранного фронта
|
||||
npm run frontend:electron:dev # Electron + Vite
|
||||
npm run frontend:electron:build # сборка Electron
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React-приложение (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ ├── electron/ # main/preload Electron
|
||||
│ └── 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 Client](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
@@ -1,33 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from migration import run_auto_migration
|
||||
from db import engine
|
||||
|
||||
from routes import account, messaging, profile, push
|
||||
|
||||
# Инициализация 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)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _auto_migrate_on_startup():
|
||||
try:
|
||||
run_auto_migration(engine)
|
||||
except Exception:
|
||||
# Keep startup resilient; errors should be visible in server logs
|
||||
pass
|
||||
@@ -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,45 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate VAPID keys for push notifications
|
||||
Run this script to generate new VAPID keys for your application
|
||||
"""
|
||||
|
||||
import sys
|
||||
import base64
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
def generate_vapid_keys():
|
||||
"""Generate VAPID keys for push notifications"""
|
||||
try:
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# Convert to base64 for web push
|
||||
private_key_b64 = base64.urlsafe_b64encode(
|
||||
private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
).decode('utf-8').rstrip('=')
|
||||
|
||||
# Get the raw uncompressed public key point (65 bytes: 0x04 + 32 bytes x + 32 bytes y)
|
||||
public_numbers = public_key.public_numbers()
|
||||
x_bytes = public_numbers.x.to_bytes(32, 'big')
|
||||
y_bytes = public_numbers.y.to_bytes(32, 'big')
|
||||
public_key_raw = b'\x04' + x_bytes + y_bytes
|
||||
|
||||
public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('utf-8').rstrip('=')
|
||||
|
||||
print(f"VAPID_PRIVATE_KEY=\"{private_key_b64}\"")
|
||||
print(f"VAPID_PUBLIC_KEY=\"{public_key_b64}\"")
|
||||
|
||||
return private_key_b64, public_key_b64
|
||||
except Exception as e:
|
||||
print(f"Error generating VAPID keys: {e}", file=sys.stderr)
|
||||
return None, None
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_vapid_keys()
|
||||
@@ -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,92 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from traceback import format_exc
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
from models import Base
|
||||
from constants import DATABASE_URL
|
||||
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
|
||||
LOCK_FILE = MIGRATIONS_DIR / ".autogen.lock"
|
||||
SCHEMA_HASH_FILE = MIGRATIONS_DIR / ".schema.hash"
|
||||
|
||||
|
||||
def _ensure_alembic_layout() -> None:
|
||||
"""Create a minimal Alembic environment if missing."""
|
||||
versions = MIGRATIONS_DIR / "versions"
|
||||
versions.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _alembic_config() -> Config:
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
|
||||
cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
# Provide a minimal ini section so env.py can read config_ini_section
|
||||
cfg.config_file_name = "alembic.ini"
|
||||
cfg.set_section_option("alembic", "sqlalchemy.url", DATABASE_URL)
|
||||
return cfg
|
||||
|
||||
|
||||
def _model_schema_fingerprint() -> str:
|
||||
"""Compute a deterministic fingerprint of the current SQLAlchemy model schema."""
|
||||
parts: list[str] = []
|
||||
md = Base.metadata
|
||||
for table in sorted(md.tables.values(), key=lambda t: t.name):
|
||||
parts.append(f"T:{table.name}")
|
||||
for col in sorted(table.columns, key=lambda c: c.name):
|
||||
col_type = str(col.type)
|
||||
parts.append(f"C:{col.name}:{col_type}:N{int(bool(col.nullable))}")
|
||||
digest = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
||||
return digest
|
||||
|
||||
|
||||
def run_auto_migration(engine: Engine) -> None:
|
||||
"""Use Alembic to autogenerate and apply migrations automatically on startup."""
|
||||
# Ensure env present
|
||||
_ensure_alembic_layout()
|
||||
cfg = _alembic_config()
|
||||
|
||||
try:
|
||||
# Upgrade existing migrations (if any) first
|
||||
command.upgrade(cfg, "head")
|
||||
except Exception:
|
||||
print("[alembic] upgrade to head failed:\n" + format_exc())
|
||||
|
||||
# Always attempt autogenerate only when model schema fingerprint changed
|
||||
try:
|
||||
# Avoid concurrent autogenerate on dev server reloads
|
||||
try:
|
||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
os.close(fd)
|
||||
have_lock = True
|
||||
except FileExistsError:
|
||||
have_lock = False
|
||||
|
||||
if have_lock:
|
||||
try:
|
||||
new_hash = _model_schema_fingerprint()
|
||||
old_hash = SCHEMA_HASH_FILE.read_text(encoding="utf-8").strip() if SCHEMA_HASH_FILE.exists() else ""
|
||||
if new_hash != old_hash:
|
||||
command.revision(cfg, message="auto", autogenerate=True)
|
||||
command.upgrade(cfg, "head")
|
||||
# Update stored fingerprint
|
||||
SCHEMA_HASH_FILE.write_text(new_hash, encoding="utf-8")
|
||||
finally:
|
||||
try:
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
print("[alembic] autogenerate failed:\n" + format_exc())
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
from models import Base
|
||||
|
||||
config = context.config
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _skip_empty_autogenerate(ctx, rev, directives):
|
||||
# Avoid creating empty migrations when there are no schema changes
|
||||
if getattr(config, "cmd_opts", None) and getattr(config.cmd_opts, "autogenerate", False):
|
||||
if directives:
|
||||
script = directives[0]
|
||||
if hasattr(script, "upgrade_ops") and script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True,
|
||||
process_revision_directives=_skip_empty_autogenerate
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(config.get_section(config.config_ini_section) or {}, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
process_revision_directives=_skip_empty_autogenerate
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '${up_revision}'
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -1,171 +0,0 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, 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])
|
||||
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class MessageFile(Base):
|
||||
__tablename__ = "message_file"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
path = Column(Text, nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("Message", back_populates="files")
|
||||
|
||||
|
||||
class CryptoPublicKey(Base):
|
||||
__tablename__ = "crypto_public_key"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
public_key_b64 = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class CryptoBackup(Base):
|
||||
__tablename__ = "crypto_backup"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||
blob_json = Column(Text, nullable=False)
|
||||
|
||||
|
||||
class DMEnvelope(Base):
|
||||
__tablename__ = "dm_envelope"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
iv_b64 = Column(Text, nullable=False)
|
||||
ciphertext_b64 = Column(Text, nullable=False)
|
||||
salt_b64 = Column(Text, nullable=False)
|
||||
iv2_b64 = Column(Text, nullable=False)
|
||||
wrapped_mk_b64 = Column(Text, nullable=False)
|
||||
reply_to_id = Column(Integer, nullable=True)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class DMFile(Base):
|
||||
__tablename__ = "dm_file"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
path = Column(Text, nullable=False)
|
||||
|
||||
message = relationship("DMEnvelope", back_populates="files")
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
__tablename__ = "push_subscription"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
endpoint = Column(Text, nullable=False)
|
||||
p256dh_key = Column(Text, nullable=False)
|
||||
auth_key = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
# Pydantic модели
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
confirm_password: str
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
content: str
|
||||
reply_to_id: int | None
|
||||
|
||||
|
||||
class EditMessageRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class DeleteMessageRequest(BaseModel):
|
||||
message_id: int
|
||||
|
||||
|
||||
class UpdateBioRequest(BaseModel):
|
||||
bio: str
|
||||
|
||||
|
||||
class PushSubscriptionRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
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,152 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
from models import PushSubscription, User, Message, DMEnvelope
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class PushNotificationService:
|
||||
def __init__(self):
|
||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
||||
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
||||
|
||||
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
||||
raise ValueError("VAPID public or private key is None")
|
||||
|
||||
self.vapid_claims = {
|
||||
"sub": "mailto:support@fromchat.ru",
|
||||
"aud": "https://fcm.googleapis.com"
|
||||
}
|
||||
|
||||
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
|
||||
"""Subscribe a user to push notifications"""
|
||||
try:
|
||||
# Check if user already has a subscription
|
||||
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
existing_sub.endpoint = endpoint
|
||||
existing_sub.p256dh_key = p256dh_key
|
||||
existing_sub.auth_key = auth_key
|
||||
else:
|
||||
# Create new subscription
|
||||
new_sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=endpoint,
|
||||
p256dh_key=p256dh_key,
|
||||
auth_key=auth_key
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Push subscription saved for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
|
||||
"""Send push notification for a new public chat message"""
|
||||
try:
|
||||
# Get all users except the sender
|
||||
users = db.query(User).filter(User.id != message.user_id)
|
||||
if exclude_user_id:
|
||||
users = users.filter(User.id != exclude_user_id)
|
||||
|
||||
for user in users:
|
||||
# Check if user has push subscription before trying to send
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
await self._send_notification_to_user(
|
||||
db, user.id,
|
||||
f"New message from {message.author.username}",
|
||||
message.content[:100] + ("..." if len(message.content) > 100 else ""),
|
||||
message.author.profile_picture,
|
||||
{
|
||||
"type": "public_message",
|
||||
"message_id": message.id,
|
||||
"sender_id": message.user_id,
|
||||
"sender_username": message.author.username
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send public message notifications: {e}")
|
||||
|
||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||
"""Send push notification for a new DM"""
|
||||
try:
|
||||
await self._send_notification_to_user(
|
||||
db, dm_envelope.recipient_id,
|
||||
f"New message from {sender.username}",
|
||||
"You have a new direct message",
|
||||
sender.profile_picture,
|
||||
{
|
||||
"type": "dm",
|
||||
"dm_id": dm_envelope.id,
|
||||
"sender_id": sender.id,
|
||||
"sender_username": sender.username
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DM notification: {e}")
|
||||
|
||||
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
|
||||
"""Send a push notification to a specific user"""
|
||||
try:
|
||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"icon": icon or "/logo.png",
|
||||
"tag": f"message_{user_id}",
|
||||
"data": data
|
||||
}
|
||||
|
||||
subscription_info = {
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {
|
||||
"p256dh": subscription.p256dh_key,
|
||||
"auth": subscription.auth_key
|
||||
}
|
||||
}
|
||||
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=self.vapid_private_key,
|
||||
vapid_claims=self.vapid_claims
|
||||
)
|
||||
|
||||
except WebPushException as e:
|
||||
logger.error(f"WebPush error for user {user_id}: {e}")
|
||||
# If the subscription is invalid, remove it
|
||||
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
||||
|
||||
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
||||
"""Unsubscribe a user from push notifications"""
|
||||
try:
|
||||
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
|
||||
db.commit()
|
||||
logger.info(f"Push subscription removed for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
# Global instance
|
||||
push_service = PushNotificationService()
|
||||
@@ -1,12 +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
|
||||
pywebpush>=1.14.0
|
||||
cryptography>=41.0.0
|
||||
alembic>=1.13.2
|
||||
better-profanity>=0.7.0
|
||||
@@ -1,221 +0,0 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
||||
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,
|
||||
"profile_picture": user.profile_picture,
|
||||
"bio": user.bio,
|
||||
"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.get("/crypto/public-key")
|
||||
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/public-key")
|
||||
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
pk = payload.get("publicKey")
|
||||
if not pk:
|
||||
raise HTTPException(status_code=400, detail="publicKey required")
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.public_key_b64 = pk
|
||||
else:
|
||||
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/crypto/backup")
|
||||
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||
return {"blob": row.blob_json if row else None}
|
||||
|
||||
|
||||
@router.post("/crypto/backup")
|
||||
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
blob = payload.get("blob")
|
||||
if not blob:
|
||||
raise HTTPException(status_code=400, detail="blob required")
|
||||
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||
if row:
|
||||
row.blob_json = blob
|
||||
else:
|
||||
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@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"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
users = db.query(User).order_by(User.username.asc()).all()
|
||||
return {
|
||||
"users": [
|
||||
convert_user(u) for u in users if u.id != current_user.id
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crypto/public-key/of/{user_id}")
|
||||
def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
|
||||
return {"publicKey": row.public_key_b64 if row else None}
|
||||
@@ -1,745 +0,0 @@
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
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, User, DMEnvelope, MessageFile, DMFile
|
||||
from push_service import push_service
|
||||
from PIL import Image
|
||||
import io
|
||||
import json
|
||||
from better_profanity import profanity as _bp
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
|
||||
|
||||
FILES_BASE_DIR = Path("data/uploads/files")
|
||||
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
|
||||
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
|
||||
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
|
||||
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
|
||||
|
||||
|
||||
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,
|
||||
"files": [
|
||||
{
|
||||
"path": f"/api/uploads/files/normal/{Path(f.path).name}",
|
||||
"id": f.id,
|
||||
"name": f.name,
|
||||
"message_id": f.message_id
|
||||
}
|
||||
for f in (msg.files or [])
|
||||
]
|
||||
}
|
||||
|
||||
# для тех кто читает этот код я эти маты не писал
|
||||
# мат писал ии а я сам не матерюсь))
|
||||
# - denis0001-dev
|
||||
_RU_EXTRA = [
|
||||
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
|
||||
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
|
||||
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
|
||||
"долбоёб", "долбоеб", "дебил"
|
||||
]
|
||||
|
||||
_bp.load_censor_words()
|
||||
_bp.add_censor_words(_RU_EXTRA)
|
||||
|
||||
# Additional phrase-level filters (case-insensitive)
|
||||
_PHRASE_PATTERNS: list[re.Pattern] = [
|
||||
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||
]
|
||||
|
||||
def _mask_span(text: str, start: int, end: int) -> str:
|
||||
return text[:start] + ("\\*" * (end - start)) + text[end:]
|
||||
|
||||
def _apply_phrase_filters(text: str) -> str:
|
||||
result = text
|
||||
for pattern in _PHRASE_PATTERNS:
|
||||
# Replace all occurrences; iterate until no more matches to avoid overlapping issues
|
||||
while True:
|
||||
m = pattern.search(result)
|
||||
if not m:
|
||||
break
|
||||
result = _mask_span(result, m.start(), m.end())
|
||||
return result
|
||||
|
||||
def filter_profanity(text: str) -> str:
|
||||
preprocessed = _apply_phrase_filters(text)
|
||||
return _bp.censor(preprocessed, censor_char="\\*")
|
||||
|
||||
|
||||
@router.post("/send_message")
|
||||
async def send_message(
|
||||
request: SendMessageRequest | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
# Optional multipart form support
|
||||
payload: str | None = Form(default=None),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
):
|
||||
# If payload is provided, prefer it for multipart requests
|
||||
if payload and request is None:
|
||||
# Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null}
|
||||
try:
|
||||
obj = json.loads(payload)
|
||||
content = obj.get("content", "")
|
||||
reply_to_id = obj.get("reply_to_id", None)
|
||||
request = SendMessageRequest(content=content, reply_to_id=reply_to_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid payload JSON")
|
||||
|
||||
if request.reply_to_id:
|
||||
# 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"
|
||||
)
|
||||
|
||||
# Apply profanity filter before storing
|
||||
filtered_content = filter_profanity(request.content.strip())
|
||||
|
||||
if len(filtered_content) > 4096:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Message too long"
|
||||
)
|
||||
|
||||
new_message = Message(
|
||||
content=filtered_content,
|
||||
user_id=current_user.id,
|
||||
reply_to_id=request.reply_to_id,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
db.add(new_message)
|
||||
db.commit()
|
||||
db.refresh(new_message)
|
||||
|
||||
# Handle files if provided (normal, not encrypted)
|
||||
if files:
|
||||
total_size = 0
|
||||
for up in files:
|
||||
# Accumulate size if available
|
||||
if hasattr(up, "size") and up.size is not None:
|
||||
total_size += int(up.size)
|
||||
else:
|
||||
# If size unknown, read into memory to determine
|
||||
data = await up.read()
|
||||
up.file.seek(0)
|
||||
total_size += len(data)
|
||||
if total_size > MAX_TOTAL_SIZE:
|
||||
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
|
||||
|
||||
for up in files:
|
||||
# Sanitize filename
|
||||
original_name = Path(up.filename or "file").name
|
||||
ext = Path(original_name).suffix.lower()
|
||||
uid = uuid.uuid4().hex
|
||||
safe_name = f"{new_message.id}_{uid}{ext or ''}"
|
||||
out_path = FILES_NORMAL_DIR / safe_name
|
||||
|
||||
content = await up.read()
|
||||
up.file.seek(0)
|
||||
|
||||
# If image, try lossless optimization
|
||||
try:
|
||||
if up.content_type and up.content_type.startswith("image/"):
|
||||
image = Image.open(io.BytesIO(content))
|
||||
img_format = image.format or ("PNG" if ext == ".png" else "JPEG")
|
||||
buf = io.BytesIO()
|
||||
save_kwargs = {"optimize": True}
|
||||
if img_format.upper() == "JPEG":
|
||||
# Use quality=95 with optimize to keep high quality (not truly lossless but near)
|
||||
save_kwargs["quality"] = 95
|
||||
image.save(buf, format=img_format, **save_kwargs)
|
||||
buf.seek(0)
|
||||
content = buf.read()
|
||||
except Exception:
|
||||
# Fallback to original content
|
||||
pass
|
||||
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
mf = MessageFile(
|
||||
message_id=new_message.id,
|
||||
name=original_name,
|
||||
path=str(out_path)
|
||||
)
|
||||
db.add(mf)
|
||||
db.commit()
|
||||
db.refresh(new_message)
|
||||
|
||||
# Send push notifications for public messages
|
||||
try:
|
||||
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
|
||||
|
||||
# Realtime broadcast for HTTP uploads as well
|
||||
try:
|
||||
from .messaging import messagingManager # self import safe here
|
||||
await messagingManager.broadcast({
|
||||
"type": "newMessage",
|
||||
"data": convert_message(new_message)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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.post("/dm/send")
|
||||
async def dm_send(
|
||||
payload: dict | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
# Multipart support
|
||||
dm_payload: str | None = Form(default=None),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files
|
||||
):
|
||||
if dm_payload and payload is None:
|
||||
try:
|
||||
payload = json.loads(dm_payload)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid dm_payload JSON")
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=400, detail="Missing payload")
|
||||
|
||||
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
||||
for key in required:
|
||||
if key not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||
|
||||
env = DMEnvelope(
|
||||
sender_id=current_user.id,
|
||||
recipient_id=int(payload["recipientId"]),
|
||||
iv_b64=payload["iv"],
|
||||
ciphertext_b64=payload["ciphertext"],
|
||||
salt_b64=payload["salt"],
|
||||
iv2_b64=payload["iv2"],
|
||||
wrapped_mk_b64=payload["wrappedMk"],
|
||||
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
|
||||
)
|
||||
db.add(env)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
# Save encrypted files if any (no processing)
|
||||
if files:
|
||||
# Validate total size
|
||||
total_size = 0
|
||||
for file in files:
|
||||
if hasattr(file, "size") and file.size is not None:
|
||||
total_size += int(file.size)
|
||||
else:
|
||||
data = await file.read()
|
||||
file.file.seek(0)
|
||||
total_size += len(data)
|
||||
if total_size > MAX_TOTAL_SIZE:
|
||||
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
|
||||
|
||||
names: list[str] = []
|
||||
if fileNames:
|
||||
try:
|
||||
decoded = json.loads(fileNames)
|
||||
if isinstance(decoded, list):
|
||||
names = [str(x) for x in decoded]
|
||||
except Exception:
|
||||
names = []
|
||||
|
||||
for i, file in enumerate(files):
|
||||
provided = names[i] if i < len(names) else None
|
||||
# Sanitize provided name to avoid path traversal
|
||||
if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided):
|
||||
provided = None
|
||||
original_name = provided or Path(file.filename or "file").name
|
||||
# Save using provided/original name to allow client to reference path directly
|
||||
safe_name = uid = uuid.uuid4().hex
|
||||
out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}"
|
||||
out_path = FILES_ENCRYPTED_DIR / out_name
|
||||
|
||||
content = await file.read()
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Save DM file record
|
||||
df = DMFile(
|
||||
message_id=env.id,
|
||||
sender_id=current_user.id,
|
||||
recipient_id=env.recipient_id,
|
||||
path=f"/api/uploads/files/encrypted/{out_name}",
|
||||
name=original_name
|
||||
)
|
||||
db.add(df)
|
||||
db.commit()
|
||||
|
||||
# Send push notification for DM
|
||||
try:
|
||||
await push_service.send_dm_notification(db, env, current_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
# Realtime notify both users for HTTP requests
|
||||
try:
|
||||
payload_ws = {
|
||||
"type": "dmNew",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"salt": env.salt_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
"replyToId": env.reply_to_id,
|
||||
}
|
||||
}
|
||||
await messagingManager.send_to_user(env.recipient_id, payload_ws)
|
||||
await messagingManager.send_to_user(env.sender_id, payload_ws)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "ok", "id": env.id}
|
||||
|
||||
def convert_envelopes(envs: list[DMEnvelope]):
|
||||
return {
|
||||
"status": "ok",
|
||||
"messages": [
|
||||
{
|
||||
"id": e.id,
|
||||
"senderId": e.sender_id,
|
||||
"recipientId": e.recipient_id,
|
||||
"iv": e.iv_b64,
|
||||
"ciphertext": e.ciphertext_b64,
|
||||
"salt": e.salt_b64,
|
||||
"iv2": e.iv2_b64,
|
||||
"wrappedMk": e.wrapped_mk_b64,
|
||||
"timestamp": e.timestamp.isoformat(),
|
||||
"files": [{"name": file.name, "path": file.path, "id": file.id} for file in e.files]
|
||||
}
|
||||
for e in envs
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/dm/fetch")
|
||||
async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id)
|
||||
if since:
|
||||
q = q.filter(DMEnvelope.id > since)
|
||||
return convert_envelopes(q.order_by(DMEnvelope.id.asc()).all())
|
||||
|
||||
|
||||
@router.get("/dm/history/{other_user_id}")
|
||||
async def dm_history(other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
return convert_envelopes(
|
||||
db.query(DMEnvelope)
|
||||
.filter(
|
||||
((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id))
|
||||
| ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id))
|
||||
)
|
||||
.order_by(DMEnvelope.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@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}
|
||||
|
||||
class MessaggingSocketManager:
|
||||
def __init__(self) -> None:
|
||||
self.connections: list[WebSocket] = []
|
||||
self.user_by_ws: dict[WebSocket, int] = {}
|
||||
|
||||
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":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if current_user:
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "ping",
|
||||
"data": {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"detail": "Failed to authorize",
|
||||
"code": 401
|
||||
}
|
||||
}
|
||||
})
|
||||
except HTTPException:
|
||||
await websocket.send_json({
|
||||
"type": "ping",
|
||||
"data": {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"detail": "Failed to authorize",
|
||||
"code": 401
|
||||
}
|
||||
}
|
||||
})
|
||||
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)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
|
||||
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)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
|
||||
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
|
||||
|
||||
response = await send_message(request, current_user, db, None, [])
|
||||
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 == "dmSend":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
self.user_by_ws[websocket] = current_user.id
|
||||
payload = data["data"]
|
||||
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
||||
for key in required:
|
||||
if key not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||
env = DMEnvelope(
|
||||
sender_id=current_user.id,
|
||||
recipient_id=int(payload["recipientId"]),
|
||||
iv_b64=payload["iv"],
|
||||
ciphertext_b64=payload["ciphertext"],
|
||||
salt_b64=payload["salt"],
|
||||
iv2_b64=payload["iv2"],
|
||||
wrapped_mk_b64=payload["wrappedMk"],
|
||||
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
|
||||
)
|
||||
db.add(env)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
payload = {
|
||||
"type": "dmNew",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"salt": env.salt_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
"replyToId": env.reply_to_id,
|
||||
}
|
||||
}
|
||||
|
||||
# Send push notification for DM
|
||||
try:
|
||||
await push_service.send_dm_notification(db, env, current_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
await self.send_to_user(env.recipient_id, payload);
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
||||
await self.send_to_user(env.sender_id, payload);
|
||||
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 == "dmEdit":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
payload = data["data"]
|
||||
env_id = int(payload["id"])
|
||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="DM not found")
|
||||
if env.sender_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only edit your own messages")
|
||||
|
||||
# Replace ciphertext and iv
|
||||
env.iv_b64 = payload["iv"]
|
||||
env.ciphertext_b64 = payload["ciphertext"]
|
||||
env.iv2_b64 = payload["iv2"]
|
||||
env.wrapped_mk_b64 = payload["wrappedMk"]
|
||||
env.salt_b64 = payload["salt"]
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
payload_ws = {
|
||||
"type": "dmEdited",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"salt": env.salt_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
}
|
||||
}
|
||||
await self.send_to_user(env.recipient_id, payload_ws)
|
||||
await self.send_to_user(env.sender_id, payload_ws)
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "dmDelete":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
payload = data["data"]
|
||||
env_id = int(payload["id"])
|
||||
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="DM not found")
|
||||
if env.sender_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only delete your own messages")
|
||||
|
||||
db.delete(env)
|
||||
db.commit()
|
||||
|
||||
payload_ws = {
|
||||
"type": "dmDeleted",
|
||||
"data": {
|
||||
"id": env_id,
|
||||
"senderId": current_user.id,
|
||||
"recipientId": payload.get("recipientId")
|
||||
}
|
||||
}
|
||||
await self.send_to_user(env.recipient_id, payload_ws)
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}})
|
||||
await self.send_to_user(env.sender_id, payload_ws)
|
||||
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)
|
||||
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)
|
||||
if websocket in self.user_by_ws:
|
||||
del self.user_by_ws[websocket]
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for websocket in self.connections:
|
||||
await websocket.send_json(message)
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict):
|
||||
for websocket in self.connections:
|
||||
if self.user_by_ws.get(websocket) == user_id:
|
||||
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)
|
||||
|
||||
|
||||
# File serving endpoints
|
||||
@router.get("/uploads/files/normal/{filename}")
|
||||
async def get_file_normal(filename: str):
|
||||
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
path = FILES_NORMAL_DIR / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(str(path))
|
||||
|
||||
|
||||
@router.get("/uploads/files/encrypted/{filename}")
|
||||
async def get_file_encrypted(filename: str, current_user: User = Depends(get_current_user)):
|
||||
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
path = FILES_ENCRYPTED_DIR / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
match = re.match(r"^(\d+)_(\d+)_(\d+)_.*$", path.resolve().name)
|
||||
if match:
|
||||
sender_id = int(match.group(1))
|
||||
recipient_id = int(match.group(2))
|
||||
|
||||
if not current_user.id in [sender_id, recipient_id]:
|
||||
raise HTTPException(403)
|
||||
else:
|
||||
raise HTTPException(500)
|
||||
|
||||
return FileResponse(str(path))
|
||||
@@ -1,205 +0,0 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
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
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Request models
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
nickname: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
# 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
|
||||
"""
|
||||
|
||||
if not re.match(r"^\d+_[0-9a-z]+\.jpg$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
|
||||
filepath = os.path.join(PROFILE_PICTURES_DIR, filename)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Profile picture not found")
|
||||
|
||||
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/profile")
|
||||
async def update_user_profile(
|
||||
request: UpdateProfileRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update current user's profile information
|
||||
"""
|
||||
updated = False
|
||||
|
||||
# Update username if provided
|
||||
if request.nickname is not None:
|
||||
nickname = request.nickname.strip()
|
||||
if len(nickname) < 3:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 3 characters long")
|
||||
if len(nickname) > 50:
|
||||
raise HTTPException(status_code=400, detail="Username must be 50 characters or less")
|
||||
|
||||
# Check if username is already taken by another user
|
||||
existing_user = db.query(User).filter(User.username == nickname, User.id != current_user.id).first()
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
current_user.username = nickname
|
||||
updated = True
|
||||
|
||||
# Update bio if provided
|
||||
if request.description is not None:
|
||||
bio = request.description.strip()
|
||||
if len(bio) > 500:
|
||||
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
|
||||
|
||||
current_user.bio = bio
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
db.commit()
|
||||
return {
|
||||
"message": "Profile updated successfully",
|
||||
"username": current_user.username,
|
||||
"bio": current_user.bio
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"message": "No changes made",
|
||||
"username": current_user.username,
|
||||
"bio": current_user.bio
|
||||
}
|
||||
|
||||
|
||||
@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,46 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, PushSubscriptionRequest
|
||||
from push_service import push_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_to_push_notifications(
|
||||
request: PushSubscriptionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Subscribe user to push notifications"""
|
||||
try:
|
||||
success = await push_service.subscribe_user(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
endpoint=request.endpoint,
|
||||
p256dh_key=request.keys["p256dh"],
|
||||
auth_key=request.keys["auth"]
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications enabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to enable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/unsubscribe")
|
||||
async def unsubscribe_from_push_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Unsubscribe user from push notifications"""
|
||||
try:
|
||||
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
|
||||
|
||||
if success:
|
||||
return {"status": "success", "message": "Push notifications disabled"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -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,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,54 +0,0 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
dockerfile: deployment/Dockerfile.backend
|
||||
context: ..
|
||||
environment:
|
||||
PORT: 8300
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
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/build/normal/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}`);
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { ElectronInterface, Platform } from "../electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("electronInterface", {
|
||||
desktop: true,
|
||||
platform: process.platform as Platform,
|
||||
notifications: {
|
||||
requestPermission: () => ipcRenderer.invoke('request-notification-permission'),
|
||||
show: (options) => ipcRenderer.invoke('show-notification', options)
|
||||
}
|
||||
} satisfies ElectronInterface);
|
||||
@@ -1,171 +0,0 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { getAuthHeaders } from "../auth/api";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/symmetric";
|
||||
import { randomBytes } from "../utils/crypto/kdf";
|
||||
import { getCurrentKeys } from "../auth/crypto";
|
||||
import { request } from "../core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "../core/types";
|
||||
import { b64, ub64 } from "../utils/utils";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
}
|
||||
|
||||
export async function fetchUsers(token: string): Promise<User[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Headers } from "../core/types";
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { getAuthHeaders } from "./api";
|
||||
import { generateX25519KeyPair } from "../utils/crypto/asymmetric";
|
||||
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../utils/crypto/backup";
|
||||
import { b64, ub64 } from "../utils/utils";
|
||||
import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export interface UserKeyPairMemory {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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(currentPublicKey, 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(currentPublicKey, 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")!);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Application configuration constants
|
||||
* @description Contains all configuration values used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base domain name for all requests in production
|
||||
* @constant
|
||||
*/
|
||||
export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL ?? "fromchat.ru";
|
||||
|
||||
/**
|
||||
* Base API endpoint for all backend requests
|
||||
* @constant
|
||||
*/
|
||||
export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`;
|
||||
|
||||
/**
|
||||
* Full API URL including hostname and port for WebSocket connections
|
||||
* @constant
|
||||
*/
|
||||
export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`;
|
||||
|
||||
/**
|
||||
* Application name displayed in UI and document title
|
||||
* @constant
|
||||
*/
|
||||
export const PRODUCT_NAME = "FromChat";
|
||||
|
||||
export const MINIMUM_WIDTH = 800;
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Application initialization logic
|
||||
* @description Handles initial application setup and state
|
||||
* @author FromChat Team
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { PRODUCT_NAME } from "./config";
|
||||
import { enableMapSet } from "immer";
|
||||
|
||||
document.title = PRODUCT_NAME;
|
||||
enableMapSet();
|
||||
@@ -1,108 +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 { API_WS_BASE_URL } from "./config";
|
||||
import type { WebSocketMessage } from "./types";
|
||||
import { delay } from "../utils/utils";
|
||||
|
||||
/**
|
||||
* Creates a new WebSocket connection to the chat server
|
||||
* @returns {WebSocket} New WebSocket instance
|
||||
* @private
|
||||
*/
|
||||
function create(): WebSocket {
|
||||
let prefix = "ws://";
|
||||
if (location.protocol.includes("https")) {
|
||||
prefix = "wss://";
|
||||
}
|
||||
|
||||
return new WebSocket(`${prefix}${API_WS_BASE_URL}/chat/ws`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global WebSocket instance
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
export let websocket: WebSocket = create();
|
||||
|
||||
/**
|
||||
* Global WebSocket message handler reference
|
||||
* This will be set by the active panel to handle incoming messages
|
||||
*/
|
||||
let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Set the global WebSocket message handler
|
||||
* @param handler - Function to handle WebSocket messages
|
||||
*/
|
||||
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
|
||||
globalMessageHandler = handler;
|
||||
}
|
||||
|
||||
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
console.log("WebSocket request:", payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
function requestInner() {
|
||||
let listener: ((e: MessageEvent) => void) | null = null;
|
||||
listener = (e) => {
|
||||
resolve(JSON.parse(e.data));
|
||||
websocket.removeEventListener("message", listener!);
|
||||
}
|
||||
websocket.addEventListener("message", listener);
|
||||
websocket.send(JSON.stringify(payload))
|
||||
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
}
|
||||
|
||||
if (websocket.readyState == 0) {
|
||||
websocket.addEventListener("open", requestInner);
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
} else {
|
||||
requestInner();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
|
||||
* If it fails, tries again in an endless loop until the connection is established
|
||||
* again.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async function onError() {
|
||||
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);
|
||||
websocket.addEventListener("error", onError);
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Initialization
|
||||
// --------------
|
||||
|
||||
websocket.addEventListener("message", (e) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(e.data);
|
||||
|
||||
// Route message to global handler if set
|
||||
if (globalMessageHandler) {
|
||||
globalMessageHandler(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
});
|
||||
websocket.addEventListener("error", onError);
|
||||
@@ -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,607 +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%;
|
||||
position: relative;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quote.contextual-content > .quote-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.reply-username {
|
||||
font-weight: 600;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.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-inner {
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
.message-profile-pic {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 4px;
|
||||
margin: 8px;
|
||||
|
||||
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-username {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
margin: 8px;
|
||||
|
||||
&:hover {
|
||||
color: $color-dark-primary;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
word-wrap: break-word;
|
||||
margin: 10px 10px 0 10px;
|
||||
white-space: pre-wrap;
|
||||
|
||||
> p:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
> p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.quote.reply-preview {
|
||||
user-select: none;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.message-attachments {
|
||||
padding: 5px 0 0 0;
|
||||
overflow: hidden;
|
||||
|
||||
.attachment {
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.attachement-image {
|
||||
max-width: 200px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-left: 3px;
|
||||
margin-right: 3px;
|
||||
margin-bottom: 3px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&.loading {
|
||||
filter: blur(10px);
|
||||
transition: filter 200ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.attachement-image.placeholder {
|
||||
background: $color-dark-surface-container-highest;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
backdrop-filter: blur(6px);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preload-image {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.with-icon-gap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
margin-top: 0.3rem;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
margin: 4px 8px 8px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
z-index: 100;
|
||||
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.file-overlay-wrapper {
|
||||
border-radius: 30px;
|
||||
outline: 3px dashed $color-dark-primary;
|
||||
outline-offset: -20px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.file-overlay-inner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(18, 18, 18, 0.8);
|
||||
border: 1px solid $color-dark-surface-container-high;
|
||||
border-radius: 12px;
|
||||
color: $color-dark-on-surface;
|
||||
|
||||
mdui-icon {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
margin: 0 20px 20px 20px;
|
||||
|
||||
&::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),
|
||||
);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 30px;
|
||||
flex-direction: column;
|
||||
|
||||
.contextual-preview {
|
||||
padding: 12px 16px 0 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
mdui-icon {
|
||||
align-self: center;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.reply-cancel {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments-preview {
|
||||
align-items: center;
|
||||
|
||||
.attachments-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
padding: 20px 20px;
|
||||
padding-right: 0;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
caret-color: $color-dark-primary;
|
||||
color: $color-dark-on-surface;
|
||||
resize: none;
|
||||
font: inherit;
|
||||
font-size: 13pt;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.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;
|
||||
align-self: flex-end;
|
||||
|
||||
@include hoverStateLayer($background: $color-dark-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-profile-pic {
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid $color-dark-outline;
|
||||
|
||||
&.loading {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-username {
|
||||
&.loading {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: $color-dark-surface;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem 0;
|
||||
min-width: 160px;
|
||||
z-index: 1000;
|
||||
|
||||
&.entering {
|
||||
animation: fadeInDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-left {
|
||||
animation: fadeInLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up {
|
||||
animation: fadeInUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up-left {
|
||||
animation: fadeInUpLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
animation: fadeOutUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-left {
|
||||
animation: fadeOutRight 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up {
|
||||
animation: fadeOutDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up-left {
|
||||
animation: fadeOutDownRight 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-dark-surface-container;
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 1.1rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen Image Viewer
|
||||
.fullscreen-image-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
z-index: 9999;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&.closing {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fullscreen-animated-image {
|
||||
position: absolute;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease;
|
||||
}
|
||||
|
||||
.fullscreen-controls {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
&.top-right {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.download-app-screen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
}
|
||||
@@ -1,150 +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;
|
||||
min-height: 0; // allow children to manage their own scrolling
|
||||
|
||||
.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;
|
||||
|
||||
.product-name {
|
||||
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%;
|
||||
height: calc(100% - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; // prevent flex collapse when inner overflows
|
||||
--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-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; // enable inner panel to scroll
|
||||
}
|
||||
|
||||
mdui-tab-panel[active] {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; // critical to avoid collapsing
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
mdui-list {
|
||||
flex: 1;
|
||||
min-height: 0; // allow scroll area to size correctly
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
mdui-bottom-app-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,249 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
mdui-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,119 +0,0 @@
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOutUp {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUpLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutDownRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-switch-out {
|
||||
animation: fadeOutUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.chat-switch-in {
|
||||
animation: fadeInDown 0.2s ease forwards;
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
@use "material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.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;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
mdui-text-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.rich-text-area {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
transition: height 0.2s ease;
|
||||
overflow-y: hidden;
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quote {
|
||||
background-color: $color-dark-surface-primary-container-lightened;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
|
||||
&.bg-surfaceContainer {
|
||||
background-color: $color-dark-secondary-container;
|
||||
|
||||
.quote-inner {
|
||||
border-left: 3px solid $color-dark-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.quote-inner {
|
||||
border-left: 3px solid $color-dark-primary;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
@use "../common/material" as *;
|
||||
|
||||
.reply-dialog .dialog-content {
|
||||
width: 300px;
|
||||
overflow-x:hidden;
|
||||
|
||||
.reply-preview-dialog {
|
||||
margin-bottom: 1rem;
|
||||
padding: 16px;
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 16px;
|
||||
|
||||
.reply-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
.reply-username {
|
||||
font-weight: 600;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +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 "dialogs/reply";
|
||||
@use "download-app";
|
||||
|
||||
@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;
|
||||
|
||||
#main-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body, #root {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
mdui-dialog {
|
||||
> *:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,50 +0,0 @@
|
||||
import { MINIMUM_WIDTH } from "../core/config";
|
||||
import { isElectron } from "../electron/electron";
|
||||
import { ElectronTitleBar } from "./components/Electron";
|
||||
import useWindowSize from "./hooks/useWindowSize";
|
||||
import ChatScreen from "./screen/ChatScreen";
|
||||
import DownloadAppScreen from "./screen/DownloadAppScreen";
|
||||
import LoginScreen from "./screen/LoginScreen";
|
||||
import RegisterScreen from "./screen/RegisterScreen";
|
||||
import { useAppState } from "./state";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function App() {
|
||||
const { currentPage, restoreUserFromStorage } = useAppState();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
// Restore user from localStorage on app initialization
|
||||
useEffect(() => {
|
||||
restoreUserFromStorage();
|
||||
}, [restoreUserFromStorage]);
|
||||
|
||||
if (!isElectron && width < MINIMUM_WIDTH) {
|
||||
return <DownloadAppScreen />
|
||||
}
|
||||
|
||||
let page = <LoginScreen />;
|
||||
|
||||
switch (currentPage) {
|
||||
case "login": {
|
||||
page = <LoginScreen />
|
||||
break;
|
||||
}
|
||||
case "register": {
|
||||
page = <RegisterScreen />
|
||||
break;
|
||||
}
|
||||
case "chat": {
|
||||
page = <ChatScreen />
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ElectronTitleBar />
|
||||
<div id="main-wrapper">
|
||||
{page}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export type AlertType = "success" | "danger"
|
||||
|
||||
export interface Alert {
|
||||
type: AlertType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
||||
return (
|
||||
<div>
|
||||
{alerts.slice(-3).map((alert, i) => {
|
||||
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
export function AuthContainer({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card fade-in">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type IconType = "filled" | "outlined";
|
||||
|
||||
export interface AuthHeaderIcon {
|
||||
name: string;
|
||||
type: IconType
|
||||
}
|
||||
|
||||
export interface AuthHeaderProps {
|
||||
title: string;
|
||||
icon: string | AuthHeaderIcon;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
|
||||
const iconType = typeof icon == "string" ? "filled" : icon.type;
|
||||
const iconName = typeof icon == "string" ? icon : icon.name;
|
||||
|
||||
return (
|
||||
<div className="auth-header">
|
||||
<h2>
|
||||
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
|
||||
{title}
|
||||
</h2>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { PRODUCT_NAME } from "../../../core/config";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import { useState } from "react";
|
||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||
|
||||
export function ChatHeader() {
|
||||
const { profileData } = useProfile();
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
|
||||
const handleProfileClick = () => {
|
||||
setIsProfileOpen(true);
|
||||
};
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="chat-header-left">
|
||||
<div className="product-name">{PRODUCT_NAME}</div>
|
||||
<div className="profile">
|
||||
<a href="#" id="profile-open" onClick={handleProfileClick}>
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
id="preview1"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { RichTextArea } from "../core/RichTextArea";
|
||||
import type { Message } from "../../../core/types";
|
||||
import Quote from "../core/Quote";
|
||||
import AnimatedHeight from "../core/animations/AnimatedHeight";
|
||||
import { useImmer } from "use-immer";
|
||||
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage: (message: string, files: File[]) => void;
|
||||
onSaveEdit?: (content: string) => void;
|
||||
replyTo?: Message | null;
|
||||
replyToVisible: boolean;
|
||||
onClearReply?: () => void;
|
||||
onCloseReply?: () => void;
|
||||
editingMessage?: Message | null;
|
||||
editVisible?: boolean;
|
||||
onClearEdit?: () => void;
|
||||
onCloseEdit?: () => void;
|
||||
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper(
|
||||
{
|
||||
onSendMessage,
|
||||
onSaveEdit,
|
||||
replyTo,
|
||||
replyToVisible,
|
||||
onClearReply,
|
||||
onCloseReply,
|
||||
editingMessage,
|
||||
editVisible = false,
|
||||
onClearEdit,
|
||||
onCloseEdit,
|
||||
onProvideFileAdder
|
||||
}: ChatInputWrapperProps
|
||||
) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
|
||||
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
|
||||
const [errorOpen, setErrorOpen] = useState(false);
|
||||
|
||||
// Expose a way for parent to programmatically add files
|
||||
useEffect(() => {
|
||||
if (onProvideFileAdder) {
|
||||
const addFiles = (files: File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setSelectedFiles(draft => { draft.push(...files) });
|
||||
};
|
||||
onProvideFileAdder(addFiles);
|
||||
}
|
||||
}, [onProvideFileAdder]);
|
||||
|
||||
// When entering edit mode, preload the message content
|
||||
useEffect(() => {
|
||||
setMessage(editingMessage ? editingMessage.content || "" : "");
|
||||
}, [editingMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
setAttachmentsVisible(selectedFiles.length > 0);
|
||||
}, [selectedFiles]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent | Event) => {
|
||||
e.preventDefault();
|
||||
const hasText = Boolean(message.trim());
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
if (hasText || hasFiles) {
|
||||
const totalSize = selectedFiles.reduce((acc, f) => acc + f.size, 0);
|
||||
const limit = 4 * 1024 * 1024 * 1024; // 4GB
|
||||
if (totalSize > limit) {
|
||||
setErrorOpen(true);
|
||||
return;
|
||||
}
|
||||
if (editingMessage && onSaveEdit) {
|
||||
onSaveEdit(message);
|
||||
setMessage("");
|
||||
if (onClearEdit) onClearEdit();
|
||||
} else {
|
||||
onSendMessage(message, selectedFiles);
|
||||
setMessage("");
|
||||
setAttachmentsVisible(false);
|
||||
if (onClearReply) onClearReply();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleAttachClick() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.addEventListener("change", () => {
|
||||
setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) });
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-input-wrapper">
|
||||
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
|
||||
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}>
|
||||
{editingMessage && (
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon name="edit" />
|
||||
<Quote className="reply-content contextual-content" background="surfaceContainer">
|
||||
<span className="reply-username">{editingMessage!.username}</span>
|
||||
<span className="reply-text">{editingMessage!.content}</span>
|
||||
</Quote>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<AnimatedHeight visible={replyToVisible} onFinish={onCloseReply}>
|
||||
{replyTo && (
|
||||
<div className="reply-preview contextual-preview">
|
||||
<mdui-icon name="reply" />
|
||||
<Quote className="reply-content contextual-content" background="surfaceContainer">
|
||||
<span className="reply-username">{replyTo!.username}</span>
|
||||
<span className="reply-text">{replyTo!.content}</span>
|
||||
</Quote>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<AnimatedHeight visible={attachmentsVisible} onFinish={() => setSelectedFiles([])}>
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="attachments-preview contextual-preview">
|
||||
<mdui-icon name="attach_file" />
|
||||
<div className="attachments-chips">
|
||||
{selectedFiles.map((file, i) => (
|
||||
<mdui-chip
|
||||
key={i}
|
||||
variant="input"
|
||||
end-icon="close"
|
||||
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
|
||||
onClick={() => {
|
||||
if (selectedFiles.length == 1) {
|
||||
setAttachmentsVisible(false);
|
||||
} else {
|
||||
setSelectedFiles(draft => { draft.splice(i) })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
|
||||
<span className="name">{file.name}</span>
|
||||
</mdui-chip>
|
||||
))}
|
||||
</div>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<div className="chat-input">
|
||||
<RichTextArea
|
||||
className="message-input"
|
||||
id="message-input"
|
||||
placeholder="Напишите сообщение..."
|
||||
autoComplete="off"
|
||||
text={message}
|
||||
rows={1}
|
||||
onTextChange={(value) => setMessage(value)}
|
||||
onEnter={handleSubmit} />
|
||||
<div className="buttons">
|
||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc>
|
||||
<div slot="headline">Ошибка</div>
|
||||
<div>Общий размер вложений превышает 4 ГБ.</div>
|
||||
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
|
||||
</MaterialDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useChat } from "../../hooks/useChat";
|
||||
import { Message } from "./Message";
|
||||
import { useAppState } from "../../state";
|
||||
import type { Message as MessageType } from "../../../core/types";
|
||||
import type { UserProfile } from "../../../core/types";
|
||||
import { UserProfileDialog } from "./UserProfileDialog";
|
||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||
import { fetchUserProfile } from "../../../api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "../../../utils/utils";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
isDm?: boolean;
|
||||
children?: ReactNode;
|
||||
onReplySelect?: (message: MessageType) => void;
|
||||
onEditSelect?: (message: MessageType) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) {
|
||||
const { messages: hookMessages } = useChat();
|
||||
const { user } = useAppState();
|
||||
|
||||
// Use prop messages if provided, otherwise use hook messages
|
||||
const messages = propMessages || hookMessages;
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
||||
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
isOpen: false,
|
||||
message: null,
|
||||
position: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
// Delete dialog
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteDialogOpen) {
|
||||
setToBeDeleted(null);
|
||||
}
|
||||
}, [deleteDialogOpen]);
|
||||
|
||||
async function handleProfileClick(username: string) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
setIsLoadingProfile(true);
|
||||
try {
|
||||
const profile = await fetchUserProfile(user.authToken, username);
|
||||
if (profile) {
|
||||
setSelectedUserProfile(profile);
|
||||
setProfileDialogOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user profile:", error);
|
||||
} finally {
|
||||
setIsLoadingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
message,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
});
|
||||
};
|
||||
|
||||
function handleContextMenuOpenChange(isOpen: boolean) {
|
||||
setContextMenu(prev => ({
|
||||
...prev,
|
||||
isOpen
|
||||
}));
|
||||
};
|
||||
|
||||
function handleEdit(message: MessageType) {
|
||||
if (onEditSelect) onEditSelect(message);
|
||||
};
|
||||
|
||||
function handleReply(message: MessageType) {
|
||||
if (onReplySelect) onReplySelect(message);
|
||||
};
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!toBeDeleted || !user.authToken) return;
|
||||
try {
|
||||
onDelete?.(toBeDeleted.id);
|
||||
// if (toBeDeleted.isDm) {
|
||||
// // For DM, send dmDelete
|
||||
// await request({
|
||||
// type: "dmDelete",
|
||||
// data: { id: toBeDeleted.id },
|
||||
// credentials: { scheme: "Bearer", credentials: user.authToken }
|
||||
// });
|
||||
// } else {
|
||||
// await request({
|
||||
// type: "deleteMessage",
|
||||
// data: { message_id: toBeDeleted.id },
|
||||
// credentials: { scheme: "Bearer", credentials: user.authToken }
|
||||
// });
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error("Failed to delete message:", error);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(message: MessageType) {
|
||||
setToBeDeleted({ id: message.id, isDm });
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
{messages.map((message) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
isAuthor={message.username === user.currentUser?.username}
|
||||
onProfileClick={handleProfileClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
isLoadingProfile={isLoadingProfile}
|
||||
isDm={isDm}
|
||||
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<UserProfileDialog
|
||||
isOpen={profileDialogOpen}
|
||||
onOpenChange={async (value) => {
|
||||
setProfileDialogOpen(value);
|
||||
if (!value) {
|
||||
await delay(1000);
|
||||
setSelectedUserProfile(null);
|
||||
}
|
||||
}}
|
||||
userProfile={selectedUserProfile}
|
||||
/>
|
||||
|
||||
<MaterialDialog
|
||||
headline="Удалить сообщение?"
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}>
|
||||
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button>
|
||||
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.message && (
|
||||
<MessageContextMenu
|
||||
message={contextMenu.message}
|
||||
isAuthor={contextMenu.message.username === user.currentUser?.username}
|
||||
onEdit={handleEdit}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onOpenChange={handleContextMenuOpenChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useChat } from "../../hooks/useChat";
|
||||
|
||||
export function ChatTabs() {
|
||||
const { activeTab, setActiveTab, setCurrentChat } = useChat();
|
||||
|
||||
const handleChatClick = (chatName: string) => {
|
||||
setCurrentChat(chatName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
|
||||
<mdui-tab value="chats">
|
||||
Чаты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="channels">
|
||||
Каналы
|
||||
</mdui-tab>
|
||||
<mdui-tab value="contacts">
|
||||
Контакты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="dms">
|
||||
ЛС
|
||||
</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
headline="Общий чат"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-1"
|
||||
onClick={() => handleChatClick("Общий чат")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
headline="Общий чат 2"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-2"
|
||||
onClick={() => handleChatClick("Общий чат 2")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src="./src/resources/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-tab-panel slot="panel" value="dms">
|
||||
<mdui-list id="dm-users"></mdui-list>
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDM } from "../../hooks/useDM";
|
||||
import { useAppState } from "../../state";
|
||||
import { fetchUserPublicKey } from "../../../api/dmApi";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
|
||||
export function DMUsersList() {
|
||||
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||
const { chat, switchToDM } = useAppState();
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.activeTab === "dms") {
|
||||
loadUsers();
|
||||
}
|
||||
}, [chat.activeTab, loadUsers]);
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return (
|
||||
<mdui-list>
|
||||
<mdui-list-item headline="Загрузка..." description="Получение списка пользователей...">
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
|
||||
if (dmUsers.length === 0) {
|
||||
return (
|
||||
<mdui-list>
|
||||
<mdui-list-item headline="Нет пользователей" description="Пользователи не найдены">
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
|
||||
const handleUserClick = async (user: any) => {
|
||||
if (!user.publicKey) {
|
||||
// Get public key if not already loaded
|
||||
const authToken = useAppState.getState().user.authToken;
|
||||
if (!authToken) return;
|
||||
|
||||
const publicKey = await fetchUserPublicKey(user.id, authToken);
|
||||
if (publicKey) {
|
||||
user.publicKey = publicKey;
|
||||
} else {
|
||||
console.error("Failed to get public key for user:", user.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await switchToDM({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
publicKey: user.publicKey,
|
||||
profilePicture: user.profile_picture,
|
||||
online: user.online || false
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<mdui-list>
|
||||
{dmUsers.map((user) => (
|
||||
<mdui-list-item
|
||||
key={user.id}
|
||||
headline={user.username}
|
||||
description={user.lastMessage || "Нет сообщений"}
|
||||
onClick={() => handleUserClick(user)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img
|
||||
src={user.profile_picture || defaultAvatar}
|
||||
alt={user.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
{user.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{user.unreadCount}
|
||||
</mdui-badge>
|
||||
)}
|
||||
</mdui-list-item>
|
||||
))}
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { PRODUCT_NAME } from "../../../core/config";
|
||||
import { useAppState } from "../../state";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||
import { SettingsDialog } from "../settings/SettingsDialog";
|
||||
import { DMUsersList } from "./DMUsersList";
|
||||
import type { Tabs } from "mdui";
|
||||
import type { ChatTabs } from "../../state";
|
||||
|
||||
function BottomAppBar() {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
const { logout } = useAppState();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<mdui-bottom-app-bar>
|
||||
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
|
||||
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
<mdui-button-icon
|
||||
icon="logout--filled"
|
||||
id="logout-btn"
|
||||
onClick={handleLogout}
|
||||
title="Выйти"
|
||||
></mdui-button-icon>
|
||||
<mdui-fab icon="edit--filled"></mdui-fab>
|
||||
</mdui-bottom-app-bar>
|
||||
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatTabs() {
|
||||
const { chat, switchToTab, switchToPublicChat } = useAppState();
|
||||
const { activeTab } = chat;
|
||||
|
||||
const handleChatClick = async (chatName: string) => {
|
||||
await switchToPublicChat(chatName);
|
||||
};
|
||||
|
||||
const handleTabChange = async (e: FormEvent<Tabs>) => {
|
||||
const tab = (e.target as Tabs).value as ChatTabs;
|
||||
await switchToTab(tab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={activeTab} full-width onChange={handleTabChange}>
|
||||
<mdui-tab value="chats">Чаты</mdui-tab>
|
||||
<mdui-tab value="channels">Каналы</mdui-tab>
|
||||
<mdui-tab value="contacts">Контакты</mdui-tab>
|
||||
<mdui-tab value="dms">ЛС</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
headline="Общий чат"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-1"
|
||||
onClick={() => handleChatClick("Общий чат")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
headline="Общий чат 2"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-2"
|
||||
onClick={() => handleChatClick("Общий чат 2")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src={defaultAvatar} 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-tab-panel slot="panel" value="dms">
|
||||
<DMUsersList />
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatHeader() {
|
||||
const [isProfileOpen, setProfileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="chat-header-left">
|
||||
<div className="product-name">{PRODUCT_NAME}</div>
|
||||
<div className="profile">
|
||||
<a href="#" id="profile-open" onClick={() => setProfileOpen(true)}>
|
||||
<img src={defaultAvatar} alt="" id="preview1" />
|
||||
</a>
|
||||
</div>
|
||||
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setProfileOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftPanel() {
|
||||
return (
|
||||
<div className="chat-list" id="chat-list">
|
||||
<ChatHeader />
|
||||
<ChatTabs />
|
||||
<BottomAppBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
import { formatTime } from "../../../utils/utils";
|
||||
import type { Attachment, Message as MessageType } from "../../../core/types";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import Quote from "../core/Quote";
|
||||
import { parse } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { getCurrentKeys } from "../../../auth/crypto";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "../../../auth/api";
|
||||
import { useAppState } from "../../state";
|
||||
import { ub64 } from "../../../utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
isAuthor: boolean;
|
||||
onProfileClick: (username: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
isLoadingProfile?: boolean;
|
||||
isDm?: boolean;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(new Set());
|
||||
const [isDownloadingFullscreen, setIsDownloadingFullscreen] = useState(false);
|
||||
const [fullscreenImage, setFullscreenImage] = useState<{
|
||||
src: string;
|
||||
name: string;
|
||||
element: HTMLImageElement;
|
||||
startRect: Rect;
|
||||
endRect: Rect;
|
||||
} | null>(null);
|
||||
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
|
||||
const { user } = useAppState();
|
||||
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setFormattedMessage({
|
||||
__html: DOMPurify.sanitize(
|
||||
await parse(message.content)
|
||||
).trim()
|
||||
});
|
||||
})();
|
||||
}, [message]);
|
||||
|
||||
// Auto-decrypt images in DMs
|
||||
useEffect(() => {
|
||||
if (isDm && message.files) {
|
||||
message.files.forEach(async (file) => {
|
||||
console.log(file);
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
|
||||
console.log("Decrypting...");
|
||||
const decryptedUrl = await decryptFile(file);
|
||||
console.log(decryptedUrl);
|
||||
if (decryptedUrl) {
|
||||
updateDecryptedFiles(draft => {
|
||||
draft.set(file.path, decryptedUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [message.files, isDm, decryptedFiles]);
|
||||
|
||||
const decryptFile = async (file: Attachment): Promise<string | null> => {
|
||||
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
|
||||
debugger;
|
||||
console.warn("Conditions not met")
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if already decrypted
|
||||
if (decryptedFiles.has(file.path)) {
|
||||
return decryptedFiles.get(file.path) || null;
|
||||
}
|
||||
|
||||
try {
|
||||
// no-op decrypt indicator removed from UI
|
||||
// Fetch encrypted file
|
||||
const response = await fetch(file.path, {
|
||||
headers: getAuthHeaders(user.authToken!)
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch file");
|
||||
|
||||
const encryptedData = await response.arrayBuffer();
|
||||
|
||||
// Get current user's keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Derive shared secret with the recipient's public key
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
|
||||
|
||||
// Derive wrapping key using the salt from the DM envelope
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Unwrap the message key
|
||||
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
|
||||
|
||||
// Decrypt the file using the message key
|
||||
const iv = new Uint8Array(encryptedData, 0, 12);
|
||||
const ciphertext = new Uint8Array(encryptedData, 12);
|
||||
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
|
||||
|
||||
// Create blob URL for download
|
||||
const blob = new Blob([decrypted.buffer as ArrayBuffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
updateDecryptedFiles(draft => {
|
||||
draft.set(file.path, url);
|
||||
});
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt file:", error);
|
||||
return null;
|
||||
} finally {
|
||||
// no-op decrypt indicator removed from UI
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageClick = async (file: Attachment, imageElement: HTMLImageElement) => {
|
||||
// Use decrypted URL if available, otherwise decrypt first
|
||||
const decryptedUrl = decryptedFiles.get(file.path);
|
||||
if (decryptedUrl) {
|
||||
openFullscreenFromThumb(imageElement, decryptedUrl, file.name || "image");
|
||||
} else if (file.encrypted && isDm) {
|
||||
const newDecryptedUrl = await decryptFile(file);
|
||||
if (newDecryptedUrl) {
|
||||
openFullscreenFromThumb(imageElement, newDecryptedUrl, file.name || "image");
|
||||
}
|
||||
} else {
|
||||
openFullscreenFromThumb(imageElement, file.path, file.name || "image");
|
||||
}
|
||||
};
|
||||
|
||||
const computeEndRect = (naturalWidth: number, naturalHeight: number): Rect => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const maxWidth = Math.floor(viewportWidth * 0.9);
|
||||
const maxHeight = Math.floor(viewportHeight * 0.9);
|
||||
const widthRatio = maxWidth / naturalWidth;
|
||||
const heightRatio = maxHeight / naturalHeight;
|
||||
const scale = Math.min(widthRatio, heightRatio, 1);
|
||||
const width = Math.round(naturalWidth * scale);
|
||||
const height = Math.round(naturalHeight * scale);
|
||||
const left = Math.round((viewportWidth - width) / 2);
|
||||
const top = Math.round((viewportHeight - height) / 2);
|
||||
return { left, top, width, height };
|
||||
};
|
||||
|
||||
const openFullscreenFromThumb = (imgEl: HTMLImageElement, src: string, name: string) => {
|
||||
const rect = imgEl.getBoundingClientRect();
|
||||
const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
||||
const tempImg = new Image();
|
||||
tempImg.src = src;
|
||||
// Hide original while animating
|
||||
imgEl.style.visibility = "hidden";
|
||||
tempImg.onload = () => {
|
||||
const endRect = computeEndRect(tempImg.naturalWidth, tempImg.naturalHeight);
|
||||
setFullscreenImage({
|
||||
src,
|
||||
name,
|
||||
element: imgEl,
|
||||
startRect,
|
||||
endRect
|
||||
});
|
||||
// Start animation on next frame to ensure DOM has overlay mounted
|
||||
requestAnimationFrame(() => setIsAnimatingOpen(true));
|
||||
};
|
||||
};
|
||||
|
||||
const closeFullscreen = () => {
|
||||
// Reverse animation
|
||||
setIsAnimatingOpen(false);
|
||||
// Wait for transition to finish
|
||||
setTimeout(() => {
|
||||
if (fullscreenImage?.element) {
|
||||
fullscreenImage.element.style.visibility = "visible";
|
||||
}
|
||||
setFullscreenImage(null);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const downloadImage = async () => {
|
||||
if (!fullscreenImage) return;
|
||||
const { src, name } = fullscreenImage;
|
||||
try {
|
||||
setIsDownloadingFullscreen(true);
|
||||
if (src.startsWith("blob:")) {
|
||||
const link = document.createElement("a");
|
||||
link.href = src;
|
||||
link.download = name;
|
||||
link.click();
|
||||
setIsDownloadingFullscreen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch with credentials/headers when not a blob URL
|
||||
const response = await fetch(src, {
|
||||
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined,
|
||||
credentials: "include"
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to download image");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsDownloadingFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = async (file: Attachment) => {
|
||||
try {
|
||||
updateDownloadingPaths(draft => {
|
||||
draft.add(file.path);
|
||||
});
|
||||
// Prefer decrypted URL if present (DM encrypted case)
|
||||
const decrypted = decryptedFiles.get(file.path);
|
||||
if (decrypted) {
|
||||
const link = document.createElement("a");
|
||||
link.href = decrypted;
|
||||
link.download = file.name || "file";
|
||||
link.click();
|
||||
updateDownloadingPaths(draft => {
|
||||
draft.delete(file.path);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If not decrypted or public file, fetch with credentials/headers
|
||||
const response = await fetch(file.path, {
|
||||
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined,
|
||||
credentials: "include"
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to download file");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = file.name || "file";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
updateDownloadingPaths(draft => {
|
||||
draft.delete(file.path);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, message);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`message ${isAuthor ? "sent" : "received"}`}
|
||||
data-id={message.id}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="message-inner">
|
||||
{/* Add profile picture for received messages */}
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-profile-pic">
|
||||
<img
|
||||
src={message.profile_picture || defaultAvatar}
|
||||
alt={message.username}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
|
||||
className={isLoadingProfile ? "loading" : ""}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthor && !isDm && (
|
||||
<div
|
||||
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
|
||||
{message.username}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add reply preview if this is a reply */}
|
||||
{message.reply_to && (
|
||||
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
|
||||
<span className="reply-username">{message.reply_to.username}</span>
|
||||
<span className="reply-text">{message.reply_to.content}</span>
|
||||
</Quote>
|
||||
)}
|
||||
|
||||
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
|
||||
|
||||
{message.files && message.files.length > 0 && (
|
||||
<mdui-list className="message-attachments">
|
||||
{message.files.map((file, idx) => {
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
const isEncryptedDm = Boolean(isDm && file.encrypted);
|
||||
const decryptedUrl = decryptedFiles.get(file.path);
|
||||
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
|
||||
const isDownloading = downloadingPaths.has(file.path);
|
||||
|
||||
return (
|
||||
<div className="attachment" key={idx}>
|
||||
{isImage ? (
|
||||
<div className="image-wrapper">
|
||||
<img
|
||||
ref={(el) => {
|
||||
if (el) imageRefs.current.set(file.path, el);
|
||||
}}
|
||||
src={imageSrc}
|
||||
alt={file.name || "image"}
|
||||
onClick={(e) => handleImageClick(file, e.currentTarget)}
|
||||
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
|
||||
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
|
||||
/>
|
||||
{!loadedImages.has(file.path) && (
|
||||
<div className="loading-overlay">
|
||||
<mdui-circular-progress />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
href="#"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
await downloadFile(file);
|
||||
}}
|
||||
>
|
||||
<mdui-list-item>
|
||||
<span className="with-icon-gap">
|
||||
{isDownloading ? <mdui-circular-progress /> : null}
|
||||
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
||||
</span>
|
||||
</mdui-list-item>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</mdui-list>
|
||||
)}
|
||||
|
||||
<div className="message-time">
|
||||
{formatTime(message.timestamp)}
|
||||
{message.is_edited ? " (edited)" : undefined}
|
||||
|
||||
{isAuthor && message.is_read && (
|
||||
<span className="material-symbols outlined"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Image Viewer with shared-element like transition */}
|
||||
{fullscreenImage && (
|
||||
<div
|
||||
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
|
||||
onClick={closeFullscreen}>
|
||||
<img
|
||||
src={fullscreenImage.src}
|
||||
alt={fullscreenImage.name}
|
||||
className={`fullscreen-animated-image ${isAnimatingOpen ? "to-end" : "to-start"}`}
|
||||
style={{
|
||||
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
|
||||
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
|
||||
width: `${isAnimatingOpen ? fullscreenImage.endRect.width : fullscreenImage.startRect.width}px`,
|
||||
height: `${isAnimatingOpen ? fullscreenImage.endRect.height : fullscreenImage.startRect.height}px`
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
|
||||
<mdui-button-icon icon="close" onClick={closeFullscreen} />
|
||||
{isDownloadingFullscreen ? (
|
||||
<div className="progress-wrapper">
|
||||
<mdui-circular-progress />
|
||||
</div>
|
||||
) : (
|
||||
<mdui-button-icon icon="download" onClick={downloadImage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Message, Size2D } from "../../../core/types";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
message: Message;
|
||||
isAuthor: boolean;
|
||||
onEdit: (message: Message) => void;
|
||||
onReply: (message: Message) => void;
|
||||
onDelete: (message: Message) => void;
|
||||
position: Size2D;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
isOpen: boolean;
|
||||
message: Message | null;
|
||||
position: Size2D;
|
||||
}
|
||||
|
||||
export function MessageContextMenu({
|
||||
message,
|
||||
isAuthor,
|
||||
onEdit,
|
||||
onReply,
|
||||
onDelete,
|
||||
position,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: MessageContextMenuProps) {
|
||||
// Internal state for closing animation
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||
const [animationClass, setAnimationClass] = useState('entering');
|
||||
|
||||
// Calculate smart positioning when component opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const menuWidth = 160; // min-width from CSS
|
||||
const menuHeight = isAuthor ? 120 : 60; // Approximate height based on items
|
||||
const padding = 10; // Padding from viewport edges
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
let animation = 'entering';
|
||||
|
||||
// Check if menu would overflow right edge
|
||||
if (x + menuWidth + padding > viewportWidth) {
|
||||
x = viewportWidth - menuWidth - padding;
|
||||
animation = 'entering-left'; // Animation from left side
|
||||
}
|
||||
|
||||
// Check if menu would overflow bottom edge
|
||||
if (y + menuHeight + padding > viewportHeight) {
|
||||
y = viewportHeight - menuHeight - padding;
|
||||
animation = 'entering-up'; // Animation from bottom
|
||||
}
|
||||
|
||||
// If both edges would overflow, use top-left positioning
|
||||
if (x + menuWidth + padding > viewportWidth && y + menuHeight + padding > viewportHeight) {
|
||||
x = Math.max(padding, position.x - menuWidth);
|
||||
y = Math.max(padding, position.y - menuHeight);
|
||||
animation = 'entering-up-left';
|
||||
}
|
||||
|
||||
setCalculatedPosition({ x, y });
|
||||
setAnimationClass(animation);
|
||||
}
|
||||
}, [isOpen, position, isAuthor]);
|
||||
|
||||
// Effect to handle clicks outside the context menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isOpen && !isClosing) {
|
||||
// Check if the click is on a context menu element
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('.context-menu')) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
// Close context menu when browser window loses focus
|
||||
if (isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
};
|
||||
}, [isOpen, isClosing]);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
switch (action) {
|
||||
case "reply":
|
||||
onReply(message);
|
||||
handleClose();
|
||||
break;
|
||||
case "edit":
|
||||
if (isAuthor) onEdit(message);
|
||||
break;
|
||||
case "delete":
|
||||
if (isAuthor) {
|
||||
onDelete(message);
|
||||
handleClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
// Set appropriate closing animation based on opening animation
|
||||
const closingAnimation = animationClass.replace('entering', 'closing');
|
||||
setAnimationClass(closingAnimation);
|
||||
|
||||
// Wait for animation to complete before calling onOpenChange
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
setIsClosing(false);
|
||||
setAnimationClass('entering'); // Reset for next opening
|
||||
}, 200); // Match the animation duration from _animations.scss
|
||||
};
|
||||
|
||||
return isOpen && (
|
||||
<div
|
||||
className={`context-menu ${animationClass}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
display: "block",
|
||||
top: calculatedPosition.y,
|
||||
left: calculatedPosition.x,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="context-menu-item" onClick={() => handleAction("reply")}>
|
||||
<span className="material-symbols">reply</span>
|
||||
Ответить
|
||||
</div>
|
||||
{isAuthor && (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction("edit")}>
|
||||
<span className="material-symbols">edit</span>
|
||||
Редактировать
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction("delete")}>
|
||||
<span className="material-symbols">delete</span>
|
||||
Удалить
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { setGlobalMessageHandler } from "../../../core/websocket";
|
||||
import type { Message } from "../../../core/types";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
|
||||
import type { DMPanel } from "../../panels/DMPanel";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
isChatSwitching: boolean;
|
||||
}
|
||||
|
||||
export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRendererProps) {
|
||||
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||
const [switchIn, setSwitchIn] = useState(false);
|
||||
const [switchOut, setSwitchOut] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [replyTo, setReplyTo] = useState<Message | null>(null);
|
||||
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
|
||||
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
||||
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
|
||||
|
||||
// Drag & drop
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const addFilesRef = useRef<null | ((files: File[]) => void)>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panel || !panelState) return;
|
||||
|
||||
return () => {
|
||||
dragCounterRef.current = 0;
|
||||
setIsDragging(false);
|
||||
};
|
||||
}, [panel, panelState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replyTo) {
|
||||
setReplyToVisible(true);
|
||||
}
|
||||
}, [replyTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMessage) {
|
||||
setEditVisible(true);
|
||||
}
|
||||
}, [editMessage]);
|
||||
|
||||
// Handle panel state changes
|
||||
useEffect(() => {
|
||||
if (panel) {
|
||||
setPanelState(panel.getState());
|
||||
|
||||
// Set up state change listener
|
||||
const handleStateChange = (newState: MessagePanelState) => {
|
||||
setPanelState(newState);
|
||||
};
|
||||
|
||||
// Store the handler for cleanup
|
||||
panel.onStateChange = handleStateChange;
|
||||
|
||||
// Set up WebSocket message handler for this panel
|
||||
if (panel.handleWebSocketMessage) {
|
||||
setGlobalMessageHandler(panel.handleWebSocketMessage);
|
||||
}
|
||||
} else {
|
||||
setPanelState(null);
|
||||
// Clear global message handler when no panel is active
|
||||
setGlobalMessageHandler(null);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (panel && panel.onStateChange) {
|
||||
panel.onStateChange = null;
|
||||
}
|
||||
};
|
||||
}, [panel]);
|
||||
|
||||
// Handle chat switching animation
|
||||
useEffect(() => {
|
||||
if (isChatSwitching) {
|
||||
setSwitchOut(true);
|
||||
setTimeout(() => {
|
||||
setSwitchOut(false);
|
||||
setSwitchIn(true);
|
||||
setTimeout(() => setSwitchIn(false), 200);
|
||||
}, 250);
|
||||
}
|
||||
}, [isChatSwitching]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [panelState?.messages]);
|
||||
|
||||
if (!panel || !panelState) {
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<div className="chat-main" id="chat-inner">
|
||||
<div className="chat-header">
|
||||
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">Выбор чата</h4>
|
||||
<p>
|
||||
<span className="online-status"></span>
|
||||
Выберите чат, чтобы начать переписку
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Выберите чат на боковой панели, чтобы начать переписку
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||
<div
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
onDragEnter={(e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current += 1;
|
||||
// Only show overlay when actual files are dragged
|
||||
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
|
||||
if (hasFiles) setIsDragging(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) setIsDragging(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length > 0 && addFilesRef.current) {
|
||||
addFilesRef.current(files);
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
}}>
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={panelState.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={panel.handleProfileClick}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState.title}</h4>
|
||||
<p>
|
||||
<span className={`online-status ${panelState.online ? "online" : "offline"}`}></span>
|
||||
{panelState.online ? "Online" : "Offline"}
|
||||
{panelState.isTyping && " • Typing..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{panelState.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
</div>
|
||||
): (
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: message });
|
||||
setEditVisible(false); // onCloseEdit will apply pending
|
||||
} else {
|
||||
setReplyTo(message);
|
||||
}
|
||||
}}
|
||||
onEditSelect={(message) => {
|
||||
if (replyTo || replyToVisible) {
|
||||
setPendingAction({ type: "edit", message: message });
|
||||
setReplyToVisible(false); // onCloseReply will apply pending
|
||||
} else {
|
||||
setEditMessage(message);
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => panel.handleDeleteMessage(id)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
)}
|
||||
|
||||
<AnimatedOpacity
|
||||
visible={isDragging}
|
||||
className="file-overlay"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}>
|
||||
<div className="file-overlay-wrapper">
|
||||
<div className="file-overlay-inner">
|
||||
<mdui-icon name="upload_file" />
|
||||
<span>Отпустите файл(ы) для добавления</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedOpacity>
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
if (editMessage) {
|
||||
panel.handleEditMessage(editMessage.id, content);
|
||||
setEditMessage(null);
|
||||
}
|
||||
}}
|
||||
replyTo={replyTo}
|
||||
replyToVisible={replyToVisible}
|
||||
onClearReply={() => {
|
||||
setPendingAction(null);
|
||||
setReplyToVisible(false);
|
||||
}}
|
||||
onCloseReply={() => {
|
||||
setReplyTo(null);
|
||||
if (pendingAction && pendingAction.type === "edit") {
|
||||
setEditMessage(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
editingMessage={editMessage}
|
||||
editVisible={editVisible}
|
||||
onClearEdit={() => {
|
||||
setPendingAction(null);
|
||||
setEditVisible(false);
|
||||
}}
|
||||
onCloseEdit={() => {
|
||||
setEditMessage(null);
|
||||
if (pendingAction && pendingAction.type === "reply") {
|
||||
setReplyTo(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Message } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { MaterialTextField } from "../core/TextField";
|
||||
|
||||
interface ReplyMessageDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
replyToMessage: Message | null;
|
||||
onSendReply: (content: string, replyToId: number) => void;
|
||||
}
|
||||
|
||||
export function ReplyMessageDialog({ isOpen, onOpenChange, replyToMessage, onSendReply }: ReplyMessageDialogProps) {
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (replyToMessage) {
|
||||
setReplyContent("");
|
||||
}
|
||||
}, [replyToMessage]);
|
||||
|
||||
const handleSendReply = () => {
|
||||
if (replyToMessage && replyContent.trim()) {
|
||||
onSendReply(replyContent.trim(), replyToMessage.id);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
setReplyContent("");
|
||||
};
|
||||
|
||||
if (!replyToMessage) return null;
|
||||
|
||||
return (
|
||||
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc className="reply-dialog">
|
||||
<div className="dialog-content">
|
||||
<h3>Ответить на сообщение</h3>
|
||||
<div className="reply-preview-dialog">
|
||||
<div className="reply-content">
|
||||
<span className="reply-username">{replyToMessage.username}</span>
|
||||
<span className="reply-text">{replyToMessage.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
<MaterialTextField
|
||||
value={replyContent}
|
||||
onInput={(e) => setReplyContent((e.target as HTMLInputElement).value)}
|
||||
label="Reply"
|
||||
variant="outlined"
|
||||
placeholder="Type your reply..."
|
||||
maxlength={1000} />
|
||||
<div className="dialog-actions">
|
||||
<mdui-button onClick={handleCancel} variant="outlined">Cancel</mdui-button>
|
||||
<mdui-button onClick={handleSendReply}>Send Reply</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useAppState } from "../../state";
|
||||
import { MessagePanelRenderer } from "./MessagePanelRenderer";
|
||||
|
||||
export function RightPanel() {
|
||||
const { chat } = useAppState();
|
||||
|
||||
return (
|
||||
<MessagePanelRenderer
|
||||
panel={chat.activePanel}
|
||||
isChatSwitching={chat.isChatSwitching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import type { UserProfile } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { formatTime } from "../../../utils/utils";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
|
||||
interface UserProfileDialogProps extends DialogProps {
|
||||
userProfile: UserProfile | null;
|
||||
}
|
||||
|
||||
export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserProfileDialogProps) {
|
||||
const content = userProfile ? (
|
||||
<div className="content">
|
||||
<div className="profile-picture-section">
|
||||
<img
|
||||
className="profile-picture"
|
||||
alt="Profile Picture"
|
||||
src={userProfile.profile_picture || defaultAvatar}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<div className="username-section">
|
||||
<h4 className="username">{userProfile.username}</h4>
|
||||
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
|
||||
{userProfile.online ? (
|
||||
<>
|
||||
<span className="online-indicator"></span> Онлайн
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bio-section">
|
||||
<label>О себе:</label>
|
||||
<div className="bio-display">
|
||||
{userProfile.bio || "No bio available."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Зарегистрирован:</span>
|
||||
<span className="stat-value member-since">{formatTime(userProfile.created_at)}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Last seen:</span>
|
||||
<span className="stat-value last-seen">{formatTime(userProfile.last_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
<mdui-button id="dm-button" variant="filled">
|
||||
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
|
||||
Send Message
|
||||
</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc id="user-profile-dialog">
|
||||
{content}
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Dialog as MduiDialog } from "mdui/components/dialog";
|
||||
import { useEffect, type Ref } from "react"
|
||||
import { createPortal } from "react-dom";
|
||||
import { id } from "../../../utils/utils";
|
||||
import useCombinedRefs from "../../hooks/useCombinedRefs";
|
||||
|
||||
export interface BaseDialogProps {
|
||||
onOpenChange: (value: boolean) => void;
|
||||
ref?: Ref<MduiDialog & HTMLElement>
|
||||
}
|
||||
|
||||
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
|
||||
|
||||
export function MaterialDialog(props: FullDialogProps) {
|
||||
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === "attributes" && mutation.attributeName === "open") {
|
||||
const isOpen = dialog.hasAttribute("open");
|
||||
if (isOpen !== props.open) {
|
||||
props.onOpenChange(isOpen);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start observing the dialog element for attribute changes
|
||||
observer.observe(dialog, {
|
||||
attributes: true,
|
||||
attributeFilter: ["open"]
|
||||
});
|
||||
|
||||
// Cleanup observer
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [dialogRef.current, props.open, props.onOpenChange]);
|
||||
|
||||
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
|
||||
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
|
||||
|
||||
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
|
||||
return <mdui-text-field
|
||||
autocomplete="off"
|
||||
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { AnimatedPropertyProps } from "./types";
|
||||
|
||||
export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) {
|
||||
const [height, setHeight] = useState("0px");
|
||||
const [shouldRender, setShouldRender] = useState(!!visible);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
setIsAnimating(true);
|
||||
// Wait for content to render, then measure
|
||||
setTimeout(() => {
|
||||
if (measureRef.current) {
|
||||
const contentHeight = measureRef.current.scrollHeight;
|
||||
setHeight(`${contentHeight}px`);
|
||||
}
|
||||
// Animation complete
|
||||
setTimeout(() => {
|
||||
setHeight("auto");
|
||||
setIsAnimating(false);
|
||||
}, duration * 1000);
|
||||
}, 0);
|
||||
} else if (shouldRender) {
|
||||
setIsAnimating(true);
|
||||
if (measureRef.current) {
|
||||
const contentHeight = measureRef.current.scrollHeight;
|
||||
setHeight(`${contentHeight}px`);
|
||||
// Force a reflow before animating to 0
|
||||
requestAnimationFrame(() => {
|
||||
// Read layout to ensure the previous height assignment is flushed
|
||||
if (containerRef.current) {
|
||||
containerRef.current.offsetHeight;
|
||||
}
|
||||
// Use a second frame to ensure the measured pixel height is applied before collapsing
|
||||
requestAnimationFrame(() => {
|
||||
setHeight("0px");
|
||||
});
|
||||
});
|
||||
}
|
||||
// Hide content after animation completes
|
||||
setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsAnimating(false);
|
||||
if (onFinish) {
|
||||
onFinish();
|
||||
}
|
||||
}, duration * 1000);
|
||||
}
|
||||
}, [visible, shouldRender]);
|
||||
|
||||
return (visible || shouldRender || isAnimating) && (
|
||||
<div
|
||||
{...props}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height,
|
||||
transition: `height ${duration}s ease`,
|
||||
overflow: "hidden",
|
||||
...props.style
|
||||
}}
|
||||
>
|
||||
<div ref={measureRef} style={{ height: "auto" }}>
|
||||
{shouldRender && children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AnimatedPropertyProps } from "./types";
|
||||
|
||||
export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) {
|
||||
const [opacity, setOpacity] = useState(visible ? 1 : 0);
|
||||
const [shouldRender, setShouldRender] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
setOpacity(0);
|
||||
|
||||
// Wait for content to render, then animate in
|
||||
const id = setTimeout(() => {
|
||||
setOpacity(1);
|
||||
}, 10);
|
||||
return () => clearTimeout(id);
|
||||
} else {
|
||||
setOpacity(0);
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
if (onFinish) {
|
||||
onFinish();
|
||||
}
|
||||
}, duration * 1000);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [visible, duration, onFinish]);
|
||||
|
||||
return shouldRender && (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
opacity,
|
||||
transition: `opacity ${duration}s ease`,
|
||||
...props.style
|
||||
}}
|
||||
>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface BaseAnimatedPropertyProps {
|
||||
visible: any;
|
||||
duration?: number;
|
||||
onFinish?: () => void
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div">
|
||||
@@ -1,19 +0,0 @@
|
||||
export function CropperDialog() {
|
||||
return (
|
||||
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<div id="cropper-area"></div>
|
||||
</div>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
|
||||
<mdui-button id="crop-save">Сохранить</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Size2D, Rect } from "../../../core/types";
|
||||
|
||||
interface ImageCropperProps {
|
||||
onCrop: (croppedImageData: string) => void;
|
||||
onCancel: () => void;
|
||||
imageFile: File | null;
|
||||
}
|
||||
|
||||
export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [cropArea, setCropArea] = useState<Rect>({ x: 0, y: 0, width: 200, height: 200 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (imageFile) {
|
||||
const reader = new FileReader();
|
||||
|
||||
function handleImageLoad() {
|
||||
setIsLoaded(true);
|
||||
// Initialize crop area to center of image
|
||||
const img = imageRef.current;
|
||||
if (img) {
|
||||
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
|
||||
setCropArea({
|
||||
x: (img.naturalWidth - size) / 2,
|
||||
y: (img.naturalHeight - size) / 2,
|
||||
width: size,
|
||||
height: size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleReaderLoad() {
|
||||
if (imageRef.current) {
|
||||
setSrc(reader.result as string);
|
||||
imageRef.current.addEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
|
||||
reader.addEventListener("load", handleReaderLoad);
|
||||
reader.readAsDataURL(imageFile);
|
||||
|
||||
return () => {
|
||||
reader.abort();
|
||||
reader.removeEventListener("load", handleReaderLoad);
|
||||
imageRef.current?.removeEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
}, [imageFile]);
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent) {
|
||||
if (!isLoaded) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Check if click is within crop area
|
||||
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
|
||||
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: x - cropArea.x, y: y - cropArea.y });
|
||||
}
|
||||
};
|
||||
|
||||
function handleMouseMove(e: React.MouseEvent) {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (isDragging && isLoaded && rect && imageRef.current) {
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const newX = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
x - dragStart.x,
|
||||
imageRef.current.naturalWidth - cropArea.width
|
||||
)
|
||||
);
|
||||
const newY = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
y - dragStart.y,
|
||||
imageRef.current.naturalHeight - cropArea.height
|
||||
)
|
||||
);
|
||||
|
||||
setCropArea(prev => ({ ...prev, x: newX, y: newY }));
|
||||
}
|
||||
};
|
||||
|
||||
function handleMouseUp() {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
function handleCrop() {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to crop area
|
||||
canvas.width = cropArea.width;
|
||||
canvas.height = cropArea.height;
|
||||
|
||||
// Draw cropped portion
|
||||
ctx.drawImage(
|
||||
imageRef.current,
|
||||
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
|
||||
0, 0, cropArea.width, cropArea.height
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
onCrop(croppedImageData);
|
||||
};
|
||||
|
||||
function drawCropArea() {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw crop overlay
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Clear crop area
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
|
||||
// Draw crop border
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
drawCropArea();
|
||||
}, [cropArea, isLoaded]);
|
||||
|
||||
if (!imageFile) return null;
|
||||
|
||||
return (
|
||||
<div className="cropper-container">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={400}
|
||||
style={{
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
border: '1px solid #ccc',
|
||||
maxWidth: '100%',
|
||||
height: 'auto'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
style={{ display: 'none' }}
|
||||
alt="Crop source"
|
||||
/>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button onClick={handleCrop} disabled={!isLoaded}>
|
||||
Обрезать
|
||||
</mdui-button>
|
||||
<mdui-button variant="outlined" onClick={onCancel}>
|
||||
Отмена
|
||||
</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
import { MaterialTextField } from "../core/TextField";
|
||||
|
||||
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
|
||||
|
||||
const [username, setUsername] = useState(profileData?.nickname ?? "");
|
||||
const [description, setDescription] = useState(profileData?.description ?? "");
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [showCropper, setShowCropper] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Update form fields when profile data changes
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
setUsername(profileData.nickname || "");
|
||||
setDescription(profileData.description || "");
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const success = await updateProfileData({
|
||||
nickname: username.trim() || undefined,
|
||||
description: description.trim() || undefined
|
||||
});
|
||||
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
setSelectedImage(file);
|
||||
setShowCropper(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropComplete = async (croppedImageData: string) => {
|
||||
try {
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(croppedImageData);
|
||||
const blob = await response.blob();
|
||||
|
||||
const success = await uploadProfilePictureData(blob);
|
||||
if (success) {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing cropped image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
|
||||
<div className="content">
|
||||
<div className="header-top">
|
||||
<div className="profile-picture-container">
|
||||
<img
|
||||
id="profile-picture"
|
||||
src={profilePictureUrl}
|
||||
alt="Ваше фото"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<mdui-button-icon
|
||||
icon="camera_alt--filled"
|
||||
id="upload-pfp-btn"
|
||||
className="upload-overlay"
|
||||
variant="filled"
|
||||
onClick={handleUploadClick}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="pfp-file-input"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
</div>
|
||||
<MaterialTextField
|
||||
id="username-field"
|
||||
label="Имя пользователя"
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
|
||||
autocomplete="username"
|
||||
disabled={isLoading || isUpdating} />
|
||||
</div>
|
||||
|
||||
<form id="profile-form" onSubmit={handleSubmit}>
|
||||
<MaterialTextField
|
||||
id="description-field"
|
||||
label="О себе"
|
||||
variant="outlined"
|
||||
value={description}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
|
||||
placeholder="Расскажите о себе..."
|
||||
autocomplete="none"
|
||||
disabled={isLoading || isUpdating} />
|
||||
<div className="dialog-actions">
|
||||
<mdui-button
|
||||
type="submit"
|
||||
id="profile-submit"
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
|
||||
</mdui-button>
|
||||
<mdui-button
|
||||
id="profile-dialog-close"
|
||||
variant="outlined"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Закрыть
|
||||
</mdui-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Image Cropper Dialog */}
|
||||
<MaterialDialog
|
||||
id="cropper-dialog"
|
||||
close-on-overlay-click
|
||||
close-on-esc
|
||||
open={showCropper}
|
||||
onOpenChange={setShowCropper}
|
||||
>
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" onClick={handleCropCancel} />
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<ImageCropper
|
||||
imageFile={selectedImage}
|
||||
onCrop={handleCropComplete}
|
||||
onCancel={handleCropCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config";
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../utils/push-notifications";
|
||||
import { isElectron } from "../../../electron/electron";
|
||||
import { useAppState } from "../../state";
|
||||
import type { Switch } from "mdui/components/switch";
|
||||
import { getAuthHeaders } from "../../../auth/api";
|
||||
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const [activePanel, setActivePanel] = useState("notifications-settings");
|
||||
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
|
||||
const [pushSupported, setPushSupported] = useState(false);
|
||||
const user = useAppState(state => state.user);
|
||||
|
||||
useEffect(() => {
|
||||
setPushSupported(isSupported());
|
||||
// For Electron, we assume notifications are enabled if supported
|
||||
// For web browsers, we check if there's a subscription
|
||||
setPushNotificationsEnabled(isSupported());
|
||||
}, []);
|
||||
|
||||
const handlePanelChange = (panelId: string) => {
|
||||
setActivePanel(panelId);
|
||||
};
|
||||
|
||||
const handlePushNotificationToggle = async (enabled: boolean) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(user.authToken);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
setPushNotificationsEnabled(true);
|
||||
}
|
||||
} else {
|
||||
await unsubscribe();
|
||||
|
||||
// For Electron, stop the notification receiver
|
||||
if (isElectron) {
|
||||
stopElectronReceiver();
|
||||
}
|
||||
|
||||
// Call API to unsubscribe on server (for web browsers)
|
||||
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(user.authToken)
|
||||
});
|
||||
setPushNotificationsEnabled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle notifications:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
|
||||
<div className="fullscreen-wrapper">
|
||||
<div id="settings-dialog-inner">
|
||||
<div className="header">
|
||||
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></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={activePanel === "notifications-settings"}
|
||||
onClick={() => handlePanelChange("notifications-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Уведомления
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="palette--filled"
|
||||
rounded
|
||||
active={activePanel === "appearance-settings"}
|
||||
onClick={() => handlePanelChange("appearance-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Внешний вид
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="security--filled"
|
||||
rounded
|
||||
active={activePanel === "security-settings"}
|
||||
onClick={() => handlePanelChange("security-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Безопасность
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="language--filled"
|
||||
rounded
|
||||
active={activePanel === "language-settings"}
|
||||
onClick={() => handlePanelChange("language-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Язык
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="storage--filled"
|
||||
rounded
|
||||
active={activePanel === "storage-settings"}
|
||||
onClick={() => handlePanelChange("storage-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Хранилище
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="help--filled"
|
||||
rounded
|
||||
active={activePanel === "help-settings"}
|
||||
onClick={() => handlePanelChange("help-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Помощь
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
icon="info--filled"
|
||||
rounded
|
||||
active={activePanel === "about-settings"}
|
||||
onClick={() => handlePanelChange("about-settings")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
О приложении
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
<div className="screen">
|
||||
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
|
||||
<h3>Уведомления</h3>
|
||||
{pushSupported && (
|
||||
<mdui-switch
|
||||
checked={pushNotificationsEnabled}
|
||||
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
|
||||
>
|
||||
Push уведомления
|
||||
</mdui-switch>
|
||||
)}
|
||||
<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" className={`settings-panel ${activePanel === "appearance-settings" ? "active" : ""}`}>
|
||||
<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" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
|
||||
<h3>Безопасность</h3>
|
||||
<mdui-button variant="outlined">Изменить пароль</mdui-button>
|
||||
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
|
||||
<mdui-switch>Автоматический выход</mdui-switch>
|
||||
</div>
|
||||
|
||||
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
|
||||
<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" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
|
||||
<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" className={`settings-panel ${activePanel === "help-settings" ? "active" : ""}`}>
|
||||
<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" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
|
||||
<h3>О приложении</h3>
|
||||
<p>Версия: 1.0.0</p>
|
||||
<p>© 2025 <span className="product-name">{PRODUCT_NAME}</span>. Все права защищены.</p>
|
||||
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
|
||||
<mdui-button variant="outlined">Условия использования</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { useAppState } from "../state";
|
||||
import { request } from "../../core/websocket";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import type { Message } from "../../core/types";
|
||||
import { getAuthHeaders } from "../../auth/api";
|
||||
import { delay } from "../../utils/utils";
|
||||
|
||||
export function useChat() {
|
||||
const {
|
||||
chat,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
removeMessage,
|
||||
clearMessages,
|
||||
setCurrentChat,
|
||||
setActiveTab,
|
||||
setDmUsers,
|
||||
setActiveDm,
|
||||
setIsChatSwitching,
|
||||
user
|
||||
} = useAppState();
|
||||
|
||||
const messagesLoadedRef = useRef(false);
|
||||
|
||||
// Load messages for the current chat
|
||||
const loadMessages = useCallback(async () => {
|
||||
if (!user.authToken || messagesLoadedRef.current) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders(user.authToken)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
// Clear existing messages and add new ones
|
||||
clearMessages();
|
||||
data.messages.forEach((msg: Message) => {
|
||||
addMessage(msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
messagesLoadedRef.current = true;
|
||||
} catch (error) {
|
||||
console.error("Error loading messages:", error);
|
||||
}
|
||||
}, [user.authToken, addMessage, clearMessages]);
|
||||
|
||||
// Send a message
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
if (!user.authToken || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await request({
|
||||
data: { content: content.trim() },
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: user.authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
console.error("Error sending message:", response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// WebSocket messages are now handled by the active panel
|
||||
// No need for duplicate handling here
|
||||
|
||||
// Load messages only once when component mounts and user is authenticated
|
||||
useEffect(() => {
|
||||
if (user.authToken && !messagesLoadedRef.current) {
|
||||
loadMessages();
|
||||
}
|
||||
}, [user.authToken, loadMessages]);
|
||||
|
||||
// Reset messages loaded flag and clear messages when chat changes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setIsChatSwitching(true);
|
||||
await delay(250);
|
||||
messagesLoadedRef.current = false;
|
||||
clearMessages(); // Clear messages when switching chats
|
||||
loadMessages();
|
||||
setIsChatSwitching(false);
|
||||
})();
|
||||
}, [chat.currentChat, clearMessages]);
|
||||
|
||||
return {
|
||||
messages: chat.messages,
|
||||
currentChat: chat.currentChat,
|
||||
activeTab: chat.activeTab,
|
||||
dmUsers: chat.dmUsers,
|
||||
activeDm: chat.activeDm,
|
||||
isChatSwitching: chat.isChatSwitching,
|
||||
setIsChatSwitching,
|
||||
sendMessage,
|
||||
updateMessage,
|
||||
removeMessage,
|
||||
clearMessages,
|
||||
setCurrentChat,
|
||||
setActiveTab,
|
||||
setDmUsers,
|
||||
setActiveDm
|
||||
};
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppState } from "../state";
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUserPublicKey,
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
} from "../../api/dmApi";
|
||||
import type { User, Message, DmEncryptedJSON } from "../../core/types";
|
||||
import { websocket } from "../../core/websocket";
|
||||
|
||||
interface DMUser extends User {
|
||||
lastMessage?: string;
|
||||
unreadCount: number;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
export function useDM() {
|
||||
const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState();
|
||||
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const usersLoadedRef = useRef(false);
|
||||
|
||||
// Load last message and unread count for a specific user
|
||||
const loadUserLastMessage = useCallback(async (dmUser: DMUser) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
// Get public key
|
||||
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) return;
|
||||
|
||||
// Get message history
|
||||
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
|
||||
if (messages.length === 0) return;
|
||||
|
||||
// Find last message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let lastPlaintext: string | null = null;
|
||||
|
||||
try {
|
||||
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
||||
console.log(lastPlaintext);
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message:", error);
|
||||
}
|
||||
|
||||
// Calculate unread count
|
||||
const lastReadId = getLastReadId(dmUser.id);
|
||||
let unreadCount = 0;
|
||||
for (const msg of messages) {
|
||||
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
|
||||
unreadCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user state
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === dmUser.id
|
||||
? {
|
||||
...u,
|
||||
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
|
||||
unreadCount,
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
));
|
||||
} catch (error) {
|
||||
console.error("Failed to load last message for user:", dmUser.id, error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load users when DM tab is active
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
|
||||
|
||||
usersLoadedRef.current = true;
|
||||
setIsLoadingUsers(true);
|
||||
try {
|
||||
const users = await fetchUsers(user.authToken);
|
||||
console.log("Fetched users:", users);
|
||||
const dmUsersWithState: DMUser[] = users.map(user => ({
|
||||
...user,
|
||||
unreadCount: 0,
|
||||
lastMessage: undefined,
|
||||
publicKey: null
|
||||
}));
|
||||
|
||||
setDmUsersState(dmUsersWithState);
|
||||
setDmUsers(users);
|
||||
|
||||
// Load last messages and unread counts for visible users
|
||||
// Call loadUserLastMessage directly without dependency
|
||||
for (const dmUser of dmUsersWithState) {
|
||||
await loadUserLastMessage(dmUser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM users:", error);
|
||||
} finally {
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
}, [user.authToken, isLoadingUsers]);
|
||||
|
||||
// Reset users loaded flag when user changes
|
||||
useEffect(() => {
|
||||
usersLoadedRef.current = false;
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load DM history for active conversation
|
||||
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
|
||||
if (!user.authToken || isLoadingHistory) return;
|
||||
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
const messages = await fetchDMHistory(userId, user.authToken, 50);
|
||||
const decryptedMessages: Message[] = [];
|
||||
let maxIncomingId = 0;
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const text = await decryptDm(env, publicKey);
|
||||
const isAuthor = env.senderId !== userId;
|
||||
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
||||
|
||||
decryptedMessages.push({
|
||||
id: env.id,
|
||||
content: text,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
|
||||
if (env.senderId === userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decrypting message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
decryptedMessages.forEach(msg => addMessage(msg));
|
||||
|
||||
// Update last read ID
|
||||
if (maxIncomingId > 0) {
|
||||
setLastReadId(userId, maxIncomingId);
|
||||
// Clear unread count
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === userId ? { ...u, unreadCount: 0 } : u
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM history:", error);
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
|
||||
|
||||
// Send DM message
|
||||
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken);
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Start DM conversation
|
||||
const startDMConversation = useCallback(async (dmUser: DMUser) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
// Get public key if not already loaded
|
||||
let publicKey = dmUser.publicKey;
|
||||
if (!publicKey) {
|
||||
publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) return;
|
||||
}
|
||||
|
||||
// Set active DM
|
||||
setActiveDm({
|
||||
userId: dmUser.id,
|
||||
username: dmUser.username,
|
||||
publicKey
|
||||
});
|
||||
|
||||
// Load conversation history
|
||||
await loadDMHistory(dmUser.id, publicKey);
|
||||
} catch (error) {
|
||||
console.error("Failed to start DM conversation:", error);
|
||||
}
|
||||
}, [user.authToken, setActiveDm, loadDMHistory]);
|
||||
|
||||
// WebSocket message handler
|
||||
useEffect(() => {
|
||||
const handleWebSocketMessage = async (e: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "dmNew") {
|
||||
const { senderId, recipientId, ...envelope } = msg.data;
|
||||
|
||||
// If this is for the active DM conversation
|
||||
if (chat.activeDm && (senderId === chat.activeDm.userId || recipientId === chat.activeDm.userId)) {
|
||||
try {
|
||||
const plaintext = await decryptDm(envelope, chat.activeDm.publicKey!);
|
||||
const isAuthor = senderId !== chat.activeDm.userId;
|
||||
|
||||
addMessage({
|
||||
id: envelope.id,
|
||||
content: plaintext,
|
||||
username: isAuthor ? (user.currentUser?.username || "Unknown") : (chat.activeDm.username || "Unknown"),
|
||||
timestamp: envelope.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
|
||||
// Update last read if it's from the other user
|
||||
if (senderId === chat.activeDm.userId) {
|
||||
setLastReadId(chat.activeDm.userId, Math.max(getLastReadId(chat.activeDm.userId), envelope.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt incoming DM:", error);
|
||||
}
|
||||
} else {
|
||||
// Update unread count for other users
|
||||
const otherUserId = senderId;
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === otherUserId
|
||||
? { ...u, unreadCount: u.unreadCount + 1 }
|
||||
: u
|
||||
));
|
||||
|
||||
// Update last message preview
|
||||
try {
|
||||
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const plaintext = await decryptDm(envelope, publicKey);
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === otherUserId
|
||||
? {
|
||||
...u,
|
||||
lastMessage: plaintext.split(/\r?\n/).slice(0, 2).join("\n"),
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update last message preview:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", handleWebSocketMessage);
|
||||
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
||||
}, [chat.activeDm, user.currentUser, addMessage]);
|
||||
|
||||
// Force reload users (useful for refreshing the list)
|
||||
const reloadUsers = useCallback(() => {
|
||||
usersLoadedRef.current = false;
|
||||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
return {
|
||||
dmUsers,
|
||||
isLoadingUsers,
|
||||
isLoadingHistory,
|
||||
loadUsers,
|
||||
reloadUsers,
|
||||
startDMConversation,
|
||||
sendDMMessage,
|
||||
loadUserLastMessage
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for localStorage
|
||||
function getLastReadId(userId: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(`dmLastRead:${userId}`);
|
||||
return v ? Number(v) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastReadId(userId: number, id: number): void {
|
||||
try {
|
||||
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||
} catch {}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import {
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket,
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope
|
||||
} from "../../api/dmApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
username: string;
|
||||
publicKey: string;
|
||||
profilePicture?: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export class DMPanel extends MessagePanel {
|
||||
public dmData: DMPanelData | null = null;
|
||||
private messagesLoaded: boolean = false;
|
||||
|
||||
constructor(
|
||||
user: UserState
|
||||
) {
|
||||
super("dm", user);
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
if (this.dmData && !this.messagesLoaded) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
// DM doesn't need special cleanup
|
||||
}
|
||||
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await decryptDm(env, this.dmData!.publicKey);
|
||||
const isAuthor = env.senderId !== this.dmData!.userId;
|
||||
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
|
||||
|
||||
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
|
||||
let content = plaintext;
|
||||
let reply_to_id: number | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(plaintext) as DmEncryptedJSON;
|
||||
if (obj && obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
reply_to_id = Number(obj.data.reply_to_id) || undefined;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const dmMsg: Message = {
|
||||
id: env.id,
|
||||
content: content,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false,
|
||||
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||
|
||||
runtimeData: {
|
||||
dmEnvelope: env
|
||||
}
|
||||
};
|
||||
|
||||
if (reply_to_id) {
|
||||
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
|
||||
if (referenced) dmMsg.reply_to = referenced;
|
||||
}
|
||||
|
||||
return dmMsg;
|
||||
}
|
||||
|
||||
async loadMessages(): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
|
||||
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50);
|
||||
const decryptedMessages: Message[] = [];
|
||||
let maxIncomingId = 0;
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||
decryptedMessages.push(dmMsg);
|
||||
|
||||
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decrypting message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.clearMessages();
|
||||
decryptedMessages.forEach(msg => this.addMessage(msg));
|
||||
|
||||
// Update last read ID
|
||||
if (maxIncomingId > 0) {
|
||||
this.setLastReadId(this.dmData.userId, maxIncomingId);
|
||||
}
|
||||
this.messagesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM history:", error);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const payload: DmEncryptedJSON = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? undefined
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(payload);
|
||||
|
||||
if (files.length === 0) {
|
||||
await sendDMViaWebSocket(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
} else {
|
||||
await sendDmWithFiles(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
files,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set DM conversation data
|
||||
setDMData(dmData: DMPanelData): void {
|
||||
this.dmData = dmData;
|
||||
this.messagesLoaded = false;
|
||||
this.updateState({
|
||||
id: `dm-${dmData.userId}`,
|
||||
title: dmData.username,
|
||||
profilePicture: dmData.profilePicture,
|
||||
online: dmData.online
|
||||
});
|
||||
}
|
||||
|
||||
// Handle incoming WebSocket DM messages
|
||||
handleWebSocketMessage = async (response: DMWebSocketMessage): Promise<void> => {
|
||||
if (response.type === "dmNew" && this.dmData) {
|
||||
const envelope = response.data;
|
||||
|
||||
// If this is for the active DM conversation
|
||||
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
|
||||
this.addMessage(dmMsg);
|
||||
|
||||
// Update last read if it's from the other user
|
||||
if (envelope.senderId === this.dmData.userId) {
|
||||
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt incoming DM:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.type === "dmEdited" && this.dmData) {
|
||||
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||
try {
|
||||
// Decrypt new content in-place
|
||||
const plaintext = await decryptDm(
|
||||
{
|
||||
id,
|
||||
senderId: 0,
|
||||
recipientId: 0,
|
||||
iv,
|
||||
ciphertext,
|
||||
salt,
|
||||
iv2,
|
||||
wrappedMk,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
this.dmData.publicKey
|
||||
);
|
||||
let content = plaintext;
|
||||
let files: Message["files"] | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
|
||||
if (obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
files = obj.data.files;
|
||||
}
|
||||
} catch {}
|
||||
const updates: Partial<Message> = { content, is_edited: true, files };
|
||||
this.updateMessage(id, updates);
|
||||
} catch (e) {
|
||||
this.updateMessage(id, { is_edited: true });
|
||||
}
|
||||
}
|
||||
if (response.type === "dmDeleted" && this.dmData) {
|
||||
const { id } = response.data;
|
||||
this.removeMessage(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset for DM switching
|
||||
reset(): void {
|
||||
this.dmData = null;
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
this.updateState({
|
||||
id: "dm",
|
||||
title: "Select a user",
|
||||
profilePicture: undefined,
|
||||
online: false
|
||||
});
|
||||
}
|
||||
|
||||
// Update auth token
|
||||
setAuthToken(authToken: string): void {
|
||||
this.currentUser.authToken = authToken;
|
||||
}
|
||||
|
||||
// Helper functions for localStorage
|
||||
private getLastReadId(userId: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(`dmLastRead:${userId}`);
|
||||
return v ? Number(v) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private setLastReadId(userId: number, id: number): void {
|
||||
try {
|
||||
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async handleDeleteMessage(messageId: number): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
// Fire and forget; UI will update via dmDeleted
|
||||
await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken);
|
||||
}
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
const msg = this.getMessages().find(m => m.id === messageId);
|
||||
// Build encrypted JSON preserving files and reply_to if present
|
||||
const payload: EncryptedMessageJson = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content,
|
||||
files: msg?.files,
|
||||
reply_to_id: msg?.reply_to?.id ?? undefined
|
||||
}
|
||||
};
|
||||
editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
||||
console.error("Failed to edit DM:", e);
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { Message } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
|
||||
export interface MessagePanelState {
|
||||
id: string;
|
||||
title: string;
|
||||
profilePicture?: string;
|
||||
online: boolean;
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isTyping: boolean;
|
||||
}
|
||||
|
||||
export interface MessagePanelCallbacks {
|
||||
onSendMessage: (content: string, files: File[]) => void;
|
||||
onEditMessage: (messageId: number, content: string) => void;
|
||||
onDeleteMessage: (messageId: number) => void;
|
||||
onReplyToMessage: (messageId: number, content: string) => void;
|
||||
onProfileClick: () => void;
|
||||
}
|
||||
|
||||
export abstract class MessagePanel {
|
||||
protected state: MessagePanelState;
|
||||
public onStateChange: ((state: MessagePanelState) => void) | null = () => {};
|
||||
protected readonly currentUser: UserState;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
currentUser: UserState,
|
||||
) {
|
||||
this.state = {
|
||||
id,
|
||||
title: "",
|
||||
online: false,
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
isTyping: false
|
||||
};
|
||||
this.currentUser = currentUser;
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by subclasses
|
||||
abstract activate(): Promise<void>;
|
||||
abstract deactivate(): void;
|
||||
abstract loadMessages(): Promise<void>;
|
||||
abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
|
||||
abstract isDm(): boolean;
|
||||
|
||||
// Optional WebSocket message handler (can be overridden by subclasses)
|
||||
handleWebSocketMessage?: (response: any) => void;
|
||||
|
||||
// Common methods
|
||||
protected updateState(updates: Partial<MessagePanelState>): void {
|
||||
this.state = { ...this.state, ...updates };
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
protected addMessage(message: Message): void {
|
||||
const messageExists = this.state.messages.some(msg => msg.id === message.id);
|
||||
if (!messageExists) {
|
||||
this.updateState({
|
||||
messages: [...this.state.messages, message]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected updateMessage(messageId: number, updates: Partial<Message>): void {
|
||||
this.updateState({
|
||||
messages: this.state.messages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, ...updates } : msg
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
protected removeMessage(messageId: number): void {
|
||||
this.updateState({
|
||||
messages: this.state.messages.filter(msg => msg.id !== messageId)
|
||||
});
|
||||
}
|
||||
|
||||
protected clearMessages(): void {
|
||||
this.updateState({ messages: [] });
|
||||
}
|
||||
|
||||
protected setLoading(loading: boolean): void {
|
||||
this.updateState({ isLoading: loading });
|
||||
}
|
||||
|
||||
protected setTyping(typing: boolean): void {
|
||||
this.updateState({ isTyping: typing });
|
||||
}
|
||||
|
||||
// Getters
|
||||
getState(): MessagePanelState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.state.id;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.state.title;
|
||||
}
|
||||
|
||||
getMessages(): Message[] {
|
||||
return [...this.state.messages];
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
|
||||
this.sendMessage(content, replyToId, files);
|
||||
};
|
||||
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
|
||||
abstract handleDeleteMessage(messageId: number): Promise<void>;
|
||||
abstract handleProfileClick(): void;
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { getAuthHeaders } from "../../auth/api";
|
||||
import { request } from "../../core/websocket";
|
||||
import type { ChatWebSocketMessage, Message, SendMessageRequest } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
|
||||
export class PublicChatPanel extends MessagePanel {
|
||||
private messagesLoaded: boolean = false;
|
||||
|
||||
constructor(
|
||||
chatName: string,
|
||||
currentUser: UserState
|
||||
) {
|
||||
super(`public-${chatName}`, currentUser);
|
||||
this.updateState({
|
||||
title: chatName,
|
||||
online: true // Public chats are always "online"
|
||||
});
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
if (!this.messagesLoaded) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
// Public chat doesn't need special cleanup
|
||||
}
|
||||
|
||||
async loadMessages(): Promise<void> {
|
||||
if (!this.currentUser.authToken || this.messagesLoaded) return;
|
||||
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders(this.currentUser.authToken)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
this.clearMessages();
|
||||
data.messages.forEach((msg: Message) => {
|
||||
this.addMessage(msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.messagesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Error loading public chat messages:", error);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !content.trim()) return;
|
||||
|
||||
try {
|
||||
if (files.length === 0) {
|
||||
const response = await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
} satisfies SendMessageRequest);
|
||||
if (response.error) {
|
||||
console.error("Error sending message:", response.error);
|
||||
}
|
||||
} else {
|
||||
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 fetch(`${API_BASE_URL}/send_message`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(this.currentUser.authToken, false),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Error sending message with files", await res.text());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming WebSocket messages
|
||||
handleWebSocketMessage = (response: ChatWebSocketMessage): void => {
|
||||
switch (response.type) {
|
||||
case 'messageEdited':
|
||||
if (response.data) {
|
||||
this.updateMessage(response.data.id, response.data);
|
||||
}
|
||||
break;
|
||||
case 'messageDeleted':
|
||||
if (response.data && response.data.message_id) {
|
||||
this.removeMessage(response.data.message_id);
|
||||
}
|
||||
break;
|
||||
case 'newMessage':
|
||||
if (response.data) {
|
||||
this.addMessage(response.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Reset for chat switching
|
||||
reset(): void {
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
}
|
||||
|
||||
// Update chat name
|
||||
setChatName(chatName: string): void {
|
||||
this.updateState({
|
||||
id: `public-${chatName}`,
|
||||
title: chatName
|
||||
});
|
||||
}
|
||||
|
||||
// Update auth token
|
||||
setAuthToken(authToken: string): void {
|
||||
this.currentUser.authToken = authToken;
|
||||
}
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken) return;
|
||||
try {
|
||||
await request({
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: messageId,
|
||||
content: content
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteMessage(id: number): Promise<void> {
|
||||
await request({
|
||||
type: "deleteMessage",
|
||||
data: { message_id: id },
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken!
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { LeftPanel } from "../components/chat/LeftPanel";
|
||||
import { RightPanel } from "../components/chat/RightPanel";
|
||||
|
||||
export default function ChatScreen() {
|
||||
return (
|
||||
<div id="chat-interface">
|
||||
<div className="all-container">
|
||||
<LeftPanel />
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export default function DownloadAppScreen() {
|
||||
return (
|
||||
<div className="download-app-screen">
|
||||
<div className="inner">
|
||||
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
|
||||
<p>
|
||||
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
|
||||
вам нужно скачать приложение мессенджера.
|
||||
</p>
|
||||
|
||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
||||
<mdui-button>Скачать на GitHub</mdui-button>
|
||||
</a>
|
||||
|
||||
<p>
|
||||
Если возникнут сложности или есть вопросы, нажмите кнопку!
|
||||
</p>
|
||||
|
||||
<a href="https://t.me/denis0001-dev">
|
||||
<mdui-button>Написать в поддержку</mdui-button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
|
||||
import { AuthContainer, AuthHeader } from "../components/Auth";
|
||||
import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types";
|
||||
import { ensureKeysOnLogin } from "../../auth/crypto";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { useRef } from "react";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import { useAppState } from "../state";
|
||||
import { MaterialTextField } from "../components/core/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/push-notifications";
|
||||
import { isElectron } from "../../electron/electron";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setCurrentPage = useAppState(state => state.setCurrentPage);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
|
||||
<div className="auth-body">
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showAlert("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 first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
setCurrentPage("chat");
|
||||
|
||||
// Initialize notifications
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(data.token);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
console.log("Notifications enabled");
|
||||
} else {
|
||||
console.log("Notification permission denied");
|
||||
}
|
||||
} else {
|
||||
console.log("Notifications not supported");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed:", e);
|
||||
}
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
id="login-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
required
|
||||
ref={usernameElement} />
|
||||
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
id="login-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
|
||||
<mdui-button type="submit">Войти</mdui-button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Ещё нет аккаунта?
|
||||
<a
|
||||
href="#"
|
||||
className="link"
|
||||
onClick={() => setCurrentPage("register")}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useImmer } from "use-immer";
|
||||
// import { showLogin } from "../../navigation";
|
||||
import { AuthContainer, AuthHeader } from "../components/Auth";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
|
||||
import { useRef } from "react";
|
||||
import { TextField } from "mdui/components/text-field";
|
||||
import type { ErrorResponse, RegisterRequest } from "../../core/types";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { delay } from "../../utils/utils";
|
||||
import { useAppState } from "../state";
|
||||
import { MaterialTextField } from "../components/core/TextField";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setCurrentPage = useAppState(state => state.setCurrentPage);
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
const confirmPasswordElement = useRef<TextField>(null);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
|
||||
<div className="auth-body">
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password || !confirmPassword) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showAlert("danger", "Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 5 || password.length > 50) {
|
||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||
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("success", "Регистрация прошла успешно! Теперь вы можете войти.");
|
||||
await delay(2000);
|
||||
setCurrentPage("login");
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Ошибка при регистрации");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
id="register-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
maxlength={20}
|
||||
counter
|
||||
required
|
||||
ref={usernameElement} />
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
id="register-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
<MaterialTextField
|
||||
label="Подтвердите пароль"
|
||||
id="register-confirm-password"
|
||||
name="confirm_password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={confirmPasswordElement} />
|
||||
|
||||
<mdui-button type="submit">Зарегистрироваться</mdui-button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Уже есть аккаунт?
|
||||
<a
|
||||
href="#"
|
||||
id="login-link"
|
||||
className="link"
|
||||
onClick={() => setCurrentPage("login")}>
|
||||
Войдите
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user