mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
173 Commits
@@ -1,4 +1,40 @@
|
||||
View git diff between the branch i specified and HEAD. If no branch is specified,
|
||||
default to main. Identify code that needs to be cleaned up, like debug logs,
|
||||
unused variables etc. Think twice before removing or adding code, because you
|
||||
mustn't alter the behavior.
|
||||
# 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,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 @@
|
||||
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:
|
||||
|
||||
@@ -16,10 +16,13 @@ When working with this project, follow these rules:
|
||||
- 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
|
||||
@@ -30,16 +33,14 @@ When working with this project, follow these rules:
|
||||
- If the typecheck passed, there's no need for checking the linter errors.
|
||||
|
||||
## Async Operations
|
||||
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
|
||||
make it async.
|
||||
- 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
|
||||
- This is a React/TypeScript browser frontend (desktop is Compose Multiplatform in the Android/KMP repo)
|
||||
- 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
|
||||
@@ -50,3 +51,16 @@ When working with this project, follow these rules:
|
||||
- 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.
|
||||
@@ -3,6 +3,11 @@ 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.
|
||||
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,46 @@
|
||||
# Shared build context ignore (used by deployment images with context: ..)
|
||||
|
||||
# VCS / editor
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.DS_Store
|
||||
|
||||
# Secrets / local env
|
||||
.env
|
||||
deployment/.env
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.vite
|
||||
dist
|
||||
build
|
||||
out
|
||||
coverage
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ipynb_checkpoints
|
||||
.venv
|
||||
venv/
|
||||
|
||||
# App runtime data/logs (mounted, not baked)
|
||||
backend/data
|
||||
backend/files
|
||||
data
|
||||
logs
|
||||
**/logs
|
||||
**/logs/**
|
||||
*.log
|
||||
|
||||
# Deploy cache (hashes)
|
||||
.deploy-cache
|
||||
|
||||
# Firebase cert: bind-mounted at runtime; do not send to docker build context
|
||||
firebase-cert.json
|
||||
@@ -0,0 +1,3 @@
|
||||
# HTTP API host.
|
||||
# Example for local backend: http://localhost:8300
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
@@ -1,171 +0,0 @@
|
||||
name: Build Electron app
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
apiBaseUrl:
|
||||
description: "API base URL"
|
||||
required: false
|
||||
default: "fromchat.ru"
|
||||
type: string
|
||||
push:
|
||||
tags: ["v**"]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'package.json'
|
||||
- '.github/workflows/build.yml'
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Build Linux
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: npm-ubuntu-latest-node-24-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
npm-ubuntu-latest-node-24-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Cache Electron downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
key: electron-ubuntu-latest-${{ hashFiles('package.json', 'package.json') }}
|
||||
restore-keys: |
|
||||
electron-ubuntu-latest-
|
||||
|
||||
- name: Build Electron app
|
||||
shell: bash
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
||||
run: npm run build:electron
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FromChat-linux
|
||||
path: |
|
||||
frontend/build/electron/forge/make/*/**
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
name: Build macOS
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: npm-macos-latest-node-24-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
npm-macos-latest-node-24-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
# - name: Cache Electron downloads
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: |
|
||||
# ~/.cache/electron
|
||||
# ~/.cache/electron-builder
|
||||
# key: electron-macos-latest-${{ hashFiles('package.json') }}
|
||||
# restore-keys: |
|
||||
# electron-macos-latest-
|
||||
|
||||
- name: Build Electron app
|
||||
shell: bash
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
||||
run: npm run build:electron
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FromChat-macOS
|
||||
path: |
|
||||
frontend/build/electron/forge/make/zip/darwin/*/**.zip
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force npm to use Bash
|
||||
run: npm config set script-shell "C:\Program Files\Git\bin\bash.exe"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: npm-windows-latest-node-24-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
npm-windows-latest-node-24-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Cache Electron downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-windows-latest-${{ hashFiles('package.json') }}
|
||||
restore-keys: |
|
||||
electron-windows-latest-
|
||||
|
||||
- name: Build Electron app
|
||||
shell: bash
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ inputs.apiBaseUrl }}
|
||||
run: npm run build:electron
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FromChat-windows
|
||||
path: |
|
||||
frontend/build/electron/forge/make/zip/win32/x64
|
||||
if-no-files-found: error
|
||||
@@ -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
|
||||
+28
-1
@@ -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
|
||||
@@ -566,11 +576,28 @@ buck-out/
|
||||
|
||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||
|
||||
backend/services/main/.fromchat_instance_id
|
||||
backend/data
|
||||
backend/files
|
||||
.vite
|
||||
*.db
|
||||
package-lock.json
|
||||
backend/alembic/**
|
||||
!backend/alembic/env.py
|
||||
!backend/alembic/script.py.mako
|
||||
!frontend/src/css/lib
|
||||
!frontend/src/css/lib
|
||||
**/*.module.scss.d.ts
|
||||
|
||||
.cursor/plans
|
||||
tmp
|
||||
compliance_keypair.txt
|
||||
backend/files
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
.deploy-cache
|
||||
|
||||
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
|
||||
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
|
||||
|
||||
*.module.d.scss.ts
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/bin/sh
|
||||
# Post-push hook: opens deploy command in system's native terminal
|
||||
# Cross-platform support: macOS, Linux, Windows, WSL
|
||||
|
||||
# Get the project root directory
|
||||
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$PROJECT_ROOT" || exit 1
|
||||
|
||||
# Command to run in terminal (deploy.sh will load .env from project root)
|
||||
COMMAND="npm run -s deploy"
|
||||
|
||||
# Detect OS and open appropriate terminal
|
||||
detect_and_open_terminal() {
|
||||
# Detect WSL
|
||||
if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -f /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
# WSL detected - try to open Windows Terminal, fallback to Linux terminals
|
||||
if command -v wt.exe >/dev/null 2>&1; then
|
||||
# Windows Terminal (preferred for WSL)
|
||||
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||
wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v wsl.exe >/dev/null 2>&1; then
|
||||
# Fallback: use wsl.exe to open cmd
|
||||
WINDOWS_PATH=$(wslpath -w "$PROJECT_ROOT" 2>/dev/null || echo "$PROJECT_ROOT")
|
||||
cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\""
|
||||
else
|
||||
# Fallback to Linux terminal
|
||||
open_linux_terminal
|
||||
fi
|
||||
# macOS
|
||||
elif [ "$(uname)" = "Darwin" ]; then
|
||||
# macOS - use .command file with open command
|
||||
# Clean up old script files and create a new one
|
||||
rm -f /tmp/post-push-deploy-*.command 2>/dev/null
|
||||
SCRIPT_FILE=$(mktemp /tmp/post-push-deploy-XXXXXX.command 2>/dev/null)
|
||||
if [ -z "$SCRIPT_FILE" ] || [ ! -f "$SCRIPT_FILE" ]; then
|
||||
# Fallback if mktemp fails
|
||||
SCRIPT_FILE="/tmp/post-push-deploy-$$.command"
|
||||
fi
|
||||
{
|
||||
echo "#!/bin/bash"
|
||||
echo "clear"
|
||||
echo "cd '$PROJECT_ROOT'"
|
||||
echo "export PS1=''"
|
||||
echo "set +x"
|
||||
# Export DEPLOYMENT_SERVER if it was set in the hook environment
|
||||
if [ -n "$DEPLOYMENT_SERVER_VALUE" ]; then
|
||||
echo "export DEPLOYMENT_SERVER='$DEPLOYMENT_SERVER_VALUE'"
|
||||
fi
|
||||
echo "$COMMAND"
|
||||
echo "echo ''"
|
||||
echo "echo 'Press Enter to close...'"
|
||||
echo "read -r"
|
||||
echo "osascript -e 'tell application \"Terminal\" to close front window' &"
|
||||
} > "$SCRIPT_FILE"
|
||||
chmod +x "$SCRIPT_FILE"
|
||||
# Use open command to launch .command file - opens only one Terminal window
|
||||
open "$SCRIPT_FILE"
|
||||
# Windows (Git Bash or similar)
|
||||
elif [ -n "${MSYSTEM:-}" ] || [ -n "${MINGW64:-}" ] || [ -n "${MINGW32:-}" ]; then
|
||||
# Git Bash on Windows
|
||||
if command -v wt.exe >/dev/null 2>&1; then
|
||||
# Windows Terminal
|
||||
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||
wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v cmd.exe >/dev/null 2>&1; then
|
||||
# Command Prompt - convert path to Windows format
|
||||
WINDOWS_PATH=$(echo "$PROJECT_ROOT" | sed 's|^/\([a-z]\)|\1:|' | sed 's|/|\\|g' | sed 's|\\|\\\\|g')
|
||||
cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\""
|
||||
else
|
||||
# Fallback
|
||||
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||
start "Deploy" bash -c "cd '$ESCAPED_PATH' && $COMMAND; exec bash"
|
||||
fi
|
||||
# Linux
|
||||
else
|
||||
open_linux_terminal
|
||||
fi
|
||||
}
|
||||
|
||||
open_linux_terminal() {
|
||||
# Escape path for use in shell commands
|
||||
ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g")
|
||||
|
||||
ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g")
|
||||
# Try different Linux terminal emulators
|
||||
if command -v gnome-terminal >/dev/null 2>&1; then
|
||||
gnome-terminal -- bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v x-terminal-emulator >/dev/null 2>&1; then
|
||||
x-terminal-emulator -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v konsole >/dev/null 2>&1; then
|
||||
konsole -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v xterm >/dev/null 2>&1; then
|
||||
xterm -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v alacritty >/dev/null 2>&1; then
|
||||
alacritty -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v kitty >/dev/null 2>&1; then
|
||||
kitty bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
elif command -v tilix >/dev/null 2>&1; then
|
||||
tilix -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
else
|
||||
# Last resort: try to find any terminal
|
||||
TERMINAL=$(command -v x-terminal-emulator gnome-terminal konsole xterm alacritty kitty tilix 2>/dev/null | head -1)
|
||||
if [ -n "$TERMINAL" ]; then
|
||||
"$TERMINAL" -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit"
|
||||
else
|
||||
echo "Could not find a terminal emulator. Please run manually: cd '$PROJECT_ROOT' && $COMMAND"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Run in background so git push doesn't wait
|
||||
# Add a small delay to ensure git push completes first
|
||||
(sleep 0.5 && detect_and_open_terminal) &
|
||||
|
||||
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
+4
-56
@@ -1,46 +1,10 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Backend",
|
||||
"type": "npm",
|
||||
"script": "backend:run",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build"
|
||||
},
|
||||
"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:dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
@@ -55,25 +19,9 @@
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
|
||||
{
|
||||
"label": "Web",
|
||||
"dependsOn": ["Backend", "Frontend (Web)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Electron",
|
||||
"dependsOn": ["Backend", "Frontend (Electron)"],
|
||||
"dependsOn": ["Frontend (Web)"],
|
||||
"dependsOrder": "parallel",
|
||||
"group": {
|
||||
"kind": "build"
|
||||
@@ -86,4 +34,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# 1. Frontend production build
|
||||
FROM node:24 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 1.1. Install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Copy sources and configure
|
||||
COPY . .
|
||||
|
||||
WORKDIR /app
|
||||
ARG NODE_ENV=production
|
||||
# Overridable at build time. Must be a real http(s) URL — never a compose ${...} stub.
|
||||
ARG VITE_API_BASE_URL=https://api.fromchat.ru
|
||||
|
||||
# Fail the build if the arg is not a real URL (e.g. unexpanded Compose ${...}).
|
||||
RUN if ! printf "%s" "${VITE_API_BASE_URL}" | grep -Eq '^https?://'; then \
|
||||
echo "ERROR: VITE_API_BASE_URL must be an http(s) URL, got: ${VITE_API_BASE_URL}" >&2; \
|
||||
exit 1; \
|
||||
fi && \
|
||||
export NODE_ENV=production VITE_API_BASE_URL && \
|
||||
npm run frontend:build
|
||||
|
||||
|
||||
# 2. Production web static server
|
||||
FROM joseluisq/static-web-server:latest AS production
|
||||
|
||||
# 2.1. Copy files to server root (/var/public -> /home/sws/public)
|
||||
COPY --from=builder /app/build/normal/dist /var/public
|
||||
|
||||
# 2.2. Configure (defaults serve the image's built-in landing page instead of our app)
|
||||
ENV SERVER_ROOT=/var/public
|
||||
ENV SERVER_FALLBACK_PAGE=/var/public/index.html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -1,5 +1,5 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# FromChat Web Client — Messaging Web App
|
||||
|
||||
[Читать на других языках: Русский](./README.md)
|
||||
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/fromchat-messenger/android/main/app/android/src/main/ic_launcher-playstore.png" width="120" alt="FromChat Logo" />
|
||||
|
||||
**Web client for FromChat messenger**
|
||||
|
||||
[🌐 Web Client](https://github.com/fromchat-messenger/web) • [🖥️ Backend](https://github.com/fromchat-messenger/backend) • [📱 Android / Desktop](https://github.com/fromchat-messenger/android) • [🌍 Website](https://github.com/fromchat-messenger/site)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description
|
||||
|
||||
FromChat Web is a React/TypeScript browser client for the FromChat server. The desktop client is Compose Multiplatform in the [Android/KMP repository](https://github.com/fromchat-messenger/android).
|
||||
|
||||
**Note:** Landing pages and legal documents live in [fromchat-messenger/site](https://github.com/fromchat-messenger/site).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Client Comparison
|
||||
|
||||
| Feature | Android | Web | iOS |
|
||||
|---|---|---|---|
|
||||
| **Messaging & profiles** | ✅ | ✅ | ❌ |
|
||||
| **Voice/video calls** | ✅ | ✅ | ❌ |
|
||||
| **Screen sharing** | ✅ | ✅ | ❌ |
|
||||
| **Message reactions** | ❌ | ✅ | ❌ |
|
||||
| **Rich attachment support** | ✅ | ❌ | ❌ |
|
||||
|
||||
⚠️ **iOS is temporarily not supported.**
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Protected DMs (legal encryption scheme)
|
||||
- Voice/video calls and screen sharing
|
||||
- Message reactions
|
||||
- Public chats and profiles
|
||||
- Device management
|
||||
- WebSocket real-time updates
|
||||
- Dark mode
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
|
||||
| Component | Notes |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | strict typing |
|
||||
| Vite 7 | dev server & build |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | state |
|
||||
| Motion | animations |
|
||||
| TweetNaCl.js | cryptography |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 20+ (Docker image uses Node 24)
|
||||
- npm
|
||||
- Backend API on `http://localhost:8300` (proxied as `/api`)
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fromchat-messenger/web.git
|
||||
cd web
|
||||
npm install
|
||||
cp .env.example .env # if needed; install may copy it for you
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
Open `http://localhost:8301`.
|
||||
|
||||
`.env`:
|
||||
|
||||
```env
|
||||
# HTTP API host (Vite proxy target for /api)
|
||||
VITE_API_BASE_URL=http://localhost:8300
|
||||
```
|
||||
|
||||
In the browser the client uses same-origin `/api` (HTTP and WebSocket, e.g. `/api/chat/ws`).
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Vite on :8301
|
||||
npm run frontend:typecheck # TypeScript
|
||||
npm run frontend:build # typecheck + production build → build/normal
|
||||
npm run frontend:preview # preview built frontend
|
||||
```
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React app (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ └── protocol/ # shared protocol (@fromchat/protocol)
|
||||
├── plugins/ # Vite plugins
|
||||
├── vite.config.ts
|
||||
├── compose.yml # production web image (:8301→80)
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
docker build -t fromchat-web:latest .
|
||||
# or via compose:
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
Container listens on port **8301** (static server on 80 inside).
|
||||
|
||||
Production edge (Caddy/HAProxy) is configured via the deployment repo / backend `compose.prod.yml`.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Branch for your change
|
||||
2. Open a PR with a description
|
||||
3. Ensure `npm run frontend:typecheck` passes
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0 — see [LICENSE](./LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Repositories
|
||||
|
||||
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||
- [Android / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: How do I run locally?**
|
||||
A: Start the backend on `:8300`, then `npm run frontend:dev` and open `http://localhost:8301`.
|
||||
|
||||
**Q: Which browsers?**
|
||||
A: Current Chrome, Firefox, Safari, Edge.
|
||||
|
||||
**Q: Do calls work on web?**
|
||||
A: Yes — voice/video and screen share (server needs LiveKit).
|
||||
|
||||
**Q: How do I report a bug?**
|
||||
A: GitHub Issues with reproduction steps.
|
||||
|
||||
---
|
||||
|
||||
**[⬆ back to top](#fromchat-web-client--messaging-web-app)**
|
||||
@@ -1,30 +1,112 @@
|
||||
# FromChat
|
||||
# FromChat Web Client — веб-приложение для обмена сообщениями
|
||||
|
||||
FromChat - полностью открытый мессенджер.
|
||||
[Read in other languages: English](./README.en.md)
|
||||
|
||||
Демо версию можно попробовать на [сайте](http://95.165.0.162:8301).
|
||||
_Написано ИИ. Могут быть ошибки._
|
||||
|
||||
## Содержание:
|
||||
- [Основные моменты](#highlights)
|
||||
- [Использование](#usage)
|
||||
- [Часто задаваемые вопросы](#faq)
|
||||
- [Внос вклада](#contributing)
|
||||
## 📝 Описание
|
||||
|
||||
## Основные моменты
|
||||
- Написан на HTML, SCSS, TypeScript (фронтэнд) и Python (бэкэнд).
|
||||
- 100% открытый исходный код позволяет настроить вид и поведение мессенджера полностью под себя.
|
||||
Веб-клиент FromChat — React/TypeScript приложение для браузера. Десктоп-клиент — Compose Multiplatform в [Android/KMP репозитории](https://github.com/fromchat-messenger/android).
|
||||
|
||||
## Использование
|
||||
_В разработке._
|
||||
## Развернуть в 1 клик
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
<!--
|
||||
Вопрос: __Чему равно 2+2?__
|
||||
Ответ: __4__
|
||||
Вопрос: __Какая цитата Джейсона Стетхема на ваш взгляд является лучшей?__
|
||||
Ответ: __"Одна ошибка, и ты ошибся."__
|
||||
-->
|
||||
_В разработке._
|
||||
```bash
|
||||
docker run -d --restart always -p 8301:80 fromchat/web:latest
|
||||
```
|
||||
|
||||
## Внос вклада
|
||||
Внести свой вклад в разработку FromChat можно при помощи pull request или вступления в нашу команду. Заявку на вступление в команду можно оставить [здесь](https://t.me/denis0001-dev).
|
||||
## ✨ Возможности
|
||||
|
||||
- Защищённые личные сообщения (легальная схема шифрования)
|
||||
- Голосовые/видеозвонки и демонстрация экрана
|
||||
- Реакции на сообщения
|
||||
- Публичные чаты и профили
|
||||
- Управление устройствами
|
||||
- WebSocket для реал-тайма
|
||||
- Тёмный режим
|
||||
|
||||
## 🏗️ Технологический стек
|
||||
|
||||
| Компонент | Примечание |
|
||||
|---|---|
|
||||
| React 19 | UI |
|
||||
| TypeScript | строгая типизация |
|
||||
| Vite 7 | dev-сервер и сборка |
|
||||
| MDUI | Material Design |
|
||||
| Zustand + use-immer | состояние |
|
||||
| Motion | анимации |
|
||||
| TweetNaCl.js | криптография |
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Требования
|
||||
|
||||
- Node.js 20+ (для Docker-образа используется Node 24)
|
||||
- npm
|
||||
- Backend API на `http://localhost:8300` (проксируется через `/api`)
|
||||
|
||||
### Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fromchat-messenger/web.git
|
||||
cd web
|
||||
npm install
|
||||
cp .env.example .env # при необходимости; install может скопировать сам
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:8301`.
|
||||
|
||||
### Команды
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Vite на :8301
|
||||
npm run frontend:typecheck # TypeScript
|
||||
npm run frontend:build # typecheck + production build → build/normal
|
||||
npm run frontend:preview # preview собранного фронта
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
web/
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main/ # React-приложение (@/)
|
||||
│ │ ├── pages/ # auth, chat, profile, …
|
||||
│ │ ├── core/ # API, websocket, calls, …
|
||||
│ │ ├── state/ # Zustand stores
|
||||
│ │ ├── utils/
|
||||
│ │ └── css/ # SCSS (Material Design)
|
||||
│ └── protocol/ # общий протокол (@fromchat/protocol)
|
||||
├── plugins/ # Vite-плагины
|
||||
├── vite.config.ts
|
||||
├── compose.yml # production-образ веба (:8301→80)
|
||||
├── Dockerfile
|
||||
├── .env.example
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Контейнер слушает порт **8301** (внутри nginx/static-server на 80).
|
||||
|
||||
## 🤝 Внесение вклада
|
||||
|
||||
1. Создайте ветку под изменение
|
||||
2. Отправьте PR с описанием
|
||||
3. Проверьте типы: `npm run frontend:typecheck`
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
GNU Affero General Public License v3.0 — см. [LICENSE](./LICENSE).
|
||||
|
||||
## 🔗 Связанные репозитории
|
||||
|
||||
- [Backend API](https://github.com/fromchat-messenger/backend)
|
||||
- [Android / Desktop (KMP)](https://github.com/fromchat-messenger/android)
|
||||
- [Website](https://github.com/fromchat-messenger/site)
|
||||
- [Deployment](https://github.com/fromchat-messenger/deployment)
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///./data/database.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,78 +0,0 @@
|
||||
from logging.config import fileConfig
|
||||
import logging
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from models import Base
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,28 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -1,51 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
from routes import account, messaging, profile, push
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup - run migration in separate process to avoid logging interference
|
||||
try:
|
||||
print("Starting database migration check...")
|
||||
# Run migration in a separate process
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||
],
|
||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||
# No capture_output - let it stream to terminal in real-time
|
||||
# No text=True - let it use the terminal's encoding
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to run database migrations: {e}")
|
||||
raise
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown (if needed in the future)
|
||||
# logger.info("Application shutdown")
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||
|
||||
# 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")
|
||||
@@ -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,523 +0,0 @@
|
||||
"""
|
||||
Database migration utility using Alembic.
|
||||
This module handles running database migrations on startup.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def run_migrations():
|
||||
"""
|
||||
Run database migrations using Alembic.
|
||||
This function will upgrade the database to the latest migration.
|
||||
Fully automated - handles all scenarios automatically.
|
||||
"""
|
||||
try:
|
||||
# Get the directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Create Alembic configuration
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
|
||||
# Disable Alembic's logging configuration to avoid interfering with FastAPI
|
||||
alembic_cfg.set_main_option("configure_logging", "false")
|
||||
|
||||
# Set the database URL in the config
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Check if any migration files exist
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
|
||||
if not os.path.exists(versions_dir):
|
||||
os.makedirs(versions_dir)
|
||||
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if not migration_files:
|
||||
logger.info("No migration files found. Creating initial migration...")
|
||||
# Check if database exists and has tables
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
|
||||
existing_tables = result.fetchall()
|
||||
|
||||
if existing_tables:
|
||||
logger.info("Found existing database with tables. Creating migration to match current schema...")
|
||||
# Create migration with autogenerate to detect differences
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration from existing database")
|
||||
|
||||
# Check if the generated migration is empty (common with existing databases)
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
# Check if migration is empty
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content:
|
||||
logger.info("Generated migration is empty. Creating complete schema migration...")
|
||||
# Remove the empty migration
|
||||
os.remove(migration_path)
|
||||
# Create a complete migration
|
||||
_create_complete_migration(alembic_cfg)
|
||||
else:
|
||||
logger.info("No existing tables found. Creating fresh migration...")
|
||||
# Create fresh migration
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
|
||||
logger.info("Initial migration created successfully.")
|
||||
else:
|
||||
# Migration files exist, check if we need to create a new migration for schema changes
|
||||
logger.info("Migration files exist. Checking for pending schema changes...")
|
||||
try:
|
||||
# Create a new migration to detect any schema changes
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration for schema changes")
|
||||
|
||||
# Check if the new migration is empty (no changes detected)
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
# Check if migration is empty
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content and 'op.drop_table' not in content and 'op.drop_column' not in content:
|
||||
logger.info("No schema changes detected. Removing empty migration...")
|
||||
# Remove the empty migration
|
||||
os.remove(migration_path)
|
||||
else:
|
||||
logger.info("Schema changes detected. New migration created.")
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"No new migrations needed or error creating migration: {e}")
|
||||
pass
|
||||
|
||||
# Run the upgrade command
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully.")
|
||||
except Exception as upgrade_error:
|
||||
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
connection.commit()
|
||||
# Try upgrade again
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully after reset.")
|
||||
else:
|
||||
raise upgrade_error
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running database migrations: {e}")
|
||||
# Fully automated recovery - handle ALL error scenarios
|
||||
logger.info("Attempting automated recovery...")
|
||||
try:
|
||||
# Clear the alembic_version table to reset state
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
# Remove any existing migration files to start fresh
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
for file in os.listdir(versions_dir):
|
||||
if file.endswith('.py') and not file.startswith('__'):
|
||||
os.remove(os.path.join(versions_dir, file))
|
||||
|
||||
# Create a completely fresh migration with full schema
|
||||
logger.info("Creating fresh migration with complete schema...")
|
||||
_create_complete_migration(alembic_cfg)
|
||||
|
||||
# Run the migration
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Automated recovery completed successfully.")
|
||||
|
||||
except Exception as recovery_error:
|
||||
logger.error(f"Automated recovery failed: {recovery_error}")
|
||||
# Last resort: create database using SQLAlchemy directly
|
||||
logger.info("Using fallback: creating database directly...")
|
||||
_create_database_directly()
|
||||
logger.info("Database created successfully using fallback method.")
|
||||
|
||||
|
||||
def _create_complete_migration(alembic_cfg):
|
||||
"""Create a complete migration file with all database schema."""
|
||||
# Create a new migration file
|
||||
command.revision(alembic_cfg, message="Complete schema migration")
|
||||
|
||||
# Get the latest migration file
|
||||
versions_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
latest_migration = max(migration_files) if migration_files else None
|
||||
|
||||
if latest_migration:
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
_populate_migration_file(migration_path)
|
||||
|
||||
|
||||
def _populate_migration_file(migration_path):
|
||||
"""Populate a migration file with the complete database schema from models."""
|
||||
# Generate the migration content dynamically from models
|
||||
migration_content = _generate_migration_from_models()
|
||||
|
||||
# Read the current migration file
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Add datetime import if needed
|
||||
if "datetime.now" in migration_content and "from datetime import datetime" not in content:
|
||||
# Insert the import after the existing imports
|
||||
import re
|
||||
content = re.sub(
|
||||
r'(from alembic import op\nimport sqlalchemy as sa\n)',
|
||||
r'\1from datetime import datetime\n',
|
||||
content
|
||||
)
|
||||
|
||||
# Replace the empty upgrade/downgrade functions
|
||||
import re
|
||||
# More flexible regex to match the actual content
|
||||
content = re.sub(
|
||||
r'def upgrade\(\) -> None:.*?pass.*?(?=\n\ndef downgrade|\n\nif __name__|\Z)',
|
||||
migration_content,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
# Write the updated content back
|
||||
with open(migration_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _generate_migration_from_models():
|
||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
||||
from models import Base
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime
|
||||
|
||||
# Generate migration content using Alembic's op functions
|
||||
upgrade_statements = []
|
||||
downgrade_statements = []
|
||||
|
||||
# Get all tables from Base metadata
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
if table_name != 'alembic_version': # Skip alembic_version table
|
||||
# Check if table exists and compare schema
|
||||
schema_diff = _detect_schema_differences(table_name, table)
|
||||
|
||||
if schema_diff['table_exists']:
|
||||
if schema_diff['needs_update']:
|
||||
# Generate ALTER TABLE statements for existing table
|
||||
upgrade_statements.append(f" # Update {table_name} table schema")
|
||||
for statement in schema_diff['alter_statements']:
|
||||
upgrade_statements.append(f" {statement}")
|
||||
else:
|
||||
# Table exists and is up to date - skip creating it
|
||||
upgrade_statements.append(f" # Table {table_name} already exists and is up to date")
|
||||
else:
|
||||
# Generate CREATE TABLE for new table
|
||||
table_code = _generate_table_creation_code(table_name, table)
|
||||
upgrade_statements.append(f" # Create {table_name} table")
|
||||
upgrade_statements.append(table_code)
|
||||
|
||||
# Only add to downgrade if table actually exists
|
||||
if schema_diff['table_exists']:
|
||||
downgrade_statements.append(f" # op.drop_table('{table_name}') # Skipped - table exists")
|
||||
else:
|
||||
downgrade_statements.append(f" op.drop_table('{table_name}')")
|
||||
|
||||
# Combine all statements
|
||||
upgrade_content = "def upgrade() -> None:\n \"\"\"Upgrade schema.\"\"\"\n" + "\n".join(upgrade_statements)
|
||||
downgrade_content = "def downgrade() -> None:\n \"\"\"Downgrade schema.\"\"\"\n" + "\n".join(downgrade_statements)
|
||||
|
||||
return upgrade_content + "\n\n" + downgrade_content
|
||||
|
||||
|
||||
def _detect_schema_differences(table_name, expected_table):
|
||||
"""Detect differences between existing table and expected schema."""
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
# Check if table exists
|
||||
inspector = inspect(connection)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return {
|
||||
'table_exists': False,
|
||||
'needs_update': False,
|
||||
'alter_statements': []
|
||||
}
|
||||
|
||||
# Get existing columns
|
||||
existing_columns = inspector.get_columns(table_name)
|
||||
existing_column_names = {col['name'] for col in existing_columns}
|
||||
|
||||
# Get expected columns
|
||||
expected_column_names = {col.name for col in expected_table.columns}
|
||||
|
||||
# Check for missing columns
|
||||
missing_columns = expected_column_names - existing_column_names
|
||||
extra_columns = existing_column_names - expected_column_names
|
||||
|
||||
alter_statements = []
|
||||
|
||||
# Add missing columns
|
||||
for column in expected_table.columns:
|
||||
if column.name in missing_columns:
|
||||
column_def = _generate_column_definition(column)
|
||||
alter_statements.append(f"op.add_column('{table_name}', {column_def})")
|
||||
|
||||
# Add missing indexes
|
||||
for index in expected_table.indexes:
|
||||
if not index.unique:
|
||||
cols = "', '".join([col.name for col in index.columns])
|
||||
alter_statements.append(f"op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
||||
|
||||
return {
|
||||
'table_exists': True,
|
||||
'needs_update': len(alter_statements) > 0,
|
||||
'alter_statements': alter_statements
|
||||
}
|
||||
|
||||
|
||||
def _generate_column_definition(column):
|
||||
"""Generate column definition for ALTER TABLE."""
|
||||
type_def = _get_column_type(column)
|
||||
nullable = "nullable=True" if column.nullable else "nullable=False"
|
||||
|
||||
definition = f"sa.Column('{column.name}', {type_def}, {nullable}"
|
||||
|
||||
# Handle default values properly
|
||||
if column.default is not None:
|
||||
if hasattr(column.default, 'arg'):
|
||||
# Handle callable defaults
|
||||
if callable(column.default.arg):
|
||||
definition += f", default=datetime.now"
|
||||
else:
|
||||
definition += f", default={repr(column.default.arg)}"
|
||||
else:
|
||||
definition += f", default={repr(column.default)}"
|
||||
|
||||
definition += ")"
|
||||
return definition
|
||||
|
||||
|
||||
def _generate_table_creation_code(table_name, table):
|
||||
"""Generate op.create_table code for a SQLAlchemy table."""
|
||||
lines = [f" op.create_table('{table_name}',"]
|
||||
|
||||
# Collect all table items (columns + constraints)
|
||||
all_items = []
|
||||
|
||||
# Add columns
|
||||
for column in table.columns:
|
||||
column_def = f" sa.Column('{column.name}', {_get_column_type(column)}, nullable={column.nullable}"
|
||||
if column.default is not None:
|
||||
# Handle callable defaults properly
|
||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
||||
column_def += f", default=datetime.now"
|
||||
else:
|
||||
column_def += f", default={repr(column.default)}"
|
||||
column_def += ")"
|
||||
all_items.append(column_def)
|
||||
|
||||
# Add constraints
|
||||
for constraint in table.constraints:
|
||||
if hasattr(constraint, 'columns'):
|
||||
if constraint.__class__.__name__ == 'PrimaryKeyConstraint':
|
||||
all_items.append(f" sa.PrimaryKeyConstraint('{constraint.columns.keys()[0]}')")
|
||||
elif constraint.__class__.__name__ == 'UniqueConstraint':
|
||||
cols = "', '".join(constraint.columns.keys())
|
||||
all_items.append(f" sa.UniqueConstraint('{cols}')")
|
||||
|
||||
# Add foreign key constraints
|
||||
for fk in table.foreign_keys:
|
||||
all_items.append(f" sa.ForeignKeyConstraint(['{fk.parent.name}'], ['{fk.column.table.name}.{fk.column.name}'], )")
|
||||
|
||||
# Add all items with commas (except the last one)
|
||||
for i, item in enumerate(all_items):
|
||||
if i < len(all_items) - 1:
|
||||
item += ","
|
||||
lines.append(item)
|
||||
|
||||
lines.append(" )")
|
||||
|
||||
# Add indexes with IF NOT EXISTS equivalent using try/except
|
||||
for index in table.indexes:
|
||||
if not index.unique:
|
||||
cols = "', '".join([col.name for col in index.columns])
|
||||
lines.append(f" # Create index for {table_name}")
|
||||
lines.append(f" try:")
|
||||
lines.append(f" op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
||||
lines.append(f" except Exception:")
|
||||
lines.append(f" pass # Index may already exist")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_column_type(column):
|
||||
"""Get SQLAlchemy column type string."""
|
||||
type_name = column.type.__class__.__name__
|
||||
|
||||
if type_name == 'String':
|
||||
return f"sa.String(length={column.type.length})"
|
||||
elif type_name == 'Integer':
|
||||
return "sa.Integer()"
|
||||
elif type_name == 'Text':
|
||||
return "sa.Text()"
|
||||
elif type_name == 'Boolean':
|
||||
return "sa.Boolean()"
|
||||
elif type_name == 'DateTime':
|
||||
return "sa.DateTime()"
|
||||
else:
|
||||
return f"sa.{type_name}()"
|
||||
|
||||
|
||||
def _create_database_directly():
|
||||
"""Fallback method: create database directly using SQLAlchemy."""
|
||||
from models import Base
|
||||
from db import engine
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
# Check existing tables and update schema
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# For each model table, check if it needs updates
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
if table_name != 'alembic_version':
|
||||
if table_name in existing_tables:
|
||||
# Table exists, check for missing columns
|
||||
existing_columns = {col['name'] for col in inspector.get_columns(table_name)}
|
||||
expected_columns = {col.name for col in table.columns}
|
||||
missing_columns = expected_columns - existing_columns
|
||||
|
||||
# Add missing columns
|
||||
for column in table.columns:
|
||||
if column.name in missing_columns:
|
||||
# Convert to raw SQL for direct execution
|
||||
sql_type = _get_sql_type(column)
|
||||
nullable = "NULL" if column.nullable else "NOT NULL"
|
||||
|
||||
# Handle datetime columns without default (SQLite limitation)
|
||||
if column.type.__class__.__name__ == 'DateTime':
|
||||
# Add column without default, then update existing rows
|
||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}"
|
||||
try:
|
||||
connection.execute(text(alter_sql))
|
||||
logger.info(f"Added column {column.name} to {table_name}")
|
||||
|
||||
# Update existing rows with current timestamp
|
||||
update_sql = f"UPDATE {table_name} SET {column.name} = CURRENT_TIMESTAMP WHERE {column.name} IS NULL"
|
||||
connection.execute(text(update_sql))
|
||||
logger.info(f"Updated {column.name} with current timestamp")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not add column {column.name}: {e}")
|
||||
else:
|
||||
# Handle other column types with defaults
|
||||
default_clause = ""
|
||||
if column.default is not None:
|
||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
||||
# Skip callable defaults for SQLite compatibility
|
||||
pass
|
||||
elif hasattr(column.default, 'arg'):
|
||||
default_clause = f" DEFAULT {repr(column.default.arg)}"
|
||||
|
||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}{default_clause}"
|
||||
try:
|
||||
connection.execute(text(alter_sql))
|
||||
logger.info(f"Added column {column.name} to {table_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not add column {column.name}: {e}")
|
||||
else:
|
||||
# Table doesn't exist, create it
|
||||
logger.info(f"Creating table {table_name}")
|
||||
|
||||
# Create alembic_version table manually
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL,
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)
|
||||
"""))
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _get_sql_type(column):
|
||||
"""Get SQL type for direct SQL execution."""
|
||||
type_name = column.type.__class__.__name__
|
||||
|
||||
if type_name == 'String':
|
||||
return f"VARCHAR({column.type.length})"
|
||||
elif type_name == 'Integer':
|
||||
return "INTEGER"
|
||||
elif type_name == 'Text':
|
||||
return "TEXT"
|
||||
elif type_name == 'Boolean':
|
||||
return "BOOLEAN"
|
||||
elif type_name == 'DateTime':
|
||||
return "DATETIME"
|
||||
else:
|
||||
return "TEXT" # fallback
|
||||
|
||||
|
||||
def check_migration_status():
|
||||
"""
|
||||
Check if the database needs migrations.
|
||||
Returns True if migrations are needed, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Create engine
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
# Check if alembic_version table exists
|
||||
with engine.connect() as connection:
|
||||
# Check if alembic_version table exists
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'")
|
||||
)
|
||||
alembic_table_exists = result.fetchone() is not None
|
||||
|
||||
if not alembic_table_exists:
|
||||
return True
|
||||
|
||||
# Get current migration context
|
||||
context = MigrationContext.configure(connection)
|
||||
current_rev = context.get_current_revision()
|
||||
|
||||
# Get the latest revision from alembic
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
script_dir = command.ScriptDirectory.from_config(alembic_cfg)
|
||||
head_rev = script_dir.get_current_head()
|
||||
|
||||
return current_rev != head_rev
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking migration status: {e}")
|
||||
return True # Assume migrations are needed if we can't check
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# This allows running migrations directly
|
||||
run_migrations()
|
||||
@@ -1,239 +0,0 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
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")
|
||||
reactions = relationship("Reaction", 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")
|
||||
reactions = relationship("DMReaction", 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)
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
# Ensure unique combination of message, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
|
||||
|
||||
|
||||
class DMReaction(Base):
|
||||
__tablename__ = "dm_reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
|
||||
|
||||
# Ensure unique combination of dm_envelope, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
||||
|
||||
|
||||
# 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 = 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
|
||||
|
||||
|
||||
class ReactionRequest(BaseModel):
|
||||
message_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class ReactionResponse(BaseModel):
|
||||
id: int
|
||||
message_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DMReactionRequest(BaseModel):
|
||||
dm_envelope_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class DMReactionResponse(BaseModel):
|
||||
id: int
|
||||
dm_envelope_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Tables are now created through Alembic migrations
|
||||
# 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,225 +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)
|
||||
|
||||
token = create_token(new_user.id, new_user.username)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Регистрация прошла успешно",
|
||||
"token": token,
|
||||
"user": convert_user(new_user)
|
||||
}
|
||||
|
||||
@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,983 +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, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse
|
||||
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:
|
||||
# Group reactions by emoji
|
||||
reactions_dict = {}
|
||||
if msg.reactions:
|
||||
for reaction in msg.reactions:
|
||||
emoji = reaction.emoji
|
||||
if emoji not in reactions_dict:
|
||||
reactions_dict[emoji] = {
|
||||
"emoji": emoji,
|
||||
"count": 0,
|
||||
"users": []
|
||||
}
|
||||
reactions_dict[emoji]["count"] += 1
|
||||
reactions_dict[emoji]["users"].append({
|
||||
"id": reaction.user_id,
|
||||
"username": reaction.user.username
|
||||
})
|
||||
|
||||
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,
|
||||
"reactions": list(reactions_dict.values()),
|
||||
"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 [])
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
||||
# Group reactions by emoji
|
||||
reactions_dict = {}
|
||||
if envelope.reactions:
|
||||
for reaction in envelope.reactions:
|
||||
emoji = reaction.emoji
|
||||
if emoji not in reactions_dict:
|
||||
reactions_dict[emoji] = {
|
||||
"emoji": emoji,
|
||||
"count": 0,
|
||||
"users": []
|
||||
}
|
||||
reactions_dict[emoji]["count"] += 1
|
||||
reactions_dict[emoji]["users"].append({
|
||||
"id": reaction.user_id,
|
||||
"username": reaction.user.username
|
||||
})
|
||||
|
||||
return {
|
||||
"id": envelope.id,
|
||||
"senderId": envelope.sender_id,
|
||||
"recipientId": envelope.recipient_id,
|
||||
"iv": envelope.iv_b64,
|
||||
"ciphertext": envelope.ciphertext_b64,
|
||||
"salt": envelope.salt_b64,
|
||||
"iv2": envelope.iv2_b64,
|
||||
"wrappedMk": envelope.wrapped_mk_b64,
|
||||
"timestamp": envelope.timestamp.isoformat(),
|
||||
"reactions": list(reactions_dict.values()),
|
||||
"files": [
|
||||
{
|
||||
"path": f"/api/uploads/files/encrypted/{Path(f.path).name}",
|
||||
"id": f.id,
|
||||
"name": f.name,
|
||||
"dm_envelope_id": f.dm_envelope_id
|
||||
}
|
||||
for f in (envelope.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}
|
||||
|
||||
|
||||
@router.post("/add_reaction")
|
||||
async def add_reaction(
|
||||
request: ReactionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check if message exists
|
||||
message = db.query(Message).filter(Message.id == request.message_id).first()
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# Check if reaction already exists
|
||||
existing_reaction = db.query(Reaction).filter(
|
||||
Reaction.message_id == request.message_id,
|
||||
Reaction.user_id == current_user.id,
|
||||
Reaction.emoji == request.emoji
|
||||
).first()
|
||||
|
||||
if existing_reaction:
|
||||
# Remove existing reaction (toggle off)
|
||||
db.delete(existing_reaction)
|
||||
action = "removed"
|
||||
else:
|
||||
# Add new reaction
|
||||
new_reaction = Reaction(
|
||||
message_id=request.message_id,
|
||||
user_id=current_user.id,
|
||||
emoji=request.emoji
|
||||
)
|
||||
db.add(new_reaction)
|
||||
action = "added"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh message to get updated reactions
|
||||
db.refresh(message)
|
||||
|
||||
# Broadcast reaction update
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast({
|
||||
"type": "reactionUpdate",
|
||||
"data": {
|
||||
"message_id": request.message_id,
|
||||
"emoji": request.emoji,
|
||||
"action": action,
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": convert_message(message)["reactions"]
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
|
||||
|
||||
|
||||
@router.post("/dm/add_reaction")
|
||||
async def add_dm_reaction(
|
||||
request: DMReactionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check if DM envelope exists
|
||||
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first()
|
||||
if not envelope:
|
||||
raise HTTPException(status_code=404, detail="DM envelope not found")
|
||||
|
||||
# Check if user is part of this DM conversation
|
||||
if current_user.id not in [envelope.sender_id, envelope.recipient_id]:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to react to this message")
|
||||
|
||||
# Check if reaction already exists
|
||||
existing_reaction = db.query(DMReaction).filter(
|
||||
DMReaction.dm_envelope_id == request.dm_envelope_id,
|
||||
DMReaction.user_id == current_user.id,
|
||||
DMReaction.emoji == request.emoji
|
||||
).first()
|
||||
|
||||
if existing_reaction:
|
||||
# Remove existing reaction (toggle off)
|
||||
db.delete(existing_reaction)
|
||||
action = "removed"
|
||||
else:
|
||||
# Add new reaction
|
||||
new_reaction = DMReaction(
|
||||
dm_envelope_id=request.dm_envelope_id,
|
||||
user_id=current_user.id,
|
||||
emoji=request.emoji
|
||||
)
|
||||
db.add(new_reaction)
|
||||
action = "added"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh envelope to get updated reactions
|
||||
db.refresh(envelope)
|
||||
|
||||
# Broadcast reaction update to both participants
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast({
|
||||
"type": "dmReactionUpdate",
|
||||
"data": {
|
||||
"dm_envelope_id": request.dm_envelope_id,
|
||||
"emoji": request.emoji,
|
||||
"action": action,
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": convert_dm_envelope(envelope)["reactions"]
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
|
||||
|
||||
|
||||
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)
|
||||
elif type == "addReaction":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
request_data = data["data"]
|
||||
reaction_request = ReactionRequest(
|
||||
message_id=request_data["message_id"],
|
||||
emoji=request_data["emoji"]
|
||||
)
|
||||
|
||||
response = await add_reaction(reaction_request, current_user, db)
|
||||
|
||||
# Broadcast reaction update
|
||||
await self.broadcast({
|
||||
"type": "reactionUpdate",
|
||||
"data": {
|
||||
"message_id": request_data["message_id"],
|
||||
"emoji": request_data["emoji"],
|
||||
"action": response["action"],
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": response["reactions"]
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "addDmReaction":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
request_data = data["data"]
|
||||
reaction_request = DMReactionRequest(
|
||||
dm_envelope_id=request_data["dm_envelope_id"],
|
||||
emoji=request_data["emoji"]
|
||||
)
|
||||
|
||||
response = await add_dm_reaction(reaction_request, current_user, db)
|
||||
|
||||
# Broadcast reaction update
|
||||
await self.broadcast({
|
||||
"type": "dmReactionUpdate",
|
||||
"data": {
|
||||
"dm_envelope_id": request_data["dm_envelope_id"],
|
||||
"emoji": request_data["emoji"],
|
||||
"action": response["action"],
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": response["reactions"]
|
||||
}
|
||||
})
|
||||
|
||||
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,44 +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
|
||||
|
||||
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
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: fromchat-data
|
||||
@@ -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,52 +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/ .
|
||||
|
||||
# 2.3. Build
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# 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:prod"]
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "frontend-server",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node server.ts",
|
||||
"build": "tsc -b",
|
||||
"start:prod": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0",
|
||||
"ts-node": "^10.9.0"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import express from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
||||
const filePath = process.env.STATIC_FILE_PATH || ".";
|
||||
|
||||
// API proxy middleware
|
||||
app.use('/api', createProxyMiddleware({
|
||||
target: backendHost,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
ws: true
|
||||
}));
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(resolve(filePath)));
|
||||
|
||||
// SPA routing - catch all handler for client-side routing
|
||||
app.use((_req, res) => {
|
||||
res.sendFile(resolve(filePath, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server launched on http://localhost:${port}`);
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"server.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Vendored
-25
@@ -1,25 +0,0 @@
|
||||
export type Platform = "win32" | "darwin" | "linux"
|
||||
|
||||
export interface NotificationShowOptions {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface ElectronNotifications {
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
show: (options: NotificationShowOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ElectronInterface {
|
||||
desktop: true,
|
||||
platform: Platform,
|
||||
notifications: ElectronNotifications
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronInterface: ElectronInterface
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
||||
import path from "node:path";
|
||||
import type { NotificationShowOptions } from '../electron.d.ts';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
app.whenReady().then(() => {
|
||||
mainWindow = new BrowserWindow({
|
||||
title: 'Main window',
|
||||
minWidth: 800,
|
||||
minHeight: 420,
|
||||
webPreferences: {
|
||||
preload: path.join(import.meta.dirname, "preload.mjs")
|
||||
},
|
||||
titleBarStyle: "hidden",
|
||||
trafficLightPosition: {
|
||||
x: 16 - 4,
|
||||
y: 16 - 4
|
||||
},
|
||||
titleBarOverlay: process.platform !== "darwin"
|
||||
});
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile('frontend/build/electron/dist/index.html');
|
||||
}
|
||||
|
||||
// Handle notification permission requests
|
||||
ipcMain.handle('request-notification-permission', async () => {
|
||||
if (Notification.isSupported()) {
|
||||
return 'granted';
|
||||
}
|
||||
return 'denied';
|
||||
});
|
||||
|
||||
// Handle showing notifications
|
||||
ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => {
|
||||
if (Notification.isSupported()) {
|
||||
try {
|
||||
const notification = new Notification({
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
icon: options.icon,
|
||||
silent: false,
|
||||
urgency: 'normal'
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
notification.show();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error creating notification:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
@@ -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,141 +0,0 @@
|
||||
import js from "@eslint/js";
|
||||
import typescript from "@typescript-eslint/eslint-plugin";
|
||||
import typescriptParser from "@typescript-eslint/parser";
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": typescript,
|
||||
"react": react,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
"jsx-a11y": jsxA11y
|
||||
},
|
||||
rules: {
|
||||
// TypeScript rules
|
||||
...typescript.configs.recommended.rules,
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
|
||||
// React rules
|
||||
...react.configs.recommended.rules,
|
||||
"react/react-in-jsx-scope": "off", // Not needed with React 17+
|
||||
"react/prop-types": "off", // Using TypeScript instead
|
||||
"react/jsx-uses-react": "off", // Not needed with React 17+
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-pascal-case": "error",
|
||||
"react/no-array-index-key": "off",
|
||||
"react/no-danger": "off",
|
||||
"react/no-deprecated": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-unescaped-entities": "error",
|
||||
"react/no-unknown-property": "error",
|
||||
"react/require-render-return": "error",
|
||||
"react/self-closing-comp": "error",
|
||||
"react/jsx-wrap-multilines": "error",
|
||||
"react/jsx-closing-bracket-location": "off",
|
||||
"react/jsx-closing-tag-location": "error",
|
||||
"react/jsx-curly-spacing": ["error", "never"],
|
||||
"react/jsx-equals-spacing": ["error", "never"],
|
||||
"react/jsx-first-prop-new-line": ["off", "multiline-multiprop"],
|
||||
"react/jsx-max-props-per-line": ["error", { maximum: 2, when: "multiline" }],
|
||||
"react/jsx-no-bind": "off",
|
||||
"react/jsx-no-literals": "off",
|
||||
"react/jsx-sort-props": "off",
|
||||
|
||||
// React Hooks rules
|
||||
...reactHooks.configs.recommended.rules,
|
||||
|
||||
// React Refresh rules
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true }
|
||||
],
|
||||
|
||||
// Accessibility rules
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
"jsx-a11y/alt-text": "off",
|
||||
"jsx-a11y/anchor-has-content": "error",
|
||||
"jsx-a11y/aria-props": "error",
|
||||
"jsx-a11y/aria-proptypes": "error",
|
||||
"jsx-a11y/aria-unsupported-elements": "error",
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/heading-has-content": "error",
|
||||
"jsx-a11y/img-redundant-alt": "warn",
|
||||
"jsx-a11y/no-access-key": "error",
|
||||
"jsx-a11y/role-has-required-aria-props": "error",
|
||||
"jsx-a11y/role-supports-aria-props": "error",
|
||||
"jsx-a11y/scope": "error",
|
||||
"jsx-a11y/tabindex-no-positive": "error",
|
||||
"jsx-a11y/no-noninteractive-element-interactions": "off",
|
||||
"jsx-a11y/anchor-is-valid": "off",
|
||||
|
||||
// General JavaScript/TypeScript rules
|
||||
"no-console": "off",
|
||||
"no-debugger": "error",
|
||||
"no-unused-vars": "off", // Handled by TypeScript version
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"no-undef": "off", // Handled by TypeScript version
|
||||
"eqeqeq": ["error", "always"],
|
||||
"curly": "off", // Changed from error to warn
|
||||
"brace-style": ["off", "1tbs"],
|
||||
"comma-dangle": "warn", // Changed from error to warn
|
||||
"comma-spacing": ["error", { before: false, after: true }],
|
||||
"comma-style": ["error", "last"],
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"func-call-spacing": ["off", "never"],
|
||||
"key-spacing": ["error", { beforeColon: false, afterColon: true }],
|
||||
"keyword-spacing": ["error", { before: true, after: true }],
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
"semi-spacing": ["error", { before: false, after: true }],
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": ["off", "never"],
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "error",
|
||||
"space-unary-ops": ["error", { words: true, nonwords: false }],
|
||||
"quotes": "warn", // Changed from error to warn
|
||||
"max-len": ["warn", { code: 150, ignoreUrls: true, ignoreStrings: true }],
|
||||
"no-empty": "off"
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"out/**",
|
||||
"*.min.js",
|
||||
"coverage/**",
|
||||
".nyc_output/**",
|
||||
"backend/**",
|
||||
"deployment/**",
|
||||
"web-calls/**"
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -1,45 +0,0 @@
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
|
||||
export default {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
},
|
||||
outDir: "frontend/build/electron/forge",
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
config: {},
|
||||
platforms: ['win32', 'darwin'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
config: {},
|
||||
platforms: ['linux'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
config: {},
|
||||
platforms: ['linux'],
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
config: {},
|
||||
},
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
// at package time, before code signing the application
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
} satisfies ForgeConfig;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { ElectronTitleBar } from "./Electron";
|
||||
import { useAppState } from "./pages/chat/state";
|
||||
import { useEffect, useState, lazy } from "react";
|
||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||
|
||||
// Lazy load route components
|
||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||
const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
|
||||
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
|
||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||
|
||||
export default function App() {
|
||||
const { restoreUserFromStorage } = useAppState();
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
|
||||
// Restore user from localStorage on app initialization
|
||||
useEffect(() => {
|
||||
restoreUserFromStorage().finally(() => {
|
||||
setAuthReady(true);
|
||||
});
|
||||
}, [restoreUserFromStorage]);
|
||||
|
||||
return authReady && (
|
||||
<BrowserRouter>
|
||||
<ElectronTitleBar />
|
||||
<div id="main-wrapper">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/download-app" element={<DownloadAppPage />} />
|
||||
<Route path="/">
|
||||
<Route path="chat" element={
|
||||
<ProtectedRoute>
|
||||
<ChatPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { PRODUCT_NAME } from "./core/config";
|
||||
import { isElectron } from "./core/electron/electron";
|
||||
|
||||
export function ElectronTitleBar() {
|
||||
return isElectron && (
|
||||
<div id="electron-title-bar">
|
||||
{window.electronInterface.platform === "darwin" && <div className="macos-padding" />}
|
||||
<div id="window-title">{PRODUCT_NAME}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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> {
|
||||
await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({
|
||||
publicKey: b64(publicKey)
|
||||
} satisfies UploadPublicKeyRequest)
|
||||
});
|
||||
}
|
||||
|
||||
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,183 +0,0 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { getCurrentKeys } from "./authApi";
|
||||
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,48 +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 "@/core/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) {
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
|
||||
const { open, onOpenChange } = props;
|
||||
|
||||
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 !== open) {
|
||||
onOpenChange(isOpen);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start observing the dialog element for attribute changes
|
||||
observer.observe(dialog, {
|
||||
attributes: true,
|
||||
attributeFilter: ["open"]
|
||||
});
|
||||
|
||||
// Cleanup observer
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [open, onOpenChange, dialogRef]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
|
||||
}
|
||||
@@ -1,11 +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,73 +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) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
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, duration, onFinish]);
|
||||
|
||||
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,43 +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) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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,11 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface BaseAnimatedPropertyProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
visible: any;
|
||||
duration?: number;
|
||||
onFinish?: () => void
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div">
|
||||
@@ -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,41 +0,0 @@
|
||||
@use "../../css/material" as *;
|
||||
|
||||
#electron-title-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.electron {
|
||||
#electron-title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
background-color: $color-dark-surface-container;
|
||||
width: 100%;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
transition: background-color 0.5s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.color-surface {
|
||||
background-color: $color-dark-surface;
|
||||
}
|
||||
}
|
||||
|
||||
#window-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#main-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&.platform-darwin .macos-padding {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Electron-specific code
|
||||
* @description This module initializes Electron-specific functionality.
|
||||
* @author denis0001-dev
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import "./electron.scss";
|
||||
|
||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface !== undefined;
|
||||
|
||||
if (isElectron) {
|
||||
console.log("Running in Electron");
|
||||
document.documentElement.classList.add("electron", `platform-${window.electronInterface.platform}`);
|
||||
} else {
|
||||
console.log("Running in normal browser");
|
||||
}
|
||||
@@ -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,257 +0,0 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import type { Message, NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
|
||||
import serviceWorker from "./service-worker?worker&url";
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
// Global state
|
||||
let isInitialized = false;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let subscription: PushSubscription | null = null;
|
||||
let isElectronReceiverRunning = false;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
// Helper functions
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
async function subscribeToWebPush(): Promise<PushSubscription | null> {
|
||||
if (!registration) {
|
||||
throw new Error("Service Worker not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(
|
||||
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
|
||||
).slice().buffer
|
||||
});
|
||||
|
||||
console.log("Push subscription successful");
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error("Push subscription failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSubscriptionToServer(token: string): Promise<boolean> {
|
||||
if (!subscription) {
|
||||
throw new Error("No push subscription available");
|
||||
}
|
||||
|
||||
const subscriptionData: PushSubscriptionData = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!),
|
||||
auth: arrayBufferToBase64(subscription.getKey("auth")!)
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(subscriptionData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("Failed to send subscription to server:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function showMessageNotification(message: Message): Promise<void> {
|
||||
try {
|
||||
await showNotification({
|
||||
title: `New message from ${message.username}`,
|
||||
body: message.content.length > 100
|
||||
? message.content.substring(0, 100) + "..."
|
||||
: message.content,
|
||||
icon: message.profile_picture || "/logo.png",
|
||||
tag: `message_${message.id}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show message notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebSocketMessage(response: WebSocketMessage<object>): Promise<void> {
|
||||
// Handle notifications for new messages
|
||||
if (response.type === "newMessage" && response.data) {
|
||||
const newResponse = response as NewMessageWebSocketMessage;
|
||||
await showMessageNotification(newResponse.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API functions
|
||||
export async function initialize(): Promise<boolean> {
|
||||
if (isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isElectron) {
|
||||
// For Electron, we just need to request permission
|
||||
const permission = await window.electronInterface.notifications.requestPermission();
|
||||
isInitialized = permission === "granted";
|
||||
return isInitialized;
|
||||
} else {
|
||||
// For web browsers, initialize service worker and push manager
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
console.log("Push messaging is not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
|
||||
console.log("Service Worker registered successfully");
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "granted") {
|
||||
await subscribeToWebPush();
|
||||
isInitialized = true;
|
||||
}
|
||||
return isInitialized;
|
||||
} catch (error) {
|
||||
console.error("Service Worker registration failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize notification service:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribe(token: string): Promise<boolean> {
|
||||
if (!isInitialized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need server-side subscription
|
||||
return true;
|
||||
}
|
||||
|
||||
return await sendSubscriptionToServer(token);
|
||||
}
|
||||
|
||||
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
try {
|
||||
return await window.electronInterface.notifications.show(payload);
|
||||
} catch (error) {
|
||||
console.error("Failed to show Electron notification:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For web browsers, notifications are handled by the service worker
|
||||
// when push messages are received from the server
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function unsubscribe(): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need to unsubscribe from server
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to unsubscribe:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupported(): boolean {
|
||||
if (isElectron) {
|
||||
return true; // Electron always supports notifications
|
||||
}
|
||||
return "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
// Electron-specific functions
|
||||
export async function startElectronReceiver(): Promise<void> {
|
||||
if (!isElectron || isElectronReceiverRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = true;
|
||||
|
||||
// Add our own message listener to the existing WebSocket
|
||||
messageListener = (event: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage<object> = JSON.parse(event.data);
|
||||
handleWebSocketMessage(response);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", messageListener);
|
||||
}
|
||||
|
||||
export function stopElectronReceiver(): void {
|
||||
if (!isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = false;
|
||||
|
||||
// Remove our message listener
|
||||
if (messageListener) {
|
||||
websocket.removeEventListener("message", messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
}
|
||||
@@ -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<object>) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Set the global WebSocket message handler
|
||||
* @param handler - Function to handle WebSocket messages
|
||||
*/
|
||||
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<object>) => void) | null): void {
|
||||
globalMessageHandler = handler;
|
||||
}
|
||||
|
||||
export function request<Request, Response = object>(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 = 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<object> = 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,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,84 +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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,21 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppState } from "./chat/state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { user } = useAppState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.authToken) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
}, [user.authToken, user.currentUser, navigate]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,56 +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>
|
||||
)
|
||||
}
|
||||
|
||||
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,148 +0,0 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "./Auth";
|
||||
import { AuthContainer, AuthHeader } from "./Auth";
|
||||
import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types";
|
||||
import { ensureKeysOnLogin } from "@/core/api/authApi";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { useRef } from "react";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialTextField } from "@/core/components/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./auth.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => {
|
||||
alerts.push({ type: type, message: message });
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
navigate("/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 {
|
||||
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={() => navigate("/register")}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AuthContainer, AuthHeader } from "./Auth";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "./Auth";
|
||||
import { useRef } from "react";
|
||||
import { TextField } from "mdui/components/text-field";
|
||||
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialTextField } from "@/core/components/TextField";
|
||||
import { ensureKeysOnLogin } from "@/core/api/authApi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./auth.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
const confirmPasswordElement = useRef<TextField>(null);
|
||||
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => {
|
||||
alerts.push({ type: type, message: message });
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Ошибка при регистрации");
|
||||
}
|
||||
} catch {
|
||||
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={() => navigate("/login")}>
|
||||
Войдите
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
@use "../../css/colors" as *;
|
||||
@use "../../css/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,107 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Animation for reactions appearing/disappearing
|
||||
@keyframes messageReactionsFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reactionFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reactionFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu wrapper animations
|
||||
@keyframes contextMenuEnter {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterLeft {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterUpLeft {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuClose {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseLeft {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseUp {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseUpLeft {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) translateY(20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiMenuEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
margin: 0 10px 10px 10px;
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
background: $color-dark-surface-container;
|
||||
border-radius: 30px;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
|
||||
.buttons, .left-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left-buttons {
|
||||
.emoji-btn {
|
||||
margin: 10px;
|
||||
color: $color-dark-on-surface-variant;
|
||||
transition: color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
|
||||
&:hover {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
caret-color: $color-dark-primary;
|
||||
color: $color-dark-on-surface;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font: inherit;
|
||||
font-size: 13pt;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: $color-dark-on-surface-variant;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
.send-btn {
|
||||
margin: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
|
||||
color: $color-dark-on-primary;
|
||||
border: 1px solid rgba($color-dark-primary, 0.5);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.25s ease;
|
||||
align-self: flex-end;
|
||||
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 30px rgba($color-dark-primary, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji Menu Styles
|
||||
.emoji-menu {
|
||||
$transition: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
background: $color-dark-surface-container;
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform-origin: bottom left;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: transform 0.25s $transition, opacity 0.25s $transition;
|
||||
user-select: none;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.emoji-menu-header {
|
||||
background: $color-dark-surface-container-high;
|
||||
border-bottom: 1px solid rgba($color-dark-outline-variant, 0.2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
.emoji-category-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
padding: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
}
|
||||
|
||||
.emoji-category-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 1.2rem;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background: $color-dark-surface-container;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $color-dark-primary-container;
|
||||
color: $color-dark-on-primary-container;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: $color-dark-primary-container;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.emoji-category-section {
|
||||
.emoji-category-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $color-dark-on-surface-variant;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.emoji-category-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
$size: 30px;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: $size;
|
||||
width: $size;
|
||||
height: $size;
|
||||
box-sizing: content-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: $color-dark-surface-container-high;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-empty-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
// Integrated mode styles (inside reaction bar)
|
||||
&.integrated {
|
||||
position: relative !important;
|
||||
width: 320px !important;
|
||||
height: 400px !important;
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: $color-dark-surface-container;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Reaction bar styles (standalone)
|
||||
.reaction-bar {
|
||||
background: $color-dark-surface-container;
|
||||
border: 1px solid $color-dark-outline;
|
||||
border-radius: 24px;
|
||||
padding: 8px;
|
||||
opacity: 1;
|
||||
transition: all 0.15s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
transform: translateY(0);
|
||||
|
||||
&.closing {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji menu wrapper inside reaction bar
|
||||
.emoji-menu-wrapper {
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// Context menu wrapper with animations
|
||||
.context-menu-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
// Animation states
|
||||
&.entering {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
animation: contextMenuEnter 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-left {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) scale(0.8);
|
||||
animation: contextMenuEnterLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
animation: contextMenuEnterUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up-left {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) translateY(20px) scale(0.8);
|
||||
animation: contextMenuEnterUpLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
animation: contextMenuClose 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-left {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
animation: contextMenuCloseLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
animation: contextMenuCloseUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up-left {
|
||||
opacity: 1;
|
||||
transform: translateX(0) translateY(0) scale(1);
|
||||
animation: contextMenuCloseUpLeft 0.2s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reaction bar inside context menu wrapper
|
||||
.context-menu-reaction-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: $color-dark-surface-container;
|
||||
border: 1px solid $color-dark-outline;
|
||||
border-radius: 16px;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
transition: width 0.3s ease-out, height 0.3s ease-out;
|
||||
|
||||
&.left {
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&.right {
|
||||
right: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
border-radius: 16px;
|
||||
|
||||
// Default: expand downward from the reaction bar's bottom edge
|
||||
position: absolute;
|
||||
bottom: auto;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateY(0);
|
||||
|
||||
&.expand-upward {
|
||||
// Expand upward from the reaction bar's top edge
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.emoji-menu-wrapper {
|
||||
animation: emojiMenuEnter 0.5s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-bar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.faded {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-emoji-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
font-size: 18px;
|
||||
|
||||
&:hover {
|
||||
background: var(--mdui-color-surface-container-high);
|
||||
transform: scale(1.3);
|
||||
box-shadow: var(--mdui-elevation-1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-expand-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--mdui-color-outline);
|
||||
border-radius: 16px;
|
||||
background: var(--mdui-color-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
&:hover {
|
||||
background: var(--mdui-color-surface-container-high);
|
||||
border-color: var(--mdui-color-primary);
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--mdui-elevation-1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 18px;
|
||||
color: var(--mdui-color-on-surface);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .material-symbols {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
|
||||
// ChatHeader component styles
|
||||
.chat-header-left {
|
||||
.product-name {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
|
||||
}
|
||||
|
||||
.profile {
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba($color-dark-primary, 0.4);
|
||||
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 25px rgba($color-dark-primary, 0.5);
|
||||
border-color: rgba($color-dark-primary, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Reaction styles
|
||||
.message-reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
animation: messageReactionsFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.reaction-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background-color: $color-dark-surface-container;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
font-size: 1px;
|
||||
min-height: 28px;
|
||||
animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
&.removing {
|
||||
animation: reactionFadeOut 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&.reacted {
|
||||
background-color: $color-dark-primary-container;
|
||||
border-color: $color-dark-primary;
|
||||
color: $color-dark-on-primary-container;
|
||||
|
||||
&:hover {
|
||||
background-color: color.adjust($color-dark-primary-container, $lightness: 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-emoji {
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.reaction-count {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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: 10px;
|
||||
|
||||
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: 10px;
|
||||
|
||||
&: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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
|
||||
.message-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
.error-icon {
|
||||
color: #f44336;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #4caf50;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
mdui-circular-progress {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.received .message-inner {
|
||||
background: $color-dark-surface-container;
|
||||
color: $color-dark-on-surface;
|
||||
border-top-left-radius: 5px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba($color-dark-primary, 0.05), rgba($color-dark-tertiary, 0.03));
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.received .message-time {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-weight: 500;
|
||||
|
||||
.message-status-indicator {
|
||||
.success-icon {
|
||||
color: $color-dark-on-surface-variant;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #ff6b6b;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.sent {
|
||||
margin-left: auto;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-inner {
|
||||
background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
|
||||
color: $color-dark-on-primary;
|
||||
border-top-right-radius: 5px;
|
||||
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
|
||||
border: 1px solid rgba($color-dark-primary, 0.5);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08));
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: $color-dark-on-primary;
|
||||
font-weight: 500;
|
||||
|
||||
.message-status-indicator {
|
||||
.success-icon {
|
||||
color: $color-dark-on-primary;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #ff6b6b;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,251 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Profile styles
|
||||
#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,131 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.chat-main {
|
||||
.chat-header {
|
||||
padding: 16px;
|
||||
background: rgba($color-dark-surface-container, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 20px rgba($color-dark-primary, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.chat-header-avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 20%;
|
||||
object-fit: cover;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
display: flex;
|
||||
|
||||
.info-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h4 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #718096;
|
||||
}
|
||||
}
|
||||
|
||||
.online-status {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: $success;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
justify-content: end;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
right: 2%;
|
||||
top: 2%;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 10px 20px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Settings styles
|
||||
#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,11 +0,0 @@
|
||||
// Import all component-specific styles
|
||||
@use "layout";
|
||||
@use "left-panel";
|
||||
@use "right-panel";
|
||||
@use "message";
|
||||
@use "chat-input";
|
||||
@use "message-reactions";
|
||||
@use "context-menu";
|
||||
@use "profile-dialog";
|
||||
@use "settings-dialog";
|
||||
@use "animations";
|
||||
@@ -1,300 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUserPublicKey,
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
} from "../../../core/api/dmApi";
|
||||
import type { User, Message, DmEncryptedJSON } from "@/core/types";
|
||||
import { websocket } from "@/core/websocket";
|
||||
|
||||
export 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, loadUserLastMessage, setDmUsers]);
|
||||
|
||||
// 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(() => {
|
||||
async function handleWebSocketMessage(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, user.authToken]);
|
||||
|
||||
// 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,362 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import type { Message, User } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
import { MessagePanel } from "./ui/right/panels/MessagePanel";
|
||||
import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel";
|
||||
import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { restoreKeys } from "@/core/api/authApi";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
|
||||
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||
|
||||
interface ActiveDM {
|
||||
userId: number;
|
||||
username: string;
|
||||
publicKey: string | null
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: Message[];
|
||||
currentChat: string;
|
||||
activeTab: ChatTabs;
|
||||
dmUsers: User[];
|
||||
activeDm: ActiveDM | null;
|
||||
isSwitching: boolean;
|
||||
setIsSwitching: (value: boolean) => void;
|
||||
activePanel: MessagePanel | null;
|
||||
publicChatPanel: PublicChatPanel | null;
|
||||
dmPanel: DMPanel | null;
|
||||
pendingPanel?: MessagePanel | null;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
currentUser: User | null;
|
||||
authToken: string | null;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Chat state
|
||||
chat: ChatState;
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
|
||||
removeMessage: (messageId: number) => void;
|
||||
setCurrentChat: (chat: string) => void;
|
||||
setActiveTab: (tab: ChatState["activeTab"]) => void;
|
||||
setDmUsers: (users: User[]) => void;
|
||||
setActiveDm: (dm: ChatState["activeDm"]) => void;
|
||||
clearMessages: () => void;
|
||||
setActivePanel: (panel: MessagePanel | null) => void;
|
||||
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||
applyPendingPanel: () => void;
|
||||
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||
|
||||
// User state
|
||||
user: UserState;
|
||||
setUser: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
restoreUserFromStorage: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppState = create<AppState>((set, get) => ({
|
||||
// Chat state
|
||||
chat: {
|
||||
messages: [],
|
||||
currentChat: "Общий чат",
|
||||
activeTab: "chats",
|
||||
dmUsers: [],
|
||||
activeDm: null,
|
||||
isSwitching: false,
|
||||
setIsSwitching: (value: boolean) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
isSwitching: value
|
||||
}
|
||||
})),
|
||||
activePanel: null,
|
||||
publicChatPanel: null,
|
||||
dmPanel: null,
|
||||
pendingPanel: null
|
||||
},
|
||||
addMessage: (message: Message) => set((state) => {
|
||||
// Check if message already exists to prevent duplicates
|
||||
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||
if (messageExists) {
|
||||
return state; // Return unchanged state if message already exists
|
||||
}
|
||||
|
||||
return {
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: [...state.chat.messages, message]
|
||||
}
|
||||
};
|
||||
}),
|
||||
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: state.chat.messages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
|
||||
)
|
||||
}
|
||||
})),
|
||||
removeMessage: (messageId: number) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: state.chat.messages.filter(msg => msg.id !== messageId)
|
||||
}
|
||||
})),
|
||||
clearMessages: () => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
messages: []
|
||||
}
|
||||
})),
|
||||
setCurrentChat: (chat: string) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
currentChat: chat
|
||||
}
|
||||
})),
|
||||
setActiveTab: (tab: ChatState["activeTab"]) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activeTab: tab
|
||||
}
|
||||
})),
|
||||
setDmUsers: (users: User[]) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
dmUsers: users
|
||||
}
|
||||
})),
|
||||
setActiveDm: (dm: ChatState["activeDm"]) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activeDm: dm
|
||||
}
|
||||
})),
|
||||
|
||||
// User state
|
||||
user: {
|
||||
currentUser: null,
|
||||
authToken: null
|
||||
},
|
||||
setUser: (token: string, user: User) => {
|
||||
set(() => ({
|
||||
user: {
|
||||
currentUser: user,
|
||||
authToken: token
|
||||
}
|
||||
}));
|
||||
|
||||
// Store credentials in localStorage
|
||||
try {
|
||||
localStorage.setItem("authToken", token);
|
||||
localStorage.setItem("currentUser", JSON.stringify(user));
|
||||
} catch (error) {
|
||||
console.error("Failed to store credentials in localStorage:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
request({
|
||||
type: "ping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
},
|
||||
data: {}
|
||||
}).then(() => {
|
||||
console.log("Ping succeeded")
|
||||
})
|
||||
} catch {}
|
||||
},
|
||||
logout: () => {
|
||||
// Clear localStorage
|
||||
try {
|
||||
localStorage.removeItem("authToken");
|
||||
localStorage.removeItem("currentUser");
|
||||
} catch (error) {
|
||||
console.error("Failed to clear localStorage:", error);
|
||||
}
|
||||
|
||||
set(() => ({
|
||||
user: {
|
||||
currentUser: null,
|
||||
authToken: null
|
||||
}
|
||||
}));
|
||||
},
|
||||
restoreUserFromStorage: async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
if (token) {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user: User = await response.json();
|
||||
restoreKeys();
|
||||
|
||||
set(() => ({
|
||||
user: {
|
||||
currentUser: user,
|
||||
authToken: token
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
request({
|
||||
type: "ping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
},
|
||||
data: {}
|
||||
}).then(() => {
|
||||
console.log("Ping succeeded")
|
||||
})
|
||||
} catch {}
|
||||
|
||||
// Initialize notifications after successful credential restoration
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(token);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed (restored):", e);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unable to authenticate");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to restore user from localStorage:", error);
|
||||
// Clear invalid data
|
||||
localStorage.removeItem("authToken");
|
||||
localStorage.removeItem("currentUser");
|
||||
}
|
||||
},
|
||||
|
||||
// Panel management
|
||||
setActivePanel: (panel: MessagePanel | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: panel
|
||||
}
|
||||
})),
|
||||
// Stash a panel to be applied after switch-out animation ends
|
||||
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: panel
|
||||
}
|
||||
})),
|
||||
// Apply pending panel atomically and update related fields
|
||||
applyPendingPanel: () => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||
// when switching to public chat, keep reference if type matches
|
||||
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||
? (state.chat.pendingPanel as PublicChatPanel)
|
||||
: state.chat.publicChatPanel,
|
||||
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||
? (state.chat.pendingPanel as DMPanel)
|
||||
: state.chat.dmPanel,
|
||||
// update currentChat from panel title if available
|
||||
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||
pendingPanel: null
|
||||
}
|
||||
})),
|
||||
|
||||
switchToPublicChat: async (chatName: string) => {
|
||||
const { user, chat } = get();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
// Start chat switching animation
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
// Create or get public chat panel
|
||||
let publicChatPanel = chat.publicChatPanel;
|
||||
if (!publicChatPanel) {
|
||||
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||
} else {
|
||||
publicChatPanel.setChatName(chatName);
|
||||
publicChatPanel.setAuthToken(user.authToken);
|
||||
// Reset messages for the new chat
|
||||
publicChatPanel.clearMessages();
|
||||
}
|
||||
|
||||
// Activate panel
|
||||
await publicChatPanel.activate();
|
||||
|
||||
// Defer panel swap until animation switch-out completes
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: publicChatPanel,
|
||||
activeTab: "chats"
|
||||
}
|
||||
}));
|
||||
|
||||
// Let MessagePanelRenderer handle the animation timing completely
|
||||
// It will set isChatSwitching to false when the fadeInDown animation completes
|
||||
},
|
||||
|
||||
switchToDM: async (dmData: DMPanelData) => {
|
||||
const { user, chat } = get();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
// Start chat switching animation
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
// Create or get DM panel
|
||||
let dmPanel = chat.dmPanel;
|
||||
if (!dmPanel) {
|
||||
dmPanel = new DMPanel(user);
|
||||
} else {
|
||||
dmPanel.setAuthToken(user.authToken);
|
||||
// Reset messages for the new DM
|
||||
dmPanel.clearMessages();
|
||||
}
|
||||
|
||||
// Set DM data
|
||||
dmPanel.setDMData(dmData);
|
||||
|
||||
// Activate panel
|
||||
await dmPanel.activate();
|
||||
|
||||
// Defer panel swap until animation switch-out completes
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: dmPanel,
|
||||
activeDm: {
|
||||
userId: dmData.userId,
|
||||
username: dmData.username,
|
||||
publicKey: dmData.publicKey
|
||||
},
|
||||
activeTab: "dms"
|
||||
}
|
||||
}));
|
||||
|
||||
// Let MessagePanelRenderer handle the animation timing completely
|
||||
// It will set isChatSwitching to false when the fadeInDown animation completes
|
||||
}
|
||||
}));
|
||||
@@ -1,18 +0,0 @@
|
||||
import { LeftPanel } from "./left/LeftPanel";
|
||||
import { RightPanel } from "./right/RightPanel";
|
||||
import "@/pages/chat/css/chat.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
|
||||
return (
|
||||
<div id="chat-interface">
|
||||
<div className="all-container">
|
||||
<LeftPanel />
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { PRODUCT_NAME } from "@/core/config";
|
||||
import useProfile from "@/pages/chat/hooks/useProfile";
|
||||
import defaultAvatar from "@/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,94 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||
import defaultAvatar from "@/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>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUserClick(user: DMUser) {
|
||||
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: DMUser) => (
|
||||
<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,115 +0,0 @@
|
||||
import { PRODUCT_NAME } from "@/core/config";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import defaultAvatar from "@/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 as ChatTabsType } from "@/pages/chat/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 icon="group_add--filled" />
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
<mdui-button-icon
|
||||
icon="logout--filled"
|
||||
id="logout-btn"
|
||||
onClick={handleLogout}
|
||||
title="Выйти" />
|
||||
<mdui-fab icon="edit--filled" />
|
||||
</mdui-bottom-app-bar>
|
||||
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatTabs() {
|
||||
const { chat, setActiveTab, switchToPublicChat } = useAppState();
|
||||
const { activeTab } = chat;
|
||||
|
||||
async function handleChatClick(chatName: string) {
|
||||
await switchToPublicChat(chatName);
|
||||
}
|
||||
|
||||
function handleTabChange(e: FormEvent<Tabs>) {
|
||||
setActiveTab((e.target as Tabs).value as ChatTabsType);
|
||||
}
|
||||
|
||||
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,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" />
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<div id="cropper-area" />
|
||||
</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,194 +0,0 @@
|
||||
import { useCallback, 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(() => {
|
||||
const img = imageRef.current;
|
||||
|
||||
if (imageFile) {
|
||||
const reader = new FileReader();
|
||||
|
||||
function handleImageLoad() {
|
||||
setIsLoaded(true);
|
||||
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 (img) {
|
||||
setSrc(reader.result as string);
|
||||
img.addEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
|
||||
reader.addEventListener("load", handleReaderLoad);
|
||||
reader.readAsDataURL(imageFile);
|
||||
|
||||
return () => {
|
||||
reader.abort();
|
||||
reader.removeEventListener("load", handleReaderLoad);
|
||||
img?.removeEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
}, [imageFile, imageRef]);
|
||||
|
||||
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() {
|
||||
const img = imageRef.current;
|
||||
if (!canvasRef.current || !img || !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(
|
||||
img,
|
||||
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);
|
||||
};
|
||||
|
||||
const drawCropArea = useCallback(() => {
|
||||
const img = imageRef.current;
|
||||
if (!canvasRef.current || !img || !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(img, 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);
|
||||
}, [cropArea, isLoaded, imageRef]);
|
||||
|
||||
useEffect(() => {
|
||||
drawCropArea();
|
||||
}, [cropArea, isLoaded, drawCropArea]);
|
||||
|
||||
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,180 +0,0 @@
|
||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import useProfile from "@/pages/chat/hooks/useProfile";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
import { MaterialTextField } from "@/core/components/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) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
function handleCropCancel() {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function 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,205 +0,0 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { PRODUCT_NAME, API_BASE_URL } from "@/core/config";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import type { Switch } from "mdui/components/switch";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const [activePanel, setActivePanel] = useState("notifications-settings");
|
||||
const pushSupported = useMemo(() => isSupported(), []);
|
||||
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(pushSupported);
|
||||
const user = useAppState(state => state.user);
|
||||
|
||||
function handlePanelChange(panelId: string) {
|
||||
setActivePanel(panelId);
|
||||
}
|
||||
|
||||
async function handlePushNotificationToggle(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-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-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,230 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||
import type { Message } from "@/core/types";
|
||||
import Quote from "@/core/components/Quote";
|
||||
import AnimatedHeight from "@/core/components/animations/AnimatedHeight";
|
||||
import { useImmer } from "use-immer";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
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;
|
||||
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper(
|
||||
{
|
||||
onSendMessage,
|
||||
onSaveEdit,
|
||||
replyTo,
|
||||
replyToVisible,
|
||||
onClearReply,
|
||||
onCloseReply,
|
||||
editingMessage,
|
||||
editVisible = false,
|
||||
onClearEdit,
|
||||
onCloseEdit,
|
||||
onProvideFileAdder,
|
||||
messagePanelRef
|
||||
}: ChatInputWrapperProps
|
||||
) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
|
||||
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
|
||||
const [errorOpen, setErrorOpen] = useState(false);
|
||||
const [emojiMenuOpen, setEmojiMenuOpen] = useState(false);
|
||||
const [emojiMenuPosition, setEmojiMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const chatInputWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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, setSelectedFiles]);
|
||||
|
||||
// When entering edit mode, preload the message content
|
||||
useEffect(() => {
|
||||
setMessage(editingMessage ? editingMessage.content || "" : "");
|
||||
}, [editingMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
setAttachmentsVisible(selectedFiles.length > 0);
|
||||
}, [selectedFiles]);
|
||||
|
||||
function handleEmojiButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!emojiMenuOpen) {
|
||||
if (chatInputWrapperRef.current && messagePanelRef?.current) {
|
||||
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
|
||||
const panelRect = messagePanelRef.current.getBoundingClientRect();
|
||||
|
||||
// Position menu 10px from message panel edge and 10px above the chat input
|
||||
// The animation will start 30px below this position
|
||||
setEmojiMenuPosition({
|
||||
x: panelRect.left + 10, // 10px from message panel edge
|
||||
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
|
||||
});
|
||||
setEmojiMenuOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmojiMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
setMessage(prev => prev + emoji);
|
||||
}
|
||||
|
||||
async function handleSubmit(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" ref={chatInputWrapperRef}>
|
||||
<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} />
|
||||
</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} />
|
||||
</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" />
|
||||
<span className="name">{file.name}</span>
|
||||
</mdui-chip>
|
||||
))}
|
||||
</div>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)} />
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<div className="chat-input">
|
||||
<div className="left-buttons">
|
||||
<mdui-button-icon
|
||||
icon="mood"
|
||||
onClick={handleEmojiButtonClick}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onMouseUp={e => e.stopPropagation()}
|
||||
className="emoji-btn" />
|
||||
</div>
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<EmojiMenu
|
||||
isOpen={emojiMenuOpen}
|
||||
onClose={() => setEmojiMenuOpen(false)}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
position={emojiMenuPosition}
|
||||
mode="standalone"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { setGlobalMessageHandler } from "@/core/websocket";
|
||||
import type { Message, WebSocketMessage } from "@/core/types";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
|
||||
import type { DMPanel } from "./panels/DMPanel";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
}
|
||||
|
||||
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const { applyPendingPanel, chat } = useAppState();
|
||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||
const [switchIn, setSwitchIn] = useState(false);
|
||||
const [switchOut, setSwitchOut] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const previousMessageCountRef = useRef(0);
|
||||
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());
|
||||
|
||||
// Store the handler for cleanup
|
||||
panel.onStateChange = (newState: MessagePanelState) => {
|
||||
setPanelState(newState);
|
||||
};
|
||||
|
||||
// Set up WebSocket message handler for this panel
|
||||
if (panel.handleWebSocketMessage) {
|
||||
setGlobalMessageHandler((message: WebSocketMessage<object>) => panel.handleWebSocketMessage(message));
|
||||
}
|
||||
} else {
|
||||
setPanelState(null);
|
||||
setGlobalMessageHandler(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (panel) {
|
||||
if (panel.onStateChange) {
|
||||
panel.onStateChange = null;
|
||||
}
|
||||
|
||||
if (typeof panel.destroy === "function") {
|
||||
panel.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [panel]);
|
||||
|
||||
// Handle chat switching animation with event listeners
|
||||
useEffect(() => {
|
||||
if (chat.isSwitching) {
|
||||
setSwitchOut(true);
|
||||
|
||||
// Use animation event listeners instead of hardcoded delays
|
||||
function handleAnimationEnd(event: Event) {
|
||||
const animationEvent = event as AnimationEvent;
|
||||
|
||||
if (animationEvent.animationName === "fadeOutUp") {
|
||||
// Apply pending panel exactly at the boundary between animations
|
||||
applyPendingPanel();
|
||||
setSwitchOut(false);
|
||||
setSwitchIn(true);
|
||||
} else if (animationEvent.animationName === "fadeInDown") {
|
||||
setSwitchIn(false);
|
||||
// End the chat switching state
|
||||
chat.setIsSwitching(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener to document to catch all animation events
|
||||
document.addEventListener("animationend", handleAnimationEnd);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
document.removeEventListener("animationend", handleAnimationEnd);
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat.isSwitching, chat.setIsSwitching, applyPendingPanel]);
|
||||
|
||||
// Load messages when panel changes and animation is not running
|
||||
useEffect(() => {
|
||||
if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return;
|
||||
|
||||
const panelState = chat.activePanel.getState();
|
||||
|
||||
if (panelState.messages.length === 0 && !panelState.isLoading) {
|
||||
chat.activePanel.loadMessages();
|
||||
}
|
||||
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]);
|
||||
|
||||
// Scroll to bottom only when new messages are added
|
||||
useEffect(() => {
|
||||
if (!panelState || chat.isSwitching || switchOut || switchIn) return;
|
||||
|
||||
const currentMessageCount = panelState.messages.length;
|
||||
const previousMessageCount = previousMessageCountRef.current;
|
||||
|
||||
const el = messagesEndRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) {
|
||||
el.scrollIntoView({ behavior: "instant", block: "end" });
|
||||
} else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
|
||||
const id = requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
// Update the previous message count
|
||||
previousMessageCountRef.current = currentMessageCount;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
|
||||
|
||||
return (
|
||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||
<div
|
||||
ref={messagePanelRef}
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
onDragEnter={panel ? (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);
|
||||
} : undefined}
|
||||
onDragOver={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
} : undefined}
|
||||
onDragLeave={panel ? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) setIsDragging(false);
|
||||
} : undefined}
|
||||
onDrop={panel ? (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;
|
||||
} : undefined}>
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={panel?.handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }} />
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<p>
|
||||
<span className={`online-status ${panelState?.online ? "online" : ""}`} />
|
||||
{panelState ? (
|
||||
panelState.online ? "Online" : "Offline"
|
||||
) : (
|
||||
"Выберите чат, чтобы начать переписку"
|
||||
)}
|
||||
</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>
|
||||
) : panelState && panel ? (
|
||||
<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)}
|
||||
onRetryMessage={(id) => panel.retryMessage(id)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{panel && (
|
||||
<>
|
||||
<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; }}
|
||||
messagePanelRef={messagePanelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user