226 Commits

299 changed files with 28967 additions and 9637 deletions
+40
View File
@@ -0,0 +1,40 @@
# Code Cleanup Command
## Overview
Analyze git diff between the specified branch and HEAD (defaults to main if no branch specified) and clean up code quality issues without altering functionality.
## Process
1. **Get diff**: Run `git diff <branch>..HEAD` to see changes
2. **Identify issues**: Look for code quality problems in the diff
3. **Clean up**: Remove only the identified issues
4. **Verify**: Ensure no behavioral changes
## What to Clean Up
- **Debug artifacts**: `console.log()`, `debugger`, `print()` statements
- **Unused code**: Variables, imports, functions, parameters
- **Commented code**: Dead code blocks, TODO comments (unless active)
- **Formatting**: Inconsistent spacing, trailing whitespace
- **Temporary code**: Test values, hardcoded strings meant to be dynamic
- **Redundant code**: Duplicate logic, unnecessary intermediate variables
## What NOT to Touch
- **Functional logic**: Don't change how features work
- **API interfaces**: Keep method signatures intact
- **Configuration**: Don't modify settings or constants
- **Comments**: Keep documentation and explanatory comments
- **Error handling**: Don't remove try-catch blocks or validation
## Safety Rules
- ✅ Only modify code that appears in the git diff
- ✅ Preserve all existing functionality
- ✅ Maintain code readability and structure
- ❌ Don't refactor or optimize beyond cleanup
- ❌ Don't add new features or improvements
- ❌ Don't change variable names or function signatures
## Example
```bash
# If user specifies: "/clean-up main"
git diff main
# Clean only the issues found in this diff
```
+1
View File
@@ -0,0 +1 @@
Analyze my codebase and think how it could be better organized, like a better folder or code structure.
+72
View File
@@ -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)
+25
View File
@@ -0,0 +1,25 @@
Please analyze the recent commits in this repository and help me clean up the commit history. I want you to:
1. First, show me all commits between the target ref and HEAD using: `git log --oneline <base-branch>..HEAD`
2. Identify which commits should be squashed together (like cleanup commits, small fixes, or related changes)
3. For each group of commits you plan to squash, read their full details using: `git show <commit-hash>` to understand what changes they contain
4. Create a git-rebase-todo.txt file with your recommended rebase plan
5. Explain your reasoning for the squashing decisions, including what changes each squashed group contains. Be concise.
6. Stop and ask me if i agree with your plan.
7. Then execute the rebase using these exact commands (replace `<base-branch>` with the target branch/ref):
```bash
export GIT_SEQUENCE_EDITOR="cp git-rebase-todo.txt"
git rebase -i <base-branch>
```
8. If there are conflicts, stop and ask me to fix them.
9. Delete the `git-rebase-todo.txt` file you created.s
**Usage:** You can specify a target branch/ref as an argument. If no argument is provided, stop and ask me for the branch or ref to rebase onto.
Focus on:
- Squashing small cleanup commits into their related feature commits
- Combining related bug fixes
- Keeping meaningful feature commits separate
- Maintaining a clean, logical commit history
Please be conservative - if you're unsure about squashing something, ask me for clarification.
+1
View File
@@ -0,0 +1 @@
Run the command "npm run frontend:typecheck" and fix all errors listed in the command if there's any.
+19
View File
@@ -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 -2
View File
@@ -1,6 +1,5 @@
---
description: Documentation rules
alwaysApply: false
alwaysApply: true
---
When documenting this project, follow these rules:
+54 -8
View File
@@ -4,17 +4,63 @@ alwaysApply: true
When working with this project, follow these rules:
## Core Behavior
- NEVER do anything i didn't ask you for!
- Use double quotes ("") for strings.
- Don't talk like a robot. Behave more like a human.
- Be concise and direct in responses.
- If you're unsure about something, ask for clarification instead of guessing.
## Code Quality & Principles
- Follow DRY, SOLID, YAGNI and KISS principles.
- Do NOT use old, outdated or deprecated APIs and functions.
- Use double quotes ("") for strings consistently.
- Prefer functional components over class components in React.
- Use TypeScript strictly - avoid `any` types unless absolutely necessary.
- DO NOT leave placeholders - ask me when it would be better or implement it fully.
## File Operations
- If possible, try to update files in a single edit when making multiple changes.
- Do NOT "cd" to the project directory.
- NEVER edit/delete/regenerate .env files without explicit permission.
- Don't use rg, it doesn't work anyway.
## Testing & Validation
- Do NOT "test the implementation" when you are done. The only exception is when you
need to typecheck or build the app, in that case:
- To typecheck, run `npm run frontend:typecheck`.
- To build, run `npm run frontend:build`.
- Do NOT execute other commands like "cd".
- If the typecheck passed, there's no need for checking the linter errors.
Do NOT execute other commands like "cd".
- Do NOT "cd" to the project directory.
- If possible, try to update files in a single edit.
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
make it async. The import is `<project>/frontend/src/utils/utils`.
- When you complete your task, remove unused imports if there are any.
## Async Operations
- When you need a delay, use `await delay(millis);` from `@/utils/utils` in an async function. If the current function is not async, make it async.
## Database
- NEVER create database migrations, they are auto-generated.
## Project Structure Awareness
- This is a React/TypeScript browser frontend (desktop is Compose Multiplatform in the Android/KMP repo)
- Uses MDUI components for UI
- Uses Zustand for state management
- Uses use-immer for immutable state updates
- Uses React Router for navigation
- Has WebSocket support for real-time features
- Uses encryption (tweetnacl) for security
## Performance & Efficiency
- Batch tool calls when possible to reduce latency
- Use semantic search before grep when looking for concepts
- Use TODOs for complex multi-step tasks to track progress
## Styling
- Use SCSS modules
- Use nested styles
- Put SCSS into one folder per page
## Animations with Framer Motion
- Don't use variants if they are used only once
## Debug Mode
- When in debug mode and the issue is not yet fixed, ALWAYS end responses with `<reproduction_steps>` containing the steps to reproduce the issue and trigger logging
- When NOT in debug mode or when the issue IS fixed, escape the tag as `&lt;reproduction_steps&gt;` 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 (dont 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.
+7 -4
View File
@@ -1,10 +1,13 @@
---
alwaysApply: true
---
When you work with UI:
1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML.
2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML.
1. Use MDUI components through the wrapper: `@/utils/material`. If the component you want to use is missing in that wrapper,
add it. Do NOT remove anything.
3. The supporting text slot for MDUI lists is "description".
4. When working with lists/sets in states, use the "useImmer" hook.
4. When working with lists/sets in states, use the "useImmer" hook.
5. Do NOT use inline styles in React components if they are static, instead write them in CSS.
Find the appropriate file to put the styles in, or create a new one.
6. In SCSS, for Material Design colors use `$color-dark-<color-name>` variables. For all colors, refer
to `frontend/src/css/_material.scss`.
+170
View File
@@ -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 320 chars: letters, digits, `-`, `_`; password 550 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 **users 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.
+46
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# HTTP API host.
# Example for local backend: http://localhost:8300
VITE_API_BASE_URL=http://localhost:8300
-171
View File
@@ -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
-61
View File
@@ -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
+225 -8
View File
@@ -1,6 +1,6 @@
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,macos,node,osx
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,macos,node,osx
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,osx,node,macos,react,reactnative
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,osx,node,macos,react,reactnative
### macOS ###
# General
@@ -35,6 +35,9 @@ Temporary Items
# iCloud generated files
*.icloud
### FromChat local tools (downloaded LiveKit server binary) ###
.tools/
### Node ###
# Logs
logs
@@ -112,11 +115,18 @@ web_modules/
# dotenv environment variable files
.env
.env.prod
.env.development.local
.env.test.local
.env.production.local
.env.local
# Firebase service account JSON (backend/firebase-cert.json; bind-mounted in docker-compose)
firebase-cert.json
**/firebase-cert.json
firebase-adminsdk.json
**/firebase-adminsdk.json
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
@@ -203,6 +213,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
@@ -353,6 +365,194 @@ poetry.toml
# LSP config files
pyrightconfig.json
### react ###
.DS_*
**/*.backup.*
**/*.back.*
node_modules
*.sublime*
psd
thumb
sketch
### ReactNative ###
# React Native Stack Base
.expo
__generated__
### ReactNative.macOS Stack ###
# General
# Icon must end with two \r
# Thumbnails
# Files that might appear in the root of a volume
# Directories potentially created on remote AFP share
### ReactNative.Android Stack ###
# Gradle files
.gradle/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
### ReactNative.Gradle Stack ###
.gradle
**/build/
!src/**/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Avoid ignore Gradle wrappper properties
!gradle-wrapper.properties
# Cache of project
.gradletasknamecache
# Eclipse Gradle plugin generated files
# Eclipse Core
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
### ReactNative.Xcode Stack ###
## User settings
xcuserdata/
## Xcode 8 and earlier
*.xcscmblueprint
*.xccheckout
### ReactNative.Linux Stack ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### ReactNative.Node Stack ###
# Logs
# Diagnostic reports (https://nodejs.org/api/report.html)
# Runtime data
# Directory for instrumented libs generated by jscoverage/JSCover
# Coverage directory used by tools like istanbul
# nyc test coverage
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
# Bower dependency directory (https://bower.io/)
# node-waf configuration
# Compiled binary addons (https://nodejs.org/api/addons.html)
# Dependency directories
# Snowpack dependency directory (https://snowpack.dev/)
# TypeScript cache
# Optional npm cache directory
# Optional eslint cache
# Optional stylelint cache
# Microbundle cache
# Optional REPL history
# Output of 'npm pack'
# Yarn Integrity file
# dotenv environment variable files
# parcel-bundler cache (https://parceljs.org/)
# Next.js build output
# Nuxt.js build / generate output
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
# vuepress v2.x temp and cache directory
# Docusaurus cache and generated files
# Serverless directories
# FuseBox cache
# DynamoDB Local files
# TernJS port file
# Stores VSCode versions used for testing VSCode extensions
# yarn v2
### ReactNative.Buck Stack ###
buck-out/
.buckconfig.local
.buckd/
.buckversion
.fakebuckversion
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
@@ -372,15 +572,32 @@ pyrightconfig.json
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,macos,node,osx
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,osx,node,macos,react,reactnative
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
data
backend/services/main/.fromchat_instance_id
backend/data
backend/files
.vite
*.db
package-lock.json
dist-electron
backend/migrations/**
!backend/migrations/env.py
!backend/migrations/script.py.mako
backend/alembic/**
!backend/alembic/env.py
!backend/alembic/script.py.mako
!frontend/src/css/lib
**/*.module.scss.d.ts
.cursor/plans
tmp
compliance_keypair.txt
backend/files
*.db-wal
*.db-shm
.deploy-cache
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/
!scripts/offline_python_linux_amd64/venv_linux_amd64/venv/lib
*.module.d.scss.ts
+116
View File
@@ -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) &
+15 -4
View File
@@ -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
}
+4 -56
View File
@@ -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
View File
@@ -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
+65 -78
View File
@@ -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
View File
@@ -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)**
+105 -23
View File
@@ -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)
-33
View File
@@ -1,33 +0,0 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from migration import run_auto_migration
from db import engine
from routes import account, messaging, profile, push
# Инициализация FastAPI
app = FastAPI(title="PixelChat")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # В продакшене замените на нужные домены
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routes
app.include_router(account.router)
app.include_router(messaging.router)
app.include_router(profile.router)
app.include_router(push.router, prefix="/push")
@app.on_event("startup")
def _auto_migrate_on_startup():
try:
run_auto_migration(engine)
except Exception:
# Keep startup resilient; errors should be visible in server logs
pass
-11
View File
@@ -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")
-10
View File
@@ -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)
-38
View File
@@ -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
-45
View File
@@ -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()
-7
View File
@@ -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 *
-92
View File
@@ -1,92 +0,0 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
from traceback import format_exc
import hashlib
from sqlalchemy.engine import Engine
from alembic import command
from alembic.config import Config
from models import Base
from constants import DATABASE_URL
MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
LOCK_FILE = MIGRATIONS_DIR / ".autogen.lock"
SCHEMA_HASH_FILE = MIGRATIONS_DIR / ".schema.hash"
def _ensure_alembic_layout() -> None:
"""Create a minimal Alembic environment if missing."""
versions = MIGRATIONS_DIR / "versions"
versions.mkdir(parents=True, exist_ok=True)
def _alembic_config() -> Config:
cfg = Config()
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Provide a minimal ini section so env.py can read config_ini_section
cfg.config_file_name = "alembic.ini"
cfg.set_section_option("alembic", "sqlalchemy.url", DATABASE_URL)
return cfg
def _model_schema_fingerprint() -> str:
"""Compute a deterministic fingerprint of the current SQLAlchemy model schema."""
parts: list[str] = []
md = Base.metadata
for table in sorted(md.tables.values(), key=lambda t: t.name):
parts.append(f"T:{table.name}")
for col in sorted(table.columns, key=lambda c: c.name):
col_type = str(col.type)
parts.append(f"C:{col.name}:{col_type}:N{int(bool(col.nullable))}")
digest = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
return digest
def run_auto_migration(engine: Engine) -> None:
"""Use Alembic to autogenerate and apply migrations automatically on startup."""
# Ensure env present
_ensure_alembic_layout()
cfg = _alembic_config()
try:
# Upgrade existing migrations (if any) first
command.upgrade(cfg, "head")
except Exception:
print("[alembic] upgrade to head failed:\n" + format_exc())
# Always attempt autogenerate only when model schema fingerprint changed
try:
# Avoid concurrent autogenerate on dev server reloads
try:
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.close(fd)
have_lock = True
except FileExistsError:
have_lock = False
if have_lock:
try:
new_hash = _model_schema_fingerprint()
old_hash = SCHEMA_HASH_FILE.read_text(encoding="utf-8").strip() if SCHEMA_HASH_FILE.exists() else ""
if new_hash != old_hash:
command.revision(cfg, message="auto", autogenerate=True)
command.upgrade(cfg, "head")
# Update stored fingerprint
SCHEMA_HASH_FILE.write_text(new_hash, encoding="utf-8")
finally:
try:
LOCK_FILE.unlink(missing_ok=True)
except Exception:
pass
except Exception:
print("[alembic] autogenerate failed:\n" + format_exc())
-43
View File
@@ -1,43 +0,0 @@
from __future__ import annotations
from sqlalchemy import engine_from_config, pool
from alembic import context
from models import Base
config = context.config
target_metadata = Base.metadata
def _skip_empty_autogenerate(ctx, rev, directives):
# Avoid creating empty migrations when there are no schema changes
if getattr(config, "cmd_opts", None) and getattr(config.cmd_opts, "autogenerate", False):
if directives:
script = directives[0]
if hasattr(script, "upgrade_ops") and script.upgrade_ops.is_empty():
directives[:] = []
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"},
render_as_batch=True,
process_revision_directives=_skip_empty_autogenerate
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(config.get_section(config.config_ini_section) or {}, prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
process_revision_directives=_skip_empty_autogenerate
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-23
View File
@@ -1,23 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '${up_revision}'
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
pass
def downgrade():
pass
-171
View File
@@ -1,171 +0,0 @@
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text
from sqlalchemy.orm import relationship
from datetime import datetime
from db import engine
from pydantic import BaseModel
Base = declarative_base()
# Модели базы данных
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
password_hash = Column(String(200), nullable=False)
profile_picture = Column(String(255), nullable=True)
bio = Column(Text, nullable=True)
online = Column(Boolean, default=False)
last_seen = Column(DateTime, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
messages = relationship("Message", back_populates="author", lazy="select")
class Message(Base):
__tablename__ = "message"
id = Column(Integer, primary_key=True, index=True)
content = Column(Text, nullable=False)
timestamp = Column(DateTime, default=datetime.now)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
is_read = Column(Boolean, default=False)
reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True)
is_edited = Column(Boolean, default=False)
author = relationship("User", back_populates="messages")
reply_to = relationship("Message", remote_side=[id])
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
class MessageFile(Base):
__tablename__ = "message_file"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
path = Column(Text, nullable=False)
name = Column(Text, nullable=False)
message = relationship("Message", back_populates="files")
class CryptoPublicKey(Base):
__tablename__ = "crypto_public_key"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
public_key_b64 = Column(Text, nullable=False)
class CryptoBackup(Base):
__tablename__ = "crypto_backup"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
blob_json = Column(Text, nullable=False)
class DMEnvelope(Base):
__tablename__ = "dm_envelope"
id = Column(Integer, primary_key=True, index=True)
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
iv_b64 = Column(Text, nullable=False)
ciphertext_b64 = Column(Text, nullable=False)
salt_b64 = Column(Text, nullable=False)
iv2_b64 = Column(Text, nullable=False)
wrapped_mk_b64 = Column(Text, nullable=False)
reply_to_id = Column(Integer, nullable=True)
timestamp = Column(DateTime, default=datetime.now)
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
class DMFile(Base):
__tablename__ = "dm_file"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
name = Column(Text, nullable=False)
path = Column(Text, nullable=False)
message = relationship("DMEnvelope", back_populates="files")
class PushSubscription(Base):
__tablename__ = "push_subscription"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
endpoint = Column(Text, nullable=False)
p256dh_key = Column(Text, nullable=False)
auth_key = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Pydantic модели
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
password: str
confirm_password: str
class SendMessageRequest(BaseModel):
content: str
reply_to_id: int | None
class EditMessageRequest(BaseModel):
content: str
class DeleteMessageRequest(BaseModel):
message_id: int
class UpdateBioRequest(BaseModel):
bio: str
class PushSubscriptionRequest(BaseModel):
endpoint: str
keys: dict
class UserProfileResponse(BaseModel):
id: int
username: str
profile_picture: str | None
bio: str | None
online: bool
last_seen: datetime
created_at: datetime
class Config:
from_attributes = True
class MessageResponse(BaseModel):
id: int
content: str
timestamp: datetime
is_author: bool
is_read: bool
username: str
profile_picture: str | None
class Config:
from_attributes = True
# Создание таблиц
Base.metadata.create_all(bind=engine)
-152
View File
@@ -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()
-12
View File
@@ -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
-221
View File
@@ -1,221 +0,0 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
from utils import create_token, get_password_hash, verify_password
from validation import is_valid_password, is_valid_username
router = APIRouter()
def convert_user(user: User) -> dict:
return {
"id": user.id,
"created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(),
"online": user.online,
"username": user.username,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": user.username == OWNER_USERNAME
}
@router.get("/check_auth")
def check_auth(current_user: User = Depends(get_current_user)):
return {
"authenticated": True,
"username": current_user.username,
"admin": current_user.username == OWNER_USERNAME
}
@router.post("/login")
def login(request: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == request.username.strip()).first()
if not user or not verify_password(request.password.strip(), user.password_hash):
raise HTTPException(
status_code=401,
detail="Неверное имя пользователя или пароль"
)
user.online = True
user.last_seen = datetime.now()
db.commit()
token = create_token(user.id, user.username)
return {
"status": "success",
"message": "Login successful",
"token": token,
"user": convert_user(user)
}
@router.post("/register")
def register(request: RegisterRequest, db: Session = Depends(get_db)):
username = request.username.strip()
password = request.password.strip()
confirm_password = request.confirm_password.strip()
# Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
# If owner not yet registered, only allow the owner to register
if not owner_exists and username != OWNER_USERNAME:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Регистрация временно закрыта до регистрации владельца"
)
# Validate input
if not is_valid_username(username):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Имя пользователя должно быть от 3 до 20 символов и не содержать пробелов"
)
if not is_valid_password(password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пароль должен быть от 5 до 50 символов и не содержать пробелов"
)
if password != confirm_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пароли не совпадают"
)
# After owner exists, disallow registering the reserved owner username via public registration
if owner_exists and username == OWNER_USERNAME:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Это имя пользователя зарезервировано"
)
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Это имя пользователя уже занято"
)
hashed_password = get_password_hash(password)
new_user = User(
username=username,
password_hash=hashed_password,
online=True,
last_seen=datetime.now()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return {
"status": "success",
"message": "Регистрация прошла успешно Теперь вы можете войти."
}
@router.get("/crypto/public-key")
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
return {"publicKey": row.public_key_b64 if row else None}
@router.post("/crypto/public-key")
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
pk = payload.get("publicKey")
if not pk:
raise HTTPException(status_code=400, detail="publicKey required")
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
if row:
row.public_key_b64 = pk
else:
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
db.add(row)
db.commit()
return {"status": "ok"}
@router.get("/crypto/backup")
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
return {"blob": row.blob_json if row else None}
@router.post("/crypto/backup")
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
blob = payload.get("blob")
if not blob:
raise HTTPException(status_code=400, detail="blob required")
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
if row:
row.blob_json = blob
else:
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
db.add(row)
db.commit()
return {"status": "ok"}
@router.delete("/admin/user/{user_id}")
def delete_user_as_owner(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Only owner can delete users
if current_user.username != OWNER_USERNAME:
raise HTTPException(status_code=403, detail="Only owner can perform this action")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting the owner account via API
if user.username == OWNER_USERNAME:
raise HTTPException(status_code=400, detail="Cannot delete owner account")
# Manually delete user's messages to satisfy FK constraints
from models import Message # local import to avoid circular
db.query(Message).filter(Message.user_id == user.id).delete()
db.delete(user)
db.commit()
return {"status": "success", "deleted_user_id": user_id}
@router.get("/logout")
def logout(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
current_user.online = False
current_user.last_seen = datetime.now()
db.commit()
return {
"status": "success",
"message": "Logged out successfully"
}
@router.get("/users")
def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.username.asc()).all()
return {
"users": [
convert_user(u) for u in users if u.id != current_user.id
]
}
@router.get("/crypto/public-key/of/{user_id}")
def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
return {"publicKey": row.public_key_b64 if row else None}
-745
View File
@@ -1,745 +0,0 @@
from datetime import datetime
import logging
from pathlib import Path
import os
import re
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile
from push_service import push_service
from PIL import Image
import io
import json
from better_profanity import profanity as _bp
router = APIRouter()
logger = logging.getLogger("uvicorn.error")
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
FILES_BASE_DIR = Path("data/uploads/files")
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
def convert_message(msg: Message) -> dict:
return {
"id": msg.id,
"content": msg.content,
"timestamp": msg.timestamp.isoformat(),
"is_read": msg.is_read,
"is_edited": msg.is_edited,
"username": msg.author.username,
"profile_picture": msg.author.profile_picture,
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
"files": [
{
"path": f"/api/uploads/files/normal/{Path(f.path).name}",
"id": f.id,
"name": f.name,
"message_id": f.message_id
}
for f in (msg.files or [])
]
}
# для тех кто читает этот код я эти маты не писал
# мат писал ии а я сам не матерюсь))
# - denis0001-dev
_RU_EXTRA = [
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
"долбоёб", "долбоеб", "дебил"
]
_bp.load_censor_words()
_bp.add_censor_words(_RU_EXTRA)
# Additional phrase-level filters (case-insensitive)
_PHRASE_PATTERNS: list[re.Pattern] = [
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
]
def _mask_span(text: str, start: int, end: int) -> str:
return text[:start] + ("\\*" * (end - start)) + text[end:]
def _apply_phrase_filters(text: str) -> str:
result = text
for pattern in _PHRASE_PATTERNS:
# Replace all occurrences; iterate until no more matches to avoid overlapping issues
while True:
m = pattern.search(result)
if not m:
break
result = _mask_span(result, m.start(), m.end())
return result
def filter_profanity(text: str) -> str:
preprocessed = _apply_phrase_filters(text)
return _bp.censor(preprocessed, censor_char="\\*")
@router.post("/send_message")
async def send_message(
request: SendMessageRequest | None = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
# Optional multipart form support
payload: str | None = Form(default=None),
files: list[UploadFile] = File(default=[]),
):
# If payload is provided, prefer it for multipart requests
if payload and request is None:
# Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null}
try:
obj = json.loads(payload)
content = obj.get("content", "")
reply_to_id = obj.get("reply_to_id", None)
request = SendMessageRequest(content=content, reply_to_id=reply_to_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid payload JSON")
if request.reply_to_id:
# Check if the message being replied to exists
original_message = db.query(Message).filter(Message.id == request.reply_to_id).first()
if not original_message:
raise HTTPException(status_code=404, detail="Original message not found")
if not request.content.strip():
raise HTTPException(
status_code=400,
detail="No content provided"
)
# Apply profanity filter before storing
filtered_content = filter_profanity(request.content.strip())
if len(filtered_content) > 4096:
raise HTTPException(
status_code=400,
detail="Message too long"
)
new_message = Message(
content=filtered_content,
user_id=current_user.id,
reply_to_id=request.reply_to_id,
timestamp=datetime.now()
)
db.add(new_message)
db.commit()
db.refresh(new_message)
# Handle files if provided (normal, not encrypted)
if files:
total_size = 0
for up in files:
# Accumulate size if available
if hasattr(up, "size") and up.size is not None:
total_size += int(up.size)
else:
# If size unknown, read into memory to determine
data = await up.read()
up.file.seek(0)
total_size += len(data)
if total_size > MAX_TOTAL_SIZE:
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
for up in files:
# Sanitize filename
original_name = Path(up.filename or "file").name
ext = Path(original_name).suffix.lower()
uid = uuid.uuid4().hex
safe_name = f"{new_message.id}_{uid}{ext or ''}"
out_path = FILES_NORMAL_DIR / safe_name
content = await up.read()
up.file.seek(0)
# If image, try lossless optimization
try:
if up.content_type and up.content_type.startswith("image/"):
image = Image.open(io.BytesIO(content))
img_format = image.format or ("PNG" if ext == ".png" else "JPEG")
buf = io.BytesIO()
save_kwargs = {"optimize": True}
if img_format.upper() == "JPEG":
# Use quality=95 with optimize to keep high quality (not truly lossless but near)
save_kwargs["quality"] = 95
image.save(buf, format=img_format, **save_kwargs)
buf.seek(0)
content = buf.read()
except Exception:
# Fallback to original content
pass
with open(out_path, "wb") as f:
f.write(content)
mf = MessageFile(
message_id=new_message.id,
name=original_name,
path=str(out_path)
)
db.add(mf)
db.commit()
db.refresh(new_message)
# Send push notifications for public messages
try:
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
except Exception as e:
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
# Realtime broadcast for HTTP uploads as well
try:
from .messaging import messagingManager # self import safe here
await messagingManager.broadcast({
"type": "newMessage",
"data": convert_message(new_message)
})
except Exception:
pass
return {"status": "success", "message": convert_message(new_message)}
@router.get("/get_messages")
async def get_messages(db: Session = Depends(get_db)):
messages = db.query(Message).order_by(Message.timestamp.asc()).all()
messages_data = []
for msg in messages:
messages_data.append(convert_message(msg))
return {
"status": "success",
"messages": messages_data
}
@router.post("/dm/send")
async def dm_send(
payload: dict | None = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
# Multipart support
dm_payload: str | None = Form(default=None),
files: list[UploadFile] = File(default=[]),
fileNames: str | None = Form(default=None), # JSON array of filenames corresponding to files
):
if dm_payload and payload is None:
try:
payload = json.loads(dm_payload)
except Exception:
raise HTTPException(status_code=400, detail="Invalid dm_payload JSON")
if payload is None:
raise HTTPException(status_code=400, detail="Missing payload")
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
for key in required:
if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}")
env = DMEnvelope(
sender_id=current_user.id,
recipient_id=int(payload["recipientId"]),
iv_b64=payload["iv"],
ciphertext_b64=payload["ciphertext"],
salt_b64=payload["salt"],
iv2_b64=payload["iv2"],
wrapped_mk_b64=payload["wrappedMk"],
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
)
db.add(env)
db.commit()
db.refresh(env)
# Save encrypted files if any (no processing)
if files:
# Validate total size
total_size = 0
for file in files:
if hasattr(file, "size") and file.size is not None:
total_size += int(file.size)
else:
data = await file.read()
file.file.seek(0)
total_size += len(data)
if total_size > MAX_TOTAL_SIZE:
raise HTTPException(status_code=400, detail="Total attachments size exceeds 4GB")
names: list[str] = []
if fileNames:
try:
decoded = json.loads(fileNames)
if isinstance(decoded, list):
names = [str(x) for x in decoded]
except Exception:
names = []
for i, file in enumerate(files):
provided = names[i] if i < len(names) else None
# Sanitize provided name to avoid path traversal
if provided and not re.match(r"^[A-Za-z0-9._-]{1,200}$", provided):
provided = None
original_name = provided or Path(file.filename or "file").name
# Save using provided/original name to allow client to reference path directly
safe_name = uid = uuid.uuid4().hex
out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}"
out_path = FILES_ENCRYPTED_DIR / out_name
content = await file.read()
with open(out_path, "wb") as f:
f.write(content)
# Save DM file record
df = DMFile(
message_id=env.id,
sender_id=current_user.id,
recipient_id=env.recipient_id,
path=f"/api/uploads/files/encrypted/{out_name}",
name=original_name
)
db.add(df)
db.commit()
# Send push notification for DM
try:
await push_service.send_dm_notification(db, env, current_user)
except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
# Realtime notify both users for HTTP requests
try:
payload_ws = {
"type": "dmNew",
"data": {
"id": env.id,
"senderId": env.sender_id,
"recipientId": env.recipient_id,
"iv": env.iv_b64,
"ciphertext": env.ciphertext_b64,
"salt": env.salt_b64,
"iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64,
"timestamp": env.timestamp.isoformat(),
"replyToId": env.reply_to_id,
}
}
await messagingManager.send_to_user(env.recipient_id, payload_ws)
await messagingManager.send_to_user(env.sender_id, payload_ws)
except Exception:
pass
return {"status": "ok", "id": env.id}
def convert_envelopes(envs: list[DMEnvelope]):
return {
"status": "ok",
"messages": [
{
"id": e.id,
"senderId": e.sender_id,
"recipientId": e.recipient_id,
"iv": e.iv_b64,
"ciphertext": e.ciphertext_b64,
"salt": e.salt_b64,
"iv2": e.iv2_b64,
"wrappedMk": e.wrapped_mk_b64,
"timestamp": e.timestamp.isoformat(),
"files": [{"name": file.name, "path": file.path, "id": file.id} for file in e.files]
}
for e in envs
]
}
@router.get("/dm/fetch")
async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id)
if since:
q = q.filter(DMEnvelope.id > since)
return convert_envelopes(q.order_by(DMEnvelope.id.asc()).all())
@router.get("/dm/history/{other_user_id}")
async def dm_history(other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return convert_envelopes(
db.query(DMEnvelope)
.filter(
((DMEnvelope.sender_id == current_user.id) & (DMEnvelope.recipient_id == other_user_id))
| ((DMEnvelope.sender_id == other_user_id) & (DMEnvelope.recipient_id == current_user.id))
)
.order_by(DMEnvelope.id.asc())
.all()
)
@router.put("/edit_message/{message_id}")
async def edit_message(
message_id: int,
request: EditMessageRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
message = db.query(Message).filter(Message.id == message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
if message.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only edit your own messages")
if not request.content.strip():
raise HTTPException(status_code=400, detail="Message content cannot be empty")
message.content = request.content.strip()
message.is_edited = True
db.commit()
db.refresh(message)
return {"status": "success", "message": convert_message(message)}
@router.delete("/delete_message/{message_id}")
async def delete_message(
message_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
message = db.query(Message).filter(Message.id == message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
# Allow owner to delete any message
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only delete your own messages")
db.delete(message)
db.commit()
return {"status": "success", "message_id": message_id}
class MessaggingSocketManager:
def __init__(self) -> None:
self.connections: list[WebSocket] = []
self.user_by_ws: dict[WebSocket, int] = {}
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException):
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
async def handle_connection(self, websocket: WebSocket, db: Session):
while True:
data = await websocket.receive_json()
type = data["type"]
def get_current_user_inner() -> User | None:
if data["credentials"]:
return get_current_user(
HTTPAuthorizationCredentials(
scheme=data["credentials"]["scheme"],
credentials=data["credentials"]["credentials"]
),
db
)
else:
return None
if type == "ping":
try:
current_user = get_current_user_inner()
if current_user:
self.user_by_ws[websocket] = current_user.id
else:
await websocket.send_json({
"type": "ping",
"data": {
"status": "error",
"error": {
"detail": "Failed to authorize",
"code": 401
}
}
})
except HTTPException:
await websocket.send_json({
"type": "ping",
"data": {
"status": "error",
"error": {
"detail": "Failed to authorize",
"code": 401
}
}
})
await websocket.send_json({"type": "ping", "data": {"status": "success"}})
elif type == "getMessages":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
self.user_by_ws[websocket] = current_user.id
await websocket.send_json({"type": type, "data": await get_messages(current_user, db)})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "sendMessage":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
self.user_by_ws[websocket] = current_user.id
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
response = await send_message(request, current_user, db, None, [])
await self.broadcast({
"type": "newMessage",
"data": response["message"]
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "dmSend":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
self.user_by_ws[websocket] = current_user.id
payload = data["data"]
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
for key in required:
if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}")
env = DMEnvelope(
sender_id=current_user.id,
recipient_id=int(payload["recipientId"]),
iv_b64=payload["iv"],
ciphertext_b64=payload["ciphertext"],
salt_b64=payload["salt"],
iv2_b64=payload["iv2"],
wrapped_mk_b64=payload["wrappedMk"],
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
)
db.add(env)
db.commit()
db.refresh(env)
payload = {
"type": "dmNew",
"data": {
"id": env.id,
"senderId": env.sender_id,
"recipientId": env.recipient_id,
"iv": env.iv_b64,
"ciphertext": env.ciphertext_b64,
"salt": env.salt_b64,
"iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64,
"timestamp": env.timestamp.isoformat(),
"replyToId": env.reply_to_id,
}
}
# Send push notification for DM
try:
await push_service.send_dm_notification(db, env, current_user)
except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
await self.send_to_user(env.recipient_id, payload);
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
await self.send_to_user(env.sender_id, payload);
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "editMessage":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
message_id = data["data"]["message_id"]
request: EditMessageRequest = EditMessageRequest.model_validate(data["data"])
response = await edit_message(message_id, request, current_user, db)
await self.broadcast({
"type": "messageEdited",
"data": response["message"]
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "dmEdit":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
payload = data["data"]
env_id = int(payload["id"])
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
if not env:
raise HTTPException(status_code=404, detail="DM not found")
if env.sender_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only edit your own messages")
# Replace ciphertext and iv
env.iv_b64 = payload["iv"]
env.ciphertext_b64 = payload["ciphertext"]
env.iv2_b64 = payload["iv2"]
env.wrapped_mk_b64 = payload["wrappedMk"]
env.salt_b64 = payload["salt"]
db.commit()
db.refresh(env)
payload_ws = {
"type": "dmEdited",
"data": {
"id": env.id,
"iv": env.iv_b64,
"ciphertext": env.ciphertext_b64,
"iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64,
"salt": env.salt_b64,
"timestamp": env.timestamp.isoformat(),
}
}
await self.send_to_user(env.recipient_id, payload_ws)
await self.send_to_user(env.sender_id, payload_ws)
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "dmDelete":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
payload = data["data"]
env_id = int(payload["id"])
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
if not env:
raise HTTPException(status_code=404, detail="DM not found")
if env.sender_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only delete your own messages")
db.delete(env)
db.commit()
payload_ws = {
"type": "dmDeleted",
"data": {
"id": env_id,
"senderId": current_user.id,
"recipientId": payload.get("recipientId")
}
}
await self.send_to_user(env.recipient_id, payload_ws)
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}})
await self.send_to_user(env.sender_id, payload_ws)
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "deleteMessage":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
message_id = data["data"]["message_id"]
response = await delete_message(message_id, current_user, db)
await self.broadcast({
"type": "messageDeleted",
"data": {"message_id": message_id}
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
else:
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None):
try:
await websocket.close(code=code, reason=message)
finally:
self.connections.remove(websocket)
async def connect(self, websocket: WebSocket, db: Session):
await websocket.accept()
self.connections.append(websocket)
try:
await self.handle_connection(websocket, db)
except WebSocketDisconnect as e:
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
finally:
self.connections.remove(websocket)
if websocket in self.user_by_ws:
del self.user_by_ws[websocket]
async def broadcast(self, message: dict):
for websocket in self.connections:
await websocket.send_json(message)
async def send_to_user(self, user_id: int, message: dict):
for websocket in self.connections:
if self.user_by_ws.get(websocket) == user_id:
await websocket.send_json(message)
messagingManager = MessaggingSocketManager()
@router.websocket("/chat/ws")
async def chat_websocket(
websocket: WebSocket,
db: Session = Depends(get_db)
):
await messagingManager.connect(websocket, db)
# File serving endpoints
@router.get("/uploads/files/normal/{filename}")
async def get_file_normal(filename: str):
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
raise HTTPException(status_code=400, detail="Invalid file name")
path = FILES_NORMAL_DIR / filename
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(str(path))
@router.get("/uploads/files/encrypted/{filename}")
async def get_file_encrypted(filename: str, current_user: User = Depends(get_current_user)):
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
raise HTTPException(status_code=400, detail="Invalid file name")
path = FILES_ENCRYPTED_DIR / filename
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
match = re.match(r"^(\d+)_(\d+)_(\d+)_.*$", path.resolve().name)
if match:
sender_id = int(match.group(1))
recipient_id = int(match.group(2))
if not current_user.id in [sender_id, recipient_id]:
raise HTTPException(403)
else:
raise HTTPException(500)
return FileResponse(str(path))
-205
View File
@@ -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
)
-46
View File
@@ -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))
-33
View File
@@ -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")
-16
View File
@@ -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
View File
@@ -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
-33
View File
@@ -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
-25
View File
@@ -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
-54
View File
@@ -1,54 +0,0 @@
services:
backend:
build:
dockerfile: deployment/Dockerfile.backend
context: ..
environment:
PORT: 8300
JWT_SECRET: ${JWT_SECRET}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
volumes:
- "data:/app/data"
develop:
watch:
- action: sync+restart
path: ../backend
target: /app
- action: rebuild
path: ../backend/requirements.txt
networks:
- main
frontend:
build:
dockerfile: deployment/frontend/Dockerfile
context: ..
environment:
PORT: 8301
BACKEND_HOST: http://backend:8300
ports:
- "8301:8301"
depends_on:
- backend
develop:
watch:
- action: rebuild
path: ../frontend
- action: sync+restart
path: server.js
target: /server/server.js
- action: rebuild
path: package.json
networks:
- main
- default
volumes:
data:
name: fromchat-data
networks:
main:
driver: bridge
internal: true # isolate from the outside world
-32
View File
@@ -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
-47
View File
@@ -1,47 +0,0 @@
# 1. Build the frontend
FROM node:24 AS frontend
# 1.1. Install npm dependencies
WORKDIR /app
COPY package.json .
RUN --mount=type=cache,target=/root/.npm \
npm install --ignore-scripts
# 1.2. Build
COPY frontend frontend
RUN npm run frontend:build
# 2. Build the static file server
FROM node:24 AS server
# 2.1. Install npm dependencies
WORKDIR /server
COPY deployment/frontend/package.json .
RUN --mount=type=cache,target=/root/.npm \
npm install
# 2.2. Copy the code
COPY deployment/frontend/ .
# 3. Put it all together
FROM node:24-slim
# 3.1. Non-root user
RUN useradd -u 1001 app && \
mkdir -p /app && \
chown -R app /app && \
mkdir /server && \
chown -R app /server
USER app
# 3.1. Frontend static files
WORKDIR /app
COPY --from=frontend --chown=app /app/frontend/build/normal/dist .
# 3.2. Static file server
WORKDIR /server
COPY --from=server --chown=app /server .
# 4. Final command
ENV STATIC_FILE_PATH=/app
ENTRYPOINT ["npm", "run", "start"]
-12
View File
@@ -1,12 +0,0 @@
{
"name": "frontend-server",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.1.0",
"http-proxy-middleware": "^3.0.5"
}
}
-22
View File
@@ -1,22 +0,0 @@
// server.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const path = require('path');
const app = express();
const port = process.env.PORT || 3000;
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
const file_path = process.env.STATIC_FILE_PATH || ".";
app.use('/api', createProxyMiddleware({
target: backendHost,
changeOrigin: true,
pathRewrite: { '^/api': '' },
ws: true
}));
app.use(express.static(path.resolve(file_path)));
app.listen(port, () => {
console.log(`Server launched на http://localhost:${port}`);
});
-25
View File
@@ -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
}
}
-66
View File
@@ -1,66 +0,0 @@
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
import path from "node:path";
import { NotificationShowOptions } from '../electron';
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",
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
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;
});
});
-11
View File
@@ -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);
-45
View File
@@ -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;
-171
View File
@@ -1,171 +0,0 @@
import { API_BASE_URL } from "../core/config";
import { getAuthHeaders } from "../auth/api";
import { ecdhSharedSecret, deriveWrappingKey } from "../utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/symmetric";
import { randomBytes } from "../utils/crypto/kdf";
import { getCurrentKeys } from "../auth/crypto";
import { request } from "../core/websocket";
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "../core/types";
import { b64, ub64 } from "../utils/utils";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Obtain the key
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
}
export async function fetchUsers(token: string): Promise<User[]> {
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
if (!res.ok) return [];
const data = await res.json();
return data.users || [];
}
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
if (!res.ok) return null;
const data = await res.json();
return data.publicKey;
}
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
return data.messages || [];
}
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Encryption key
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
const payload: SendDMRequest = {
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
};
if (replyToId) payload.replyToId = replyToId;
await request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: payload
});
}
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk);
const form = new FormData();
const names: string[] = [];
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
}
for (const f of files) {
// Encrypt file with same mk
const data = new Uint8Array(await f.arrayBuffer());
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
const serverName = f.name; // server uses provided name
names.push(serverName);
form.append("files", new File([blob], serverName));
}
form.append("fileNames", JSON.stringify(names));
// Merge files metadata into plaintext JSON and encrypt
let obj: DmEncryptedJSON;
try {
obj = JSON.parse(plaintextJson);
} catch {
obj = { type: "text", data: { content: String(plaintextJson) } };
}
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
form.append("dm_payload", JSON.stringify({
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
} satisfies BaseDmEnvelope));
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, false),
body: form
});
}
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({
type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken },
data: {
id,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt)
}
} as DMEditRequest);
}
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
-19
View File
@@ -1,19 +0,0 @@
import type { Headers } from "../core/types";
/**
* Generates authentication headers for API requests
* @param {boolean} json - Whether to include JSON content type header
* @returns {Headers} Headers object with authentication and content type
*/
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
const headers: Headers = {};
if (json) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
-125
View File
@@ -1,125 +0,0 @@
import { API_BASE_URL } from "../core/config";
import { getAuthHeaders } from "./api";
import { generateX25519KeyPair } from "../utils/crypto/asymmetric";
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../utils/crypto/backup";
import { b64, ub64 } from "../utils/utils";
import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: Uint8Array | null = null;
async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
if (!res.ok) return null;
const data = await res.json();
if (!data?.publicKey) return null;
return ub64(data.publicKey);
}
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
const payload: UploadPublicKeyRequest = {
publicKey: b64(publicKey)
}
const headers = getAuthHeaders(token, true);
await fetch(`${API_BASE_URL}/crypto/public-key`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
}
async function fetchBackupBlob(token: string): Promise<string | null> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET",
headers
});
if (res.ok) {
const response: BackupBlob = await res.json();
return response.blob;
} else {
return null;
}
}
async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
const payload: BackupBlob = { blob: blobJson }
const headers = getAuthHeaders(token, true);
await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
}
export interface UserKeyPairMemory {
publicKey: Uint8Array;
privateKey: Uint8Array;
}
export function getCurrentKeys(): UserKeyPairMemory | null {
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
return null;
}
function saveKeys(
publicKey: Uint8Array<ArrayBufferLike>,
privateKey: Uint8Array<ArrayBufferLike>
) {
const encodedPublicKey = b64(publicKey);
const encodedPrivateKey = b64(privateKey);
localStorage.setItem("publicKey", encodedPublicKey);
localStorage.setItem("privateKey", encodedPrivateKey);
}
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
// Try to restore from backup
const blobJson = await fetchBackupBlob(token);
if (blobJson) {
const blob = decodeBlob(blobJson);
const bundle = await decryptBackupWithPassword(password, blob);
currentPrivateKey = bundle.privateKey;
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
const serverPub = await fetchPublicKey(token);
if (serverPub) {
currentPublicKey = serverPub;
} else {
// We don't have the corresponding public key from server; regenerate pair to resync
const pair = generateX25519KeyPair();
currentPublicKey = pair.publicKey;
currentPrivateKey = pair.privateKey;
await uploadPublicKey(currentPublicKey, token);
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
await uploadBackupBlob(encodeBlob(newBlob), token);
}
saveKeys(currentPublicKey!, currentPrivateKey!);
return {
publicKey: currentPublicKey!,
privateKey: currentPrivateKey!
};
}
// First-time setup: generate keys and upload
const pair = generateX25519KeyPair();
currentPublicKey = pair.publicKey;
currentPrivateKey = pair.privateKey;
await uploadPublicKey(currentPublicKey, token);
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
await uploadBackupBlob(encodeBlob(encBlob), token);
saveKeys(pair.publicKey, pair.privateKey);
return pair;
}
export function restoreKeys() {
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
}
-32
View File
@@ -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;
-12
View File
@@ -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();
-108
View File
@@ -1,108 +0,0 @@
/**
* @fileoverview WebSocket connection management for real-time chat
* @description Handles WebSocket connections, message processing, and auto-reconnection
* @author Cursor
* @version 1.0.0
*/
import { API_WS_BASE_URL } from "./config";
import type { WebSocketMessage } from "./types";
import { delay } from "../utils/utils";
/**
* Creates a new WebSocket connection to the chat server
* @returns {WebSocket} New WebSocket instance
* @private
*/
function create(): WebSocket {
let prefix = "ws://";
if (location.protocol.includes("https")) {
prefix = "wss://";
}
return new WebSocket(`${prefix}${API_WS_BASE_URL}/chat/ws`);
}
/**
* Global WebSocket instance
* @type {WebSocket}
*/
export let websocket: WebSocket = create();
/**
* Global WebSocket message handler reference
* This will be set by the active panel to handle incoming messages
*/
let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null;
/**
* Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages
*/
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
globalMessageHandler = handler;
}
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => {
function requestInner() {
let listener: ((e: MessageEvent) => void) | null = null;
listener = (e) => {
resolve(JSON.parse(e.data));
websocket.removeEventListener("message", listener!);
}
websocket.addEventListener("message", listener);
websocket.send(JSON.stringify(payload))
setTimeout(() => reject("Request timed out"), 10000);
}
if (websocket.readyState == 0) {
websocket.addEventListener("open", requestInner);
setTimeout(() => reject("Request timed out"), 10000);
} else {
requestInner();
}
})
}
/**
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
* If it fails, tries again in an endless loop until the connection is established
* again.
*
* @private
*/
async function onError() {
console.warn("WebSocket disconnected, retrying in 3 seconds...");
await delay(3000);
websocket = create();
let listener: () => void | null;
listener = () => {
console.log("WebSocket successfully reconnected!");
websocket.removeEventListener("open", listener);
}
websocket.addEventListener("open", listener);
websocket.addEventListener("error", onError);
}
// --------------
// Initialization
// --------------
websocket.addEventListener("message", (e) => {
try {
const response: WebSocketMessage<any> = JSON.parse(e.data);
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
}
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
});
websocket.addEventListener("error", onError);
-15
View File
@@ -1,15 +0,0 @@
/**
* @fileoverview Electron-specific code
* @description This module initializes Electron-specific functionality.
* @author denis0001-dev
* @version 1.0.0
*/
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");
}
-50
View File
@@ -1,50 +0,0 @@
@use "common/colors" as *;
@use "common/material" as *;
.auth-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
padding: 2rem;
background-color: $color-dark-surface;
.auth-card {
background-color: $color-dark-surface-container;
color: $color-dark-on-surface;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 450px;
overflow: hidden;
}
.auth-header {
margin: 0;
padding: 16px;
padding-bottom: 0;
text-align: center;
h2 {
font-size: 1.8rem;
margin: 0;
margin-bottom: 0.5rem;
align-items: center;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: center;
}
}
.auth-body {
padding: 25px;
padding-bottom: 16px;
form {
display: flex;
flex-direction: column;
gap: 10px;
}
}
}
-607
View File
@@ -1,607 +0,0 @@
@use "common/colors" as *;
@use "common/material" as *;
#chat-interface {
height: 100%;
.header {
display: flex;
background-color: $color-dark-surface-container;
color: white;
padding: 16px 16px;
justify-content: end;
width: fit-content;
z-index: 1000;
position: absolute;
top: 0;
right: 0;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.logo {
font-size: 1.8rem;
font-weight: 700;
display: flex;
align-items: center;
}
#logouts {
display: none;
list-style: none;
gap: 10px;
li {
a {
color: white;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
padding: 10px;
border-radius: 10px;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
}
}
}
}
.chat-container {
display: flex;
width: 100%;
flex-direction: column;
.chat-main {
flex-grow: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
.chat-header {
padding: 16px;
background: $color-dark-surface-container;
display: flex;
align-items: center;
box-shadow: black 0 0 20px;
.chat-header-avatar {
width: 45px;
height: 45px;
border-radius: 20%;
object-fit: cover;
margin-right: 1rem;
}
.chat-header-info {
display: flex;
.info-chat {
display: flex;
flex-direction: column;
h4 {
font-size: 1.1rem;
margin: 0 0 0.2rem;
}
p {
margin: 0;
font-size: 0.8rem;
color: #718096;
}
}
.online-status {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: $success;
margin-right: 5px;
}
a {
display: flex;
flex-direction: row;
text-decoration: none;
color: white;
justify-content: end;
padding: 0;
margin: 0;
position: absolute;
right: 2%;
top: 2%;
&:hover {
border: none;
}
}
}
}
.quote.contextual-content > .quote-inner {
display: flex;
flex-direction: column;
gap: 4px;
.reply-username {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.reply-text {
overflow: hidden;
text-overflow: ellipsis;
}
}
.chat-messages {
flex: 1;
padding: 1rem;
overflow-y: auto;
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 20px;
}
.message {
margin-bottom: 1rem;
max-width: 70%;
position: relative;
width: fit-content;
display: flex;
align-items: flex-end;
gap: 8px;
.message-inner {
border-radius: 12px;
position: relative;
word-wrap: break-word;
overflow-wrap: anywhere;
word-break: break-word;
width: fit-content;
max-width: 100%;
display: inline-block;
.message-profile-pic {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-bottom: 4px;
margin: 8px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
.message-username {
font-weight: 600;
margin-bottom: 0.3rem;
font-size: 0.9rem;
transition: color 0.2s ease;
margin: 8px;
&:hover {
color: $color-dark-primary;
text-decoration: underline;
}
}
.message-content {
word-wrap: break-word;
margin: 10px 10px 0 10px;
white-space: pre-wrap;
> p:first-child {
margin-block-start: 0;
}
> p:last-child {
margin-block-end: 0;
}
}
.quote.reply-preview {
user-select: none;
margin: 10px;
}
.message-attachments {
padding: 5px 0 0 0;
overflow: hidden;
.attachment {
a {
text-decoration: none;
}
.attachement-image {
max-width: 200px;
border-radius: 8px;
cursor: pointer;
margin-left: 3px;
margin-right: 3px;
margin-bottom: 3px;
&:last-child {
margin-bottom: 0;
}
&.loading {
filter: blur(10px);
transition: filter 200ms ease;
}
}
.attachement-image.placeholder {
background: $color-dark-surface-container-highest;
pointer-events: none;
}
.image-wrapper {
position: relative;
display: inline-block;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.08);
backdrop-filter: blur(6px);
border-radius: 8px;
}
.preload-image {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.with-icon-gap {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
}
.message-time {
font-size: 0.7rem;
color: $color-dark-on-surface-variant;
margin-top: 0.3rem;
text-align: right;
user-select: none;
margin: 4px 8px 8px 8px;
}
}
&.received .message-inner {
background-color: $color-dark-surface-container;
border-top-left-radius: 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
&.sent {
margin-left: auto;
flex-direction: row-reverse;
.message-inner {
background-color: $color-dark-primary-container;
color: $color-dark-on-primary-container;
border-top-right-radius: 5px;
}
.message-time {
color: $color-dark-on-primary-container;
}
}
}
}
.file-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
backdrop-filter: blur(20px);
.file-overlay-wrapper {
border-radius: 30px;
outline: 3px dashed $color-dark-primary;
outline-offset: -20px;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
.file-overlay-inner {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: rgba(18, 18, 18, 0.8);
border: 1px solid $color-dark-surface-container-high;
border-radius: 12px;
color: $color-dark-on-surface;
mdui-icon {
color: $color-dark-primary;
}
}
}
}
.chat-input-wrapper {
position: relative;
margin: 0 20px 20px 20px;
&::before {
$height: 20px;
content: '';
position: absolute;
top: -$height;
left: 0;
right: 0;
width: 100%;
height: $height;
background: linear-gradient(
0deg,
$color-dark-surface,
rgba(255, 255, 255, 0),
);
}
.input-group {
display: flex;
background-color: $color-dark-surface-container;
border-radius: 30px;
flex-direction: column;
.contextual-preview {
padding: 12px 16px 0 16px;
display: flex;
align-items: flex-start;
gap: 16px;
mdui-icon {
align-self: center;
box-sizing: content-box;
}
.reply-cancel {
margin-left: auto;
}
}
.attachments-preview {
align-items: center;
.attachments-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.chat-input {
flex: 1;
display: flex;
flex-direction: row;
.message-input {
flex: 1;
padding: 20px 20px;
padding-right: 0;
border: none;
border-radius: 25px;
font-size: 1rem;
outline: none;
caret-color: $color-dark-primary;
color: $color-dark-on-surface;
resize: none;
font: inherit;
font-size: 13pt;
height: 100%;
width: 100%;
}
.buttons {
align-self: flex-end;
display: flex;
flex-direction: row;
align-items: center;
.send-btn {
margin: 10px;
width: 50px;
height: 50px;
border-radius: 50%;
background-color: $color-dark-primary;
color: $color-dark-on-primary;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.25s ease;
align-self: flex-end;
@include hoverStateLayer($background: $color-dark-primary);
}
}
}
}
}
}
}
}
.message-profile-pic {
img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
&.loading {
opacity: 0.6;
cursor: default;
}
}
}
.message-username {
&.loading {
opacity: 0.6;
cursor: default;
}
}
.context-menu {
position: fixed;
background: $color-dark-surface;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
padding: 0.5rem 0;
min-width: 160px;
z-index: 1000;
&.entering {
animation: fadeInDown 0.2s ease forwards;
}
&.entering-left {
animation: fadeInLeft 0.2s ease forwards;
}
&.entering-up {
animation: fadeInUp 0.2s ease forwards;
}
&.entering-up-left {
animation: fadeInUpLeft 0.2s ease forwards;
}
&.closing {
animation: fadeOutUp 0.2s ease forwards;
}
&.closing-left {
animation: fadeOutRight 0.2s ease forwards;
}
&.closing-up {
animation: fadeOutDown 0.2s ease forwards;
}
&.closing-up-left {
animation: fadeOutDownRight 0.2s ease forwards;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
color: $color-dark-on-surface;
font-size: 0.9rem;
transition: background-color 0.2s ease;
&:hover {
background-color: $color-dark-surface-container;
}
.material-symbols {
font-size: 1.1rem;
color: $color-dark-on-surface-variant;
}
}
}
// Fullscreen Image Viewer
.fullscreen-image-overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
z-index: 9999;
opacity: 1;
transition: opacity 0.3s ease;
&.closing {
opacity: 0;
}
.fullscreen-animated-image {
position: absolute;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease;
}
.fullscreen-controls {
position: absolute;
display: flex;
gap: 8px;
&.top-right {
top: 12px;
right: 12px;
}
}
.progress-wrapper {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
}
@@ -1,8 +0,0 @@
.download-app-screen {
display: flex;
justify-content: center;
align-items: center;
min-width: 100vw;
min-height: 100vh;
padding: 32px;
}
-41
View File
@@ -1,41 +0,0 @@
@use "common/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;
}
}
-150
View File
@@ -1,150 +0,0 @@
@use "common/colors" as *;
@use "common/material" as *;
// контейнер чата и панели с чатами
.all-container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
#profile {
display: none;
flex-direction: column;
z-index: 2000;
top: 0;
left: 0;
position: fixed;
height: 100vh;
width: 27%;
background-color: $color-dark-surface-container;
position: relative;
.profileheader {
display: flex;
gap: 200px;
p {
color: white;
}
a {
text-decoration: none;
color: white;
border: solid 2px $color-dark-on-surface-variant;
padding: 5px;
border-radius: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.241);
}
}
}
}
#chat-list {
display: flex;
flex-direction: column;
flex-grow: 0;
width: 40%;
background-color: $color-dark-surface-container;
height: 100%;
z-index: 1000;
min-height: 0; // allow children to manage their own scrolling
.chat-header-left {
display: flex;
color: white;
font-size: 25px;
background-color: $color-dark-surface-container;
width: 100%;
justify-content: center;
align-items: center;
padding: 16px;
overflow: hidden;
.product-name {
flex-grow: 1;
}
.profile {
font-size: 24px;
display: flex;
justify-content: start;
flex-shrink: 0;
flex-grow: 0;
#closeprofile a {
text-decoration: none;
color: white;
border: solid 2px $color-dark-on-surface-variant;
padding: 5px;
border-radius: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.241);
}
}
img {
$size: 45px;
display: flex;
border-radius: 50%;
width: $size;
height: $size;
}
}
}
.chat-tabs {
margin-top: 5px;
width: 100%;
height: calc(100% - 80px);
display: flex;
flex-direction: column;
min-height: 0; // prevent flex collapse when inner overflows
--mdui-color-surface: $color-dark-surface-container;
--mdui-color-surface-variant: transparent;
img {
width: 45px;
height: 45px;
border-radius: 20%;
object-fit: cover;
margin-right: 1rem;
}
mdui-tabs {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0; // enable inner panel to scroll
}
mdui-tab-panel[active] {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; // critical to avoid collapsing
overflow: hidden;
}
mdui-list {
flex: 1;
min-height: 0; // allow scroll area to size correctly
overflow-y: auto;
padding: 0;
margin: 0;
}
}
mdui-bottom-app-bar {
position: relative;
width: 100%;
padding-left: 16px;
padding-right: 16px;
margin-top: auto;
}
}
-249
View File
@@ -1,249 +0,0 @@
@use "common/colors" as *;
@use "common/material" as *;
#profile-dialog .content {
display: flex;
flex-direction: column;
gap: 24px;
min-width: 400px;
.header-top {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
position: relative;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline;
.profile-picture-container {
position: relative;
$size: 70px;
width: $size;
height: $size;
flex-shrink: 0;
#profile-picture {
width: $size;
height: $size;
border-radius: 50%;
object-fit: cover;
}
.upload-overlay {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
cursor: pointer;
}
}
mdui-text-field {
flex: 1;
}
}
#profile-form {
display: flex;
flex-direction: column;
gap: 16px;
mdui-text-field {
width: 100%;
}
.dialog-actions {
display: flex;
gap: 12px;
padding-top: 16px;
border-top: 1px solid $color-dark-outline;
> * {
flex: 1;
}
}
}
}
// User profile dialog content styles
#user-profile-dialog .content {
display: flex;
gap: 1.5rem;
.profile-picture-section {
flex-shrink: 0;
.profile-picture {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
}
}
.profile-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
.username-section {
display: flex;
align-items: center;
gap: 0.75rem;
.username {
margin: 0;
color: $color-dark-on-surface;
font-size: 1.1rem;
font-weight: 600;
}
.online-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-weight: 500;
&.online {
color: $success;
background-color: rgba(76, 175, 80, 0.1);
.online-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: $success;
}
}
&.offline {
color: $color-dark-on-surface-variant;
background-color: rgba(255, 255, 255, 0.05);
.offline-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: $color-dark-on-surface-variant;
}
}
}
}
.bio-section {
label {
display: block;
color: $color-dark-on-surface-variant;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.bio-display {
color: $color-dark-on-surface;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.75rem;
background-color: $color-dark-surface;
border-radius: 8px;
border: 1px solid $color-dark-outline;
min-height: 60px;
}
.bio-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
}
.profile-stats {
display: flex;
flex-direction: column;
gap: 0.5rem;
.stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
.stat-label {
color: $color-dark-on-surface-variant;
font-size: 0.85rem;
}
.stat-value {
color: $color-dark-on-surface;
font-size: 0.85rem;
font-weight: 500;
}
}
}
.profile-actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
mdui-button {
flex: 1;
}
}
}
}
// Cropper Dialog Styles
#cropper-dialog {
.cropper-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 500px;
max-width: 600px;
}
.cropper-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline;
h3 {
margin: 0;
color: $color-dark-on-surface;
}
}
.cropper-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background: $color-dark-surface-container;
border-radius: 8px;
overflow: hidden;
#cropper-area {
width: 100%;
height: 100%;
min-height: 400px;
}
}
.cropper-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid $color-dark-outline;
}
}
-99
View File
@@ -1,99 +0,0 @@
@use "common/colors" as *;
@use "common/material" as *;
#settings-dialog {
.fullscreen-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: calc(100vh - (1.5rem * 2));
width: 100%;
position: relative;
#settings-dialog-inner {
max-width: 1200px;
max-height: 1000px;
width: 100%;
height: 100%;
.header {
display: flex;
flex-direction: row;
gap: 10px;
margin-bottom: 16px;
}
#settings-menu {
display: flex;
flex-direction: row;
gap: 16px;
mdui-list {
max-width: 280px;
padding-right: 16px;
overflow-y: auto;
}
.screen {
flex: 1;
overflow-y: auto;
position: relative;
.settings-panel {
display: flex;
flex-direction: column;
gap: 16px;
opacity: 0;
visibility: hidden;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s ease;
position: absolute;
top: 0;
left: 0;
width: 100%;
&.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
position: relative;
}
h3 {
margin: 0 0 16px 0;
color: $color-dark-on-surface;
}
mdui-text-field,
mdui-select,
mdui-switch,
mdui-button {
margin-bottom: 8px;
}
mdui-switch {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid $color-dark-outline;
&:last-child {
border-bottom: none;
}
}
p {
margin: 8px 0;
color: $color-dark-on-surface-variant;
}
mdui-linear-progress {
margin: 16px 0;
}
}
}
}
}
}
}
@@ -1,119 +0,0 @@
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease forwards;
}
@keyframes fadeOutUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUpLeft {
from {
opacity: 0;
transform: translate(10px, 10px);
}
to {
opacity: 1;
transform: translate(0, 0);
}
}
@keyframes fadeOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(10px);
}
}
@keyframes fadeOutDown {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes fadeOutDownRight {
from {
opacity: 1;
transform: translate(0, 0);
}
to {
opacity: 0;
transform: translate(10px, 10px);
}
}
.chat-switch-out {
animation: fadeOutUp 0.2s ease forwards;
}
.chat-switch-in {
animation: fadeInDown 0.2s ease forwards;
}
@@ -1,150 +0,0 @@
@use "material" as *;
@use "sass:color";
.text-center {
text-align: center;
}
.alert {
padding: 0.8rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
&.alert-success {
background-color: #C6F6D5;
color: #22543D;
}
&.alert-danger {
background-color: #FED7D7;
color: #742A2A;
}
}
.link {
color: $color-dark-primary;
font-weight: 600;
}
button, input {
font: inherit;
}
.context-menu {
position: fixed;
background-color: $color-dark-surface-container;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
padding: 0.5rem 0;
z-index: 1000;
display: none;
min-width: 150px;
max-width: 200px;
white-space: nowrap;
user-select: none;
.context-menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
cursor: pointer;
color: $color-dark-on-surface;
transition: background-color 0.2s ease;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.material-symbols {
font-size: 1.1rem;
flex-shrink: 0;
}
}
&.pos-top-left {
transform-origin: top right;
}
&.pos-top-right {
transform-origin: top left;
}
&.pos-bottom-left {
transform-origin: bottom right;
}
&.pos-bottom-right {
transform-origin: bottom left;
}
&.open {
animation: context-menu-open 0.25s ease;
}
@keyframes context-menu-open {
0% {
opacity: 0;
transform: scale(0.5);
}
100% {
opacity: 1;
transform: scale(1);
}
}
}
// Dialog content styles
.dialog-content {
h3 {
margin: 0 0 1rem 0;
color: $color-dark-on-surface;
font-size: 1.2rem;
font-weight: 600;
}
mdui-text-field {
width: 100%;
}
.dialog-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1rem;
}
}
.rich-text-area {
width: 100%;
resize: none;
transition: height 0.2s ease;
overflow-y: hidden;
background-color: transparent;
display: block;
}
.quote {
background-color: $color-dark-surface-primary-container-lightened;
border-radius: 8px;
overflow: hidden;
color: $color-dark-on-surface-variant;
font-size: 0.9rem;
line-height: 1.4;
&.bg-surfaceContainer {
background-color: $color-dark-secondary-container;
.quote-inner {
border-left: 3px solid $color-dark-secondary;
}
}
.quote-inner {
border-left: 3px solid $color-dark-primary;
padding: 0.5rem;
}
}
@@ -1,31 +0,0 @@
@use "../common/material" as *;
.reply-dialog .dialog-content {
width: 300px;
overflow-x:hidden;
.reply-preview-dialog {
margin-bottom: 1rem;
padding: 16px;
background-color: $color-dark-surface-container;
border-radius: 16px;
.reply-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
.reply-username {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.reply-text {
color: $color-dark-on-surface-variant;
font-size: 0.9rem;
line-height: 1.4;
}
}
}
}
-51
View File
@@ -1,51 +0,0 @@
@use "auth";
@use "chat";
@use "profile";
@use "settings";
@use "panelchat";
@use "common/animations";
@use "common/components";
@use "common/colors" as *;
@use "common/material" as *;
@use "electron";
@use "dialogs/reply";
@use "download-app";
@use "lib/fonts/montserrat";
@use "lib/fonts/material-symbols";
* {
box-sizing: border-box;
}
body {
font-family: 'Montserrat', sans-serif;
background-color: $color-dark-surface;
color: $color-dark-on-surface;
line-height: 1.6;
#main-wrapper {
flex: 1;
position: relative;
min-height: 0;
}
}
body, #root {
height: 100vh;
position: relative;
margin: 0;
display: flex;
flex-direction: column;
}
mdui-dialog {
> *:first-child {
margin-block-start: 0;
}
> *:last-child {
margin-block-end: 0;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

-50
View File
@@ -1,50 +0,0 @@
import { MINIMUM_WIDTH } from "../core/config";
import { isElectron } from "../electron/electron";
import { ElectronTitleBar } from "./components/Electron";
import useWindowSize from "./hooks/useWindowSize";
import ChatScreen from "./screen/ChatScreen";
import DownloadAppScreen from "./screen/DownloadAppScreen";
import LoginScreen from "./screen/LoginScreen";
import RegisterScreen from "./screen/RegisterScreen";
import { useAppState } from "./state";
import { useEffect } from "react";
export default function App() {
const { currentPage, restoreUserFromStorage } = useAppState();
const { width } = useWindowSize();
// Restore user from localStorage on app initialization
useEffect(() => {
restoreUserFromStorage();
}, [restoreUserFromStorage]);
if (!isElectron && width < MINIMUM_WIDTH) {
return <DownloadAppScreen />
}
let page = <LoginScreen />;
switch (currentPage) {
case "login": {
page = <LoginScreen />
break;
}
case "register": {
page = <RegisterScreen />
break;
}
case "chat": {
page = <ChatScreen />
break;
}
}
return (
<>
<ElectronTitleBar />
<div id="main-wrapper">
{page}
</div>
</>
)
}
-16
View File
@@ -1,16 +0,0 @@
export type AlertType = "success" | "danger"
export interface Alert {
type: AlertType;
message: string;
}
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
return (
<div>
{alerts.slice(-3).map((alert, i) => {
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
})}
</div>
)
}
-39
View File
@@ -1,39 +0,0 @@
import type React from "react";
export function AuthContainer({ children }: { children?: React.ReactNode }) {
return (
<div className="auth-container">
<div className="auth-card fade-in">
{children}
</div>
</div>
)
}
export type IconType = "filled" | "outlined";
export interface AuthHeaderIcon {
name: string;
type: IconType
}
export interface AuthHeaderProps {
title: string;
icon: string | AuthHeaderIcon;
subtitle: string;
}
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
const iconType = typeof icon == "string" ? "filled" : icon.type;
const iconName = typeof icon == "string" ? icon : icon.name;
return (
<div className="auth-header">
<h2>
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
{title}
</h2>
<p>{subtitle}</p>
</div>
)
}
-11
View File
@@ -1,11 +0,0 @@
import { PRODUCT_NAME } from "../../core/config";
import { isElectron } from "../../electron/electron";
export function ElectronTitleBar() {
return isElectron && (
<div id="electron-title-bar">
{window.electronInterface.platform == "darwin" && <div className="macos-padding"></div>}
<div id="window-title">{PRODUCT_NAME}</div>
</div>
)
}
@@ -1,38 +0,0 @@
import { PRODUCT_NAME } from "../../../core/config";
import { useProfile } from "../../hooks/useProfile";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { useState } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
export function ChatHeader() {
const { profileData } = useProfile();
const [isProfileOpen, setIsProfileOpen] = useState(false);
const handleProfileClick = () => {
setIsProfileOpen(true);
};
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
return (
<>
<header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div>
<div className="profile">
<a href="#" id="profile-open" onClick={handleProfileClick}>
<img
src={profilePictureUrl}
alt=""
id="preview1"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
</a>
</div>
</header>
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
</>
);
}
@@ -1,177 +0,0 @@
import { useState, useEffect } from "react";
import { MaterialDialog } from "../core/Dialog";
import { RichTextArea } from "../core/RichTextArea";
import type { Message } from "../../../core/types";
import Quote from "../core/Quote";
import AnimatedHeight from "../core/animations/AnimatedHeight";
import { useImmer } from "use-immer";
interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void;
onSaveEdit?: (content: string) => void;
replyTo?: Message | null;
replyToVisible: boolean;
onClearReply?: () => void;
onCloseReply?: () => void;
editingMessage?: Message | null;
editVisible?: boolean;
onClearEdit?: () => void;
onCloseEdit?: () => void;
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
}
export function ChatInputWrapper(
{
onSendMessage,
onSaveEdit,
replyTo,
replyToVisible,
onClearReply,
onCloseReply,
editingMessage,
editVisible = false,
onClearEdit,
onCloseEdit,
onProvideFileAdder
}: ChatInputWrapperProps
) {
const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
const [errorOpen, setErrorOpen] = useState(false);
// Expose a way for parent to programmatically add files
useEffect(() => {
if (onProvideFileAdder) {
const addFiles = (files: File[]) => {
if (!files || files.length === 0) return;
setSelectedFiles(draft => { draft.push(...files) });
};
onProvideFileAdder(addFiles);
}
}, [onProvideFileAdder]);
// When entering edit mode, preload the message content
useEffect(() => {
setMessage(editingMessage ? editingMessage.content || "" : "");
}, [editingMessage]);
useEffect(() => {
setAttachmentsVisible(selectedFiles.length > 0);
}, [selectedFiles]);
const handleSubmit = async (e: React.FormEvent | Event) => {
e.preventDefault();
const hasText = Boolean(message.trim());
const hasFiles = selectedFiles.length > 0;
if (hasText || hasFiles) {
const totalSize = selectedFiles.reduce((acc, f) => acc + f.size, 0);
const limit = 4 * 1024 * 1024 * 1024; // 4GB
if (totalSize > limit) {
setErrorOpen(true);
return;
}
if (editingMessage && onSaveEdit) {
onSaveEdit(message);
setMessage("");
if (onClearEdit) onClearEdit();
} else {
onSendMessage(message, selectedFiles);
setMessage("");
setAttachmentsVisible(false);
if (onClearReply) onClearReply();
}
}
};
function handleAttachClick() {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.addEventListener("change", () => {
setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) });
});
input.click();
}
return (
<div className="chat-input-wrapper">
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}>
{editingMessage && (
<div className="reply-preview contextual-preview">
<mdui-icon name="edit" />
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{editingMessage!.username}</span>
<span className="reply-text">{editingMessage!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
</div>
)}
</AnimatedHeight>
<AnimatedHeight visible={replyToVisible} onFinish={onCloseReply}>
{replyTo && (
<div className="reply-preview contextual-preview">
<mdui-icon name="reply" />
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
</div>
)}
</AnimatedHeight>
<AnimatedHeight visible={attachmentsVisible} onFinish={() => setSelectedFiles([])}>
{selectedFiles.length > 0 && (
<div className="attachments-preview contextual-preview">
<mdui-icon name="attach_file" />
<div className="attachments-chips">
{selectedFiles.map((file, i) => (
<mdui-chip
key={i}
variant="input"
end-icon="close"
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
onClick={() => {
if (selectedFiles.length == 1) {
setAttachmentsVisible(false);
} else {
setSelectedFiles(draft => { draft.splice(i) })
}
}}
>
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
<span className="name">{file.name}</span>
</mdui-chip>
))}
</div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
</div>
)}
</AnimatedHeight>
<div className="chat-input">
<RichTextArea
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
text={message}
rows={1}
onTextChange={(value) => setMessage(value)}
onEnter={handleSubmit} />
<div className="buttons">
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
<button type="submit" className="send-btn">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button>
</div>
</div>
</form>
<MaterialDialog open={errorOpen} onOpenChange={setErrorOpen} close-on-overlay-click close-on-esc>
<div slot="headline">Ошибка</div>
<div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
</MaterialDialog>
</div>
);
}
@@ -1,172 +0,0 @@
import { useChat } from "../../hooks/useChat";
import { Message } from "./Message";
import { useAppState } from "../../state";
import type { Message as MessageType } from "../../../core/types";
import type { UserProfile } from "../../../core/types";
import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { fetchUserProfile } from "../../../api/profileApi";
import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils";
import { MaterialDialog } from "../core/Dialog";
interface ChatMessagesProps {
messages?: MessageType[];
isDm?: boolean;
children?: ReactNode;
onReplySelect?: (message: MessageType) => void;
onEditSelect?: (message: MessageType) => void;
onDelete?: (id: number) => void;
dmRecipientPublicKey?: string;
}
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) {
const { messages: hookMessages } = useChat();
const { user } = useAppState();
// Use prop messages if provided, otherwise use hook messages
const messages = propMessages || hookMessages;
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
// Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
message: null,
position: { x: 0, y: 0 }
});
// Delete dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
useEffect(() => {
if (!deleteDialogOpen) {
setToBeDeleted(null);
}
}, [deleteDialogOpen]);
async function handleProfileClick(username: string) {
if (!user.authToken) return;
setIsLoadingProfile(true);
try {
const profile = await fetchUserProfile(user.authToken, username);
if (profile) {
setSelectedUserProfile(profile);
setProfileDialogOpen(true);
}
} catch (error) {
console.error("Failed to fetch user profile:", error);
} finally {
setIsLoadingProfile(false);
}
};
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
e.preventDefault();
setContextMenu({
isOpen: true,
message,
position: { x: e.clientX, y: e.clientY }
});
};
function handleContextMenuOpenChange(isOpen: boolean) {
setContextMenu(prev => ({
...prev,
isOpen
}));
};
function handleEdit(message: MessageType) {
if (onEditSelect) onEditSelect(message);
};
function handleReply(message: MessageType) {
if (onReplySelect) onReplySelect(message);
};
async function confirmDelete() {
if (!toBeDeleted || !user.authToken) return;
try {
onDelete?.(toBeDeleted.id);
// if (toBeDeleted.isDm) {
// // For DM, send dmDelete
// await request({
// type: "dmDelete",
// data: { id: toBeDeleted.id },
// credentials: { scheme: "Bearer", credentials: user.authToken }
// });
// } else {
// await request({
// type: "deleteMessage",
// data: { message_id: toBeDeleted.id },
// credentials: { scheme: "Bearer", credentials: user.authToken }
// });
// }
} catch (error) {
console.error("Failed to delete message:", error);
}
setDeleteDialogOpen(false);
}
async function handleDelete(message: MessageType) {
setToBeDeleted({ id: message.id, isDm });
setDeleteDialogOpen(true);
}
return (
<>
<div className="chat-messages" id="chat-messages">
{messages.map((message) => (
<Message
key={message.id}
message={message}
isAuthor={message.username === user.currentUser?.username}
onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu}
isLoadingProfile={isLoadingProfile}
isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} />
))}
{children}
</div>
<UserProfileDialog
isOpen={profileDialogOpen}
onOpenChange={async (value) => {
setProfileDialogOpen(value);
if (!value) {
await delay(1000);
setSelectedUserProfile(null);
}
}}
userProfile={selectedUserProfile}
/>
<MaterialDialog
headline="Удалить сообщение?"
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}>
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button>
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button>
</MaterialDialog>
{/* Context Menu */}
{contextMenu.message && (
<MessageContextMenu
message={contextMenu.message}
isAuthor={contextMenu.message.username === user.currentUser?.username}
onEdit={handleEdit}
onReply={handleReply}
onDelete={handleDelete}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange}
/>
)}
</>
);
}
@@ -1,56 +0,0 @@
import { useChat } from "../../hooks/useChat";
export function ChatTabs() {
const { activeTab, setActiveTab, setCurrentChat } = useChat();
const handleChatClick = (chatName: string) => {
setCurrentChat(chatName);
};
return (
<div className="chat-tabs">
<mdui-tabs value={activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
<mdui-tab value="chats">
Чаты
</mdui-tab>
<mdui-tab value="channels">
Каналы
</mdui-tab>
<mdui-tab value="contacts">
Контакты
</mdui-tab>
<mdui-tab value="dms">
ЛС
</mdui-tab>
<mdui-tab-panel slot="panel" value="chats">
<mdui-list>
<mdui-list-item
headline="Общий чат"
description="Вы: Последнее сообщение"
id="chat-list-chat-1"
onClick={() => handleChatClick("Общий чат")}
style={{ cursor: "pointer" }}
>
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
</mdui-list-item>
<mdui-list-item
headline="Общий чат 2"
description="Вы: Последнее сообщение"
id="chat-list-chat-2"
onClick={() => handleChatClick("Общий чат 2")}
style={{ cursor: "pointer" }}
>
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="dms">
<mdui-list id="dm-users"></mdui-list>
</mdui-tab-panel>
</mdui-tabs>
</div>
);
}
@@ -1,94 +0,0 @@
import { useEffect } from "react";
import { useDM } from "../../hooks/useDM";
import { useAppState } from "../../state";
import { fetchUserPublicKey } from "../../../api/dmApi";
import defaultAvatar from "../../../resources/images/default-avatar.png";
export function DMUsersList() {
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
const { chat, switchToDM } = useAppState();
useEffect(() => {
if (chat.activeTab === "dms") {
loadUsers();
}
}, [chat.activeTab, loadUsers]);
if (isLoadingUsers) {
return (
<mdui-list>
<mdui-list-item headline="Загрузка..." description="Получение списка пользователей...">
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
);
}
if (dmUsers.length === 0) {
return (
<mdui-list>
<mdui-list-item headline="Нет пользователей" description="Пользователи не найдены">
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
);
}
const handleUserClick = async (user: any) => {
if (!user.publicKey) {
// Get public key if not already loaded
const authToken = useAppState.getState().user.authToken;
if (!authToken) return;
const publicKey = await fetchUserPublicKey(user.id, authToken);
if (publicKey) {
user.publicKey = publicKey;
} else {
console.error("Failed to get public key for user:", user.id);
return;
}
}
await switchToDM({
userId: user.id,
username: user.username,
publicKey: user.publicKey,
profilePicture: user.profile_picture,
online: user.online || false
});
};
return (
<mdui-list>
{dmUsers.map((user) => (
<mdui-list-item
key={user.id}
headline={user.username}
description={user.lastMessage || "Нет сообщений"}
onClick={() => handleUserClick(user)}
style={{ cursor: "pointer" }}
>
<img
src={user.profile_picture || defaultAvatar}
alt={user.username}
slot="icon"
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover"
}}
onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar;
}}
/>
{user.unreadCount > 0 && (
<mdui-badge slot="end-icon">
{user.unreadCount}
</mdui-badge>
)}
</mdui-list-item>
))}
</mdui-list>
);
}
@@ -1,117 +0,0 @@
import { PRODUCT_NAME } from "../../../core/config";
import { useAppState } from "../../state";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { useState, type FormEvent } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
import { SettingsDialog } from "../settings/SettingsDialog";
import { DMUsersList } from "./DMUsersList";
import type { Tabs } from "mdui";
import type { ChatTabs } from "../../state";
function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useAppState();
const handleLogout = () => {
logout();
};
return (
<>
<mdui-bottom-app-bar>
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
<div style={{ flexGrow: 1 }}></div>
<mdui-button-icon
icon="logout--filled"
id="logout-btn"
onClick={handleLogout}
title="Выйти"
></mdui-button-icon>
<mdui-fab icon="edit--filled"></mdui-fab>
</mdui-bottom-app-bar>
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
</>
);
}
function ChatTabs() {
const { chat, switchToTab, switchToPublicChat } = useAppState();
const { activeTab } = chat;
const handleChatClick = async (chatName: string) => {
await switchToPublicChat(chatName);
};
const handleTabChange = async (e: FormEvent<Tabs>) => {
const tab = (e.target as Tabs).value as ChatTabs;
await switchToTab(tab);
};
return (
<div className="chat-tabs">
<mdui-tabs value={activeTab} full-width onChange={handleTabChange}>
<mdui-tab value="chats">Чаты</mdui-tab>
<mdui-tab value="channels">Каналы</mdui-tab>
<mdui-tab value="contacts">Контакты</mdui-tab>
<mdui-tab value="dms">ЛС</mdui-tab>
<mdui-tab-panel slot="panel" value="chats">
<mdui-list>
<mdui-list-item
headline="Общий чат"
description="Вы: Последнее сообщение"
id="chat-list-chat-1"
onClick={() => handleChatClick("Общий чат")}
style={{ cursor: "pointer" }}
>
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
<mdui-list-item
headline="Общий чат 2"
description="Вы: Последнее сообщение"
id="chat-list-chat-2"
onClick={() => handleChatClick("Общий чат 2")}
style={{ cursor: "pointer" }}
>
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="dms">
<DMUsersList />
</mdui-tab-panel>
</mdui-tabs>
</div>
);
}
function ChatHeader() {
const [isProfileOpen, setProfileOpen] = useState(false);
return (
<header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div>
<div className="profile">
<a href="#" id="profile-open" onClick={() => setProfileOpen(true)}>
<img src={defaultAvatar} alt="" id="preview1" />
</a>
</div>
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setProfileOpen} />
</header>
);
}
export function LeftPanel() {
return (
<div className="chat-list" id="chat-list">
<ChatHeader />
<ChatTabs />
<BottomAppBar />
</div>
);
}
-418
View File
@@ -1,418 +0,0 @@
import { formatTime } from "../../../utils/utils";
import type { Attachment, Message as MessageType } from "../../../core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import Quote from "../core/Quote";
import { parse } from "marked";
import DOMPurify from "dompurify";
import { useEffect, useState, useRef } from "react";
import { getCurrentKeys } from "../../../auth/crypto";
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
import { getAuthHeaders } from "../../../auth/api";
import { useAppState } from "../../state";
import { ub64 } from "../../../utils/utils";
import { useImmer } from "use-immer";
interface MessageProps {
message: MessageType;
isAuthor: boolean;
onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
isLoadingProfile?: boolean;
isDm?: boolean;
dmRecipientPublicKey?: string;
}
interface Rect {
left: number;
top: number;
width: number;
height: number
}
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(new Set());
const [isDownloadingFullscreen, setIsDownloadingFullscreen] = useState(false);
const [fullscreenImage, setFullscreenImage] = useState<{
src: string;
name: string;
element: HTMLImageElement;
startRect: Rect;
endRect: Rect;
} | null>(null);
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
const { user } = useAppState();
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope;
useEffect(() => {
(async () => {
setFormattedMessage({
__html: DOMPurify.sanitize(
await parse(message.content)
).trim()
});
})();
}, [message]);
// Auto-decrypt images in DMs
useEffect(() => {
if (isDm && message.files) {
message.files.forEach(async (file) => {
console.log(file);
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
console.log("Decrypting...");
const decryptedUrl = await decryptFile(file);
console.log(decryptedUrl);
if (decryptedUrl) {
updateDecryptedFiles(draft => {
draft.set(file.path, decryptedUrl);
});
}
}
});
}
}, [message.files, isDm, decryptedFiles]);
const decryptFile = async (file: Attachment): Promise<string | null> => {
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
debugger;
console.warn("Conditions not met")
return null;
}
// Check if already decrypted
if (decryptedFiles.has(file.path)) {
return decryptedFiles.get(file.path) || null;
}
try {
// no-op decrypt indicator removed from UI
// Fetch encrypted file
const response = await fetch(file.path, {
headers: getAuthHeaders(user.authToken!)
});
if (!response.ok) throw new Error("Failed to fetch file");
const encryptedData = await response.arrayBuffer();
// Get current user's keys
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Derive shared secret with the recipient's public key
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
// Derive wrapping key using the salt from the DM envelope
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Unwrap the message key
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
// Decrypt the file using the message key
const iv = new Uint8Array(encryptedData, 0, 12);
const ciphertext = new Uint8Array(encryptedData, 12);
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
// Create blob URL for download
const blob = new Blob([decrypted.buffer as ArrayBuffer]);
const url = URL.createObjectURL(blob);
updateDecryptedFiles(draft => {
draft.set(file.path, url);
});
return url;
} catch (error) {
console.error("Failed to decrypt file:", error);
return null;
} finally {
// no-op decrypt indicator removed from UI
}
};
const handleImageClick = async (file: Attachment, imageElement: HTMLImageElement) => {
// Use decrypted URL if available, otherwise decrypt first
const decryptedUrl = decryptedFiles.get(file.path);
if (decryptedUrl) {
openFullscreenFromThumb(imageElement, decryptedUrl, file.name || "image");
} else if (file.encrypted && isDm) {
const newDecryptedUrl = await decryptFile(file);
if (newDecryptedUrl) {
openFullscreenFromThumb(imageElement, newDecryptedUrl, file.name || "image");
}
} else {
openFullscreenFromThumb(imageElement, file.path, file.name || "image");
}
};
const computeEndRect = (naturalWidth: number, naturalHeight: number): Rect => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const maxWidth = Math.floor(viewportWidth * 0.9);
const maxHeight = Math.floor(viewportHeight * 0.9);
const widthRatio = maxWidth / naturalWidth;
const heightRatio = maxHeight / naturalHeight;
const scale = Math.min(widthRatio, heightRatio, 1);
const width = Math.round(naturalWidth * scale);
const height = Math.round(naturalHeight * scale);
const left = Math.round((viewportWidth - width) / 2);
const top = Math.round((viewportHeight - height) / 2);
return { left, top, width, height };
};
const openFullscreenFromThumb = (imgEl: HTMLImageElement, src: string, name: string) => {
const rect = imgEl.getBoundingClientRect();
const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
const tempImg = new Image();
tempImg.src = src;
// Hide original while animating
imgEl.style.visibility = "hidden";
tempImg.onload = () => {
const endRect = computeEndRect(tempImg.naturalWidth, tempImg.naturalHeight);
setFullscreenImage({
src,
name,
element: imgEl,
startRect,
endRect
});
// Start animation on next frame to ensure DOM has overlay mounted
requestAnimationFrame(() => setIsAnimatingOpen(true));
};
};
const closeFullscreen = () => {
// Reverse animation
setIsAnimatingOpen(false);
// Wait for transition to finish
setTimeout(() => {
if (fullscreenImage?.element) {
fullscreenImage.element.style.visibility = "visible";
}
setFullscreenImage(null);
}, 300);
};
const downloadImage = async () => {
if (!fullscreenImage) return;
const { src, name } = fullscreenImage;
try {
setIsDownloadingFullscreen(true);
if (src.startsWith("blob:")) {
const link = document.createElement("a");
link.href = src;
link.download = name;
link.click();
setIsDownloadingFullscreen(false);
return;
}
// Fetch with credentials/headers when not a blob URL
const response = await fetch(src, {
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined,
credentials: "include"
});
if (!response.ok) throw new Error("Failed to download image");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = name;
link.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
} finally {
setIsDownloadingFullscreen(false);
}
};
const downloadFile = async (file: Attachment) => {
try {
updateDownloadingPaths(draft => {
draft.add(file.path);
});
// Prefer decrypted URL if present (DM encrypted case)
const decrypted = decryptedFiles.get(file.path);
if (decrypted) {
const link = document.createElement("a");
link.href = decrypted;
link.download = file.name || "file";
link.click();
updateDownloadingPaths(draft => {
draft.delete(file.path);
});
return;
}
// If not decrypted or public file, fetch with credentials/headers
const response = await fetch(file.path, {
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined,
credentials: "include"
});
if (!response.ok) throw new Error("Failed to download file");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name || "file";
link.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
} finally {
updateDownloadingPaths(draft => {
draft.delete(file.path);
});
}
};
function handleContextMenu(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, message);
}
return (
<>
<div
className={`message ${isAuthor ? "sent" : "received"}`}
data-id={message.id}
onContextMenu={handleContextMenu}
>
<div className="message-inner">
{/* Add profile picture for received messages */}
{!isAuthor && !isDm && (
<div className="message-profile-pic">
<img
src={message.profile_picture || defaultAvatar}
alt={message.username}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
className={isLoadingProfile ? "loading" : ""}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
</div>
)}
{!isAuthor && !isDm && (
<div
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
{message.username}
</div>
)}
{/* Add reply preview if this is a reply */}
{message.reply_to && (
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className="reply-username">{message.reply_to.username}</span>
<span className="reply-text">{message.reply_to.content}</span>
</Quote>
)}
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
{message.files && message.files.length > 0 && (
<mdui-list className="message-attachments">
{message.files.map((file, idx) => {
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
const isEncryptedDm = Boolean(isDm && file.encrypted);
const decryptedUrl = decryptedFiles.get(file.path);
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
const isDownloading = downloadingPaths.has(file.path);
return (
<div className="attachment" key={idx}>
{isImage ? (
<div className="image-wrapper">
<img
ref={(el) => {
if (el) imageRefs.current.set(file.path, el);
}}
src={imageSrc}
alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)}
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
/>
{!loadedImages.has(file.path) && (
<div className="loading-overlay">
<mdui-circular-progress />
</div>
)}
</div>
) : (
<a
href="#"
onClick={async (e) => {
e.preventDefault();
await downloadFile(file);
}}
>
<mdui-list-item>
<span className="with-icon-gap">
{isDownloading ? <mdui-circular-progress /> : null}
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</span>
</mdui-list-item>
</a>
)}
</div>
);
})}
</mdui-list>
)}
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
)}
</div>
</div>
</div>
{/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && (
<div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
onClick={closeFullscreen}>
<img
src={fullscreenImage.src}
alt={fullscreenImage.name}
className={`fullscreen-animated-image ${isAnimatingOpen ? "to-end" : "to-start"}`}
style={{
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
width: `${isAnimatingOpen ? fullscreenImage.endRect.width : fullscreenImage.startRect.width}px`,
height: `${isAnimatingOpen ? fullscreenImage.endRect.height : fullscreenImage.startRect.height}px`
}}
onClick={e => e.stopPropagation()}
/>
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
<mdui-button-icon icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? (
<div className="progress-wrapper">
<mdui-circular-progress />
</div>
) : (
<mdui-button-icon icon="download" onClick={downloadImage} />
)}
</div>
</div>
)}
</>
);
}
@@ -1,174 +0,0 @@
import { useState, useEffect } from "react";
import type { Message, Size2D } from "../../../core/types";
interface MessageContextMenuProps {
message: Message;
isAuthor: boolean;
onEdit: (message: Message) => void;
onReply: (message: Message) => void;
onDelete: (message: Message) => void;
position: Size2D;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export interface ContextMenuState {
isOpen: boolean;
message: Message | null;
position: Size2D;
}
export function MessageContextMenu({
message,
isAuthor,
onEdit,
onReply,
onDelete,
position,
isOpen,
onOpenChange
}: MessageContextMenuProps) {
// Internal state for closing animation
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering');
// Calculate smart positioning when component opens
useEffect(() => {
if (isOpen) {
const menuWidth = 160; // min-width from CSS
const menuHeight = isAuthor ? 120 : 60; // Approximate height based on items
const padding = 10; // Padding from viewport edges
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
let animation = 'entering';
// Check if menu would overflow right edge
if (x + menuWidth + padding > viewportWidth) {
x = viewportWidth - menuWidth - padding;
animation = 'entering-left'; // Animation from left side
}
// Check if menu would overflow bottom edge
if (y + menuHeight + padding > viewportHeight) {
y = viewportHeight - menuHeight - padding;
animation = 'entering-up'; // Animation from bottom
}
// If both edges would overflow, use top-left positioning
if (x + menuWidth + padding > viewportWidth && y + menuHeight + padding > viewportHeight) {
x = Math.max(padding, position.x - menuWidth);
y = Math.max(padding, position.y - menuHeight);
animation = 'entering-up-left';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
}
}, [isOpen, position, isAuthor]);
// Effect to handle clicks outside the context menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isOpen && !isClosing) {
// Check if the click is on a context menu element
const target = event.target as Element;
if (!target.closest('.context-menu')) {
handleClose();
}
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose();
}
};
const handleWindowBlur = () => {
// Close context menu when browser window loses focus
if (isOpen && !isClosing) {
handleClose();
}
};
// Add event listeners
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('blur', handleWindowBlur);
// Cleanup
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleWindowBlur);
};
}, [isOpen, isClosing]);
const handleAction = (action: string) => {
switch (action) {
case "reply":
onReply(message);
handleClose();
break;
case "edit":
if (isAuthor) onEdit(message);
break;
case "delete":
if (isAuthor) {
onDelete(message);
handleClose();
}
break;
}
onOpenChange(false);
};
const handleClose = () => {
setIsClosing(true);
// Set appropriate closing animation based on opening animation
const closingAnimation = animationClass.replace('entering', 'closing');
setAnimationClass(closingAnimation);
// Wait for animation to complete before calling onOpenChange
setTimeout(() => {
onOpenChange(false);
setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening
}, 200); // Match the animation duration from _animations.scss
};
return isOpen && (
<div
className={`context-menu ${animationClass}`}
style={{
position: "fixed",
display: "block",
top: calculatedPosition.y,
left: calculatedPosition.x,
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}>
<div className="context-menu-item" onClick={() => handleAction("reply")}>
<span className="material-symbols">reply</span>
Ответить
</div>
{isAuthor && (
<>
<div className="context-menu-item" onClick={() => handleAction("edit")}>
<span className="material-symbols">edit</span>
Редактировать
</div>
<div className="context-menu-item" onClick={() => handleAction("delete")}>
<span className="material-symbols">delete</span>
Удалить
</div>
</>
)}
</div>
)
}
@@ -1,284 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
import { setGlobalMessageHandler } from "../../../core/websocket";
import type { Message } from "../../../core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
import type { DMPanel } from "../../panels/DMPanel";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
isChatSwitching: boolean;
}
export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRendererProps) {
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const [switchIn, setSwitchIn] = useState(false);
const [switchOut, setSwitchOut] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
const [editMessage, setEditMessage] = useState<Message | null>(null);
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
// Drag & drop
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const addFilesRef = useRef<null | ((files: File[]) => void)>(null);
useEffect(() => {
if (!panel || !panelState) return;
return () => {
dragCounterRef.current = 0;
setIsDragging(false);
};
}, [panel, panelState]);
useEffect(() => {
if (replyTo) {
setReplyToVisible(true);
}
}, [replyTo]);
useEffect(() => {
if (editMessage) {
setEditVisible(true);
}
}, [editMessage]);
// Handle panel state changes
useEffect(() => {
if (panel) {
setPanelState(panel.getState());
// Set up state change listener
const handleStateChange = (newState: MessagePanelState) => {
setPanelState(newState);
};
// Store the handler for cleanup
panel.onStateChange = handleStateChange;
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler(panel.handleWebSocketMessage);
}
} else {
setPanelState(null);
// Clear global message handler when no panel is active
setGlobalMessageHandler(null);
}
// Cleanup function
return () => {
if (panel && panel.onStateChange) {
panel.onStateChange = null;
}
};
}, [panel]);
// Handle chat switching animation
useEffect(() => {
if (isChatSwitching) {
setSwitchOut(true);
setTimeout(() => {
setSwitchOut(false);
setSwitchIn(true);
setTimeout(() => setSwitchIn(false), 200);
}, 250);
}
}, [isChatSwitching]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [panelState?.messages]);
if (!panel || !panelState) {
return (
<div className="chat-container">
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">Выбор чата</h4>
<p>
<span className="online-status"></span>
Выберите чат, чтобы начать переписку
</p>
</div>
</div>
</div>
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите чат на боковой панели, чтобы начать переписку
</div>
</div>
</div>
</div>
);
}
return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div
className="chat-main"
id="chat-inner"
onDragEnter={(e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current += 1;
// Only show overlay when actual files are dragged
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
if (hasFiles) setIsDragging(true);
}}
onDragOver={(e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) setIsDragging(false);
}}
onDrop={(e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files || []);
if (files.length > 0 && addFilesRef.current) {
addFilesRef.current(files);
}
setIsDragging(false);
dragCounterRef.current = 0;
}}>
<div className="chat-header">
<img
src={panelState.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={panel.handleProfileClick}
style={{ cursor: "pointer" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState.title}</h4>
<p>
<span className={`online-status ${panelState.online ? "online" : "offline"}`}></span>
{panelState.online ? "Online" : "Offline"}
{panelState.isTyping && " • Typing..."}
</p>
</div>
</div>
</div>
{panelState.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка сообщений...
</div>
</div>
): (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
)}
<AnimatedOpacity
visible={isDragging}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
<mdui-icon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</AnimatedOpacity>
<ChatInputWrapper
onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null);
}}
onSaveEdit={(content) => {
if (editMessage) {
panel.handleEditMessage(editMessage.id, content);
setEditMessage(null);
}
}}
replyTo={replyTo}
replyToVisible={replyToVisible}
onClearReply={() => {
setPendingAction(null);
setReplyToVisible(false);
}}
onCloseReply={() => {
setReplyTo(null);
if (pendingAction && pendingAction.type === "edit") {
setEditMessage(pendingAction.message);
setPendingAction(null);
}
}}
editingMessage={editMessage}
editVisible={editVisible}
onClearEdit={() => {
setPendingAction(null);
setEditVisible(false);
}}
onCloseEdit={() => {
setEditMessage(null);
if (pendingAction && pendingAction.type === "reply") {
setReplyTo(pendingAction.message);
setPendingAction(null);
}
}}
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
/>
</div>
</div>
);
}
@@ -1,60 +0,0 @@
import { useState, useEffect } from "react";
import type { Message } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { MaterialTextField } from "../core/TextField";
interface ReplyMessageDialogProps {
isOpen: boolean;
onOpenChange: (value: boolean) => void;
replyToMessage: Message | null;
onSendReply: (content: string, replyToId: number) => void;
}
export function ReplyMessageDialog({ isOpen, onOpenChange, replyToMessage, onSendReply }: ReplyMessageDialogProps) {
const [replyContent, setReplyContent] = useState("");
useEffect(() => {
if (replyToMessage) {
setReplyContent("");
}
}, [replyToMessage]);
const handleSendReply = () => {
if (replyToMessage && replyContent.trim()) {
onSendReply(replyContent.trim(), replyToMessage.id);
onOpenChange(false);
}
};
const handleCancel = () => {
onOpenChange(false);
setReplyContent("");
};
if (!replyToMessage) return null;
return (
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc className="reply-dialog">
<div className="dialog-content">
<h3>Ответить на сообщение</h3>
<div className="reply-preview-dialog">
<div className="reply-content">
<span className="reply-username">{replyToMessage.username}</span>
<span className="reply-text">{replyToMessage.content}</span>
</div>
</div>
<MaterialTextField
value={replyContent}
onInput={(e) => setReplyContent((e.target as HTMLInputElement).value)}
label="Reply"
variant="outlined"
placeholder="Type your reply..."
maxlength={1000} />
<div className="dialog-actions">
<mdui-button onClick={handleCancel} variant="outlined">Cancel</mdui-button>
<mdui-button onClick={handleSendReply}>Send Reply</mdui-button>
</div>
</div>
</MaterialDialog>
);
}
@@ -1,13 +0,0 @@
import { useAppState } from "../../state";
import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() {
const { chat } = useAppState();
return (
<MessagePanelRenderer
panel={chat.activePanel}
isChatSwitching={chat.isChatSwitching}
/>
);
}
@@ -1,71 +0,0 @@
import type { DialogProps } from "../../../core/types";
import type { UserProfile } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { formatTime } from "../../../utils/utils";
import defaultAvatar from "../../../resources/images/default-avatar.png";
interface UserProfileDialogProps extends DialogProps {
userProfile: UserProfile | null;
}
export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserProfileDialogProps) {
const content = userProfile ? (
<div className="content">
<div className="profile-picture-section">
<img
className="profile-picture"
alt="Profile Picture"
src={userProfile.profile_picture || defaultAvatar}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
</div>
<div className="profile-info">
<div className="username-section">
<h4 className="username">{userProfile.username}</h4>
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
{userProfile.online ? (
<>
<span className="online-indicator"></span> Онлайн
</>
) : (
<>
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
</>
)}
</div>
</div>
<div className="bio-section">
<label>О себе:</label>
<div className="bio-display">
{userProfile.bio || "No bio available."}
</div>
</div>
<div className="profile-stats">
<div className="stat">
<span className="stat-label">Зарегистрирован:</span>
<span className="stat-value member-since">{formatTime(userProfile.created_at)}</span>
</div>
<div className="stat">
<span className="stat-label">Last seen:</span>
<span className="stat-value last-seen">{formatTime(userProfile.last_seen)}</span>
</div>
</div>
<div className="profile-actions">
<mdui-button id="dm-button" variant="filled">
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
Send Message
</mdui-button>
</div>
</div>
</div>
) : null
return (
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc id="user-profile-dialog">
{content}
</MaterialDialog>
);
}
@@ -1,45 +0,0 @@
import type { Dialog as MduiDialog } from "mdui/components/dialog";
import { useEffect, type Ref } from "react"
import { createPortal } from "react-dom";
import { id } from "../../../utils/utils";
import useCombinedRefs from "../../hooks/useCombinedRefs";
export interface BaseDialogProps {
onOpenChange: (value: boolean) => void;
ref?: Ref<MduiDialog & HTMLElement>
}
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
export function MaterialDialog(props: FullDialogProps) {
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "open") {
const isOpen = dialog.hasAttribute("open");
if (isOpen !== props.open) {
props.onOpenChange(isOpen);
}
}
});
});
// Start observing the dialog element for attribute changes
observer.observe(dialog, {
attributes: true,
attributeFilter: ["open"]
});
// Cleanup observer
return () => {
observer.disconnect();
};
}, [dialogRef.current, props.open, props.onOpenChange]);
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
}
@@ -1,9 +0,0 @@
import type { TextField } from "mdui/components/text-field";
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
return <mdui-text-field
autocomplete="off"
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
}
@@ -1,71 +0,0 @@
import { useEffect, useState, useRef } from "react";
import type { AnimatedPropertyProps } from "./types";
export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) {
const [height, setHeight] = useState("0px");
const [shouldRender, setShouldRender] = useState(!!visible);
const [isAnimating, setIsAnimating] = useState(false);
const measureRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (visible) {
setShouldRender(true);
setIsAnimating(true);
// Wait for content to render, then measure
setTimeout(() => {
if (measureRef.current) {
const contentHeight = measureRef.current.scrollHeight;
setHeight(`${contentHeight}px`);
}
// Animation complete
setTimeout(() => {
setHeight("auto");
setIsAnimating(false);
}, duration * 1000);
}, 0);
} else if (shouldRender) {
setIsAnimating(true);
if (measureRef.current) {
const contentHeight = measureRef.current.scrollHeight;
setHeight(`${contentHeight}px`);
// Force a reflow before animating to 0
requestAnimationFrame(() => {
// Read layout to ensure the previous height assignment is flushed
if (containerRef.current) {
containerRef.current.offsetHeight;
}
// Use a second frame to ensure the measured pixel height is applied before collapsing
requestAnimationFrame(() => {
setHeight("0px");
});
});
}
// Hide content after animation completes
setTimeout(() => {
setShouldRender(false);
setIsAnimating(false);
if (onFinish) {
onFinish();
}
}, duration * 1000);
}
}, [visible, shouldRender]);
return (visible || shouldRender || isAnimating) && (
<div
{...props}
ref={containerRef}
style={{
height,
transition: `height ${duration}s ease`,
overflow: "hidden",
...props.style
}}
>
<div ref={measureRef} style={{ height: "auto" }}>
{shouldRender && children}
</div>
</div>
);
}
@@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
import type { AnimatedPropertyProps } from "./types";
export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) {
const [opacity, setOpacity] = useState(visible ? 1 : 0);
const [shouldRender, setShouldRender] = useState(visible);
useEffect(() => {
if (visible) {
setShouldRender(true);
setOpacity(0);
// Wait for content to render, then animate in
const id = setTimeout(() => {
setOpacity(1);
}, 10);
return () => clearTimeout(id);
} else {
setOpacity(0);
const id = setTimeout(() => {
setShouldRender(false);
if (onFinish) {
onFinish();
}
}, duration * 1000);
return () => clearTimeout(id);
}
}, [visible, duration, onFinish]);
return shouldRender && (
<div
{...props}
style={{
opacity,
transition: `opacity ${duration}s ease`,
...props.style
}}
>{children}</div>
);
}
-10
View File
@@ -1,10 +0,0 @@
import type { ReactNode } from "react";
export interface BaseAnimatedPropertyProps {
visible: any;
duration?: number;
onFinish?: () => void
children?: ReactNode;
}
export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div">
@@ -1,19 +0,0 @@
export function CropperDialog() {
return (
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
</div>
<div className="cropper-container">
<div id="cropper-area"></div>
</div>
<div className="cropper-actions">
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
<mdui-button id="crop-save">Сохранить</mdui-button>
</div>
</div>
</mdui-dialog>
);
}
@@ -1,192 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "../../../core/types";
interface ImageCropperProps {
onCrop: (croppedImageData: string) => void;
onCancel: () => void;
imageFile: File | null;
}
export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [src, setSrc] = useState<string | undefined>(undefined);
const [isLoaded, setIsLoaded] = useState(false);
const [cropArea, setCropArea] = useState<Rect>({ x: 0, y: 0, width: 200, height: 200 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
useEffect(() => {
if (imageFile) {
const reader = new FileReader();
function handleImageLoad() {
setIsLoaded(true);
// Initialize crop area to center of image
const img = imageRef.current;
if (img) {
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
setCropArea({
x: (img.naturalWidth - size) / 2,
y: (img.naturalHeight - size) / 2,
width: size,
height: size
});
}
}
function handleReaderLoad() {
if (imageRef.current) {
setSrc(reader.result as string);
imageRef.current.addEventListener("load", handleImageLoad);
}
}
reader.addEventListener("load", handleReaderLoad);
reader.readAsDataURL(imageFile);
return () => {
reader.abort();
reader.removeEventListener("load", handleReaderLoad);
imageRef.current?.removeEventListener("load", handleImageLoad);
}
}
}, [imageFile]);
function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if click is within crop area
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
setIsDragging(true);
setDragStart({ x: x - cropArea.x, y: y - cropArea.y });
}
};
function handleMouseMove(e: React.MouseEvent) {
const rect = canvasRef.current?.getBoundingClientRect();
if (isDragging && isLoaded && rect && imageRef.current) {
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const newX = Math.max(
0,
Math.min(
x - dragStart.x,
imageRef.current.naturalWidth - cropArea.width
)
);
const newY = Math.max(
0,
Math.min(
y - dragStart.y,
imageRef.current.naturalHeight - cropArea.height
)
);
setCropArea(prev => ({ ...prev, x: newX, y: newY }));
}
};
function handleMouseUp() {
setIsDragging(false);
};
function handleCrop() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to crop area
canvas.width = cropArea.width;
canvas.height = cropArea.height;
// Draw cropped portion
ctx.drawImage(
imageRef.current,
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, cropArea.width, cropArea.height
);
// Convert to data URL
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
onCrop(croppedImageData);
};
function drawCropArea() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw image
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
// Draw crop overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
// Draw crop border
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
};
useEffect(() => {
drawCropArea();
}, [cropArea, isLoaded]);
if (!imageFile) return null;
return (
<div className="cropper-container">
<canvas
ref={canvasRef}
width={400}
height={400}
style={{
cursor: isDragging ? 'grabbing' : 'grab',
border: '1px solid #ccc',
maxWidth: '100%',
height: 'auto'
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
<img
ref={imageRef}
src={src}
style={{ display: 'none' }}
alt="Crop source"
/>
<div className="cropper-actions">
<mdui-button onClick={handleCrop} disabled={!isLoaded}>
Обрезать
</mdui-button>
<mdui-button variant="outlined" onClick={onCancel}>
Отмена
</mdui-button>
</div>
</div>
);
}
@@ -1,179 +0,0 @@
import { useState, useEffect, useRef, type FormEvent } from "react";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import type { TextField } from "mdui/components/text-field";
import type { DialogProps } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { useProfile } from "../../hooks/useProfile";
import { ImageCropper } from "./ImageCropper";
import { MaterialTextField } from "../core/TextField";
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
const [username, setUsername] = useState(profileData?.nickname ?? "");
const [description, setDescription] = useState(profileData?.description ?? "");
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [showCropper, setShowCropper] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Update form fields when profile data changes
useEffect(() => {
if (profileData) {
setUsername(profileData.nickname || "");
setDescription(profileData.description || "");
}
}, [profileData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const success = await updateProfileData({
nickname: username.trim() || undefined,
description: description.trim() || undefined
});
if (success) {
onOpenChange(false);
}
};
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) {
setSelectedImage(file);
setShowCropper(true);
}
};
const handleCropComplete = async (croppedImageData: string) => {
try {
// Convert data URL to blob
const response = await fetch(croppedImageData);
const blob = await response.blob();
const success = await uploadProfilePictureData(blob);
if (success) {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
} catch (error) {
console.error('Error processing cropped image:', error);
}
};
const handleCropCancel = () => {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
return (
<>
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
<div className="content">
<div className="header-top">
<div className="profile-picture-container">
<img
id="profile-picture"
src={profilePictureUrl}
alt="Ваше фото"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
<mdui-button-icon
icon="camera_alt--filled"
id="upload-pfp-btn"
className="upload-overlay"
variant="filled"
onClick={handleUploadClick}
disabled={isUpdating}
/>
<input
ref={fileInputRef}
type="file"
id="pfp-file-input"
accept="image/*"
style={{ display: "none" }}
onChange={handleImageSelect}
/>
</div>
<MaterialTextField
id="username-field"
label="Имя пользователя"
variant="outlined"
value={username}
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
autocomplete="username"
disabled={isLoading || isUpdating} />
</div>
<form id="profile-form" onSubmit={handleSubmit}>
<MaterialTextField
id="description-field"
label="О себе"
variant="outlined"
value={description}
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
placeholder="Расскажите о себе..."
autocomplete="none"
disabled={isLoading || isUpdating} />
<div className="dialog-actions">
<mdui-button
type="submit"
id="profile-submit"
disabled={isLoading || isUpdating}
>
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
</mdui-button>
<mdui-button
id="profile-dialog-close"
variant="outlined"
onClick={() => onOpenChange(false)}
disabled={isUpdating}
>
Закрыть
</mdui-button>
</div>
</form>
</div>
</MaterialDialog>
{/* Image Cropper Dialog */}
<MaterialDialog
id="cropper-dialog"
close-on-overlay-click
close-on-esc
open={showCropper}
onOpenChange={setShowCropper}
>
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" onClick={handleCropCancel} />
</div>
<div className="cropper-container">
<ImageCropper
imageFile={selectedImage}
onCrop={handleCropComplete}
onCancel={handleCropCancel}
/>
</div>
</div>
</MaterialDialog>
</>
);
}
@@ -1,212 +0,0 @@
import { useState, useEffect } from "react";
import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config";
import type { DialogProps } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../utils/push-notifications";
import { isElectron } from "../../../electron/electron";
import { useAppState } from "../../state";
import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "../../../auth/api";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings");
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
const [pushSupported, setPushSupported] = useState(false);
const user = useAppState(state => state.user);
useEffect(() => {
setPushSupported(isSupported());
// For Electron, we assume notifications are enabled if supported
// For web browsers, we check if there's a subscription
setPushNotificationsEnabled(isSupported());
}, []);
const handlePanelChange = (panelId: string) => {
setActivePanel(panelId);
};
const handlePushNotificationToggle = async (enabled: boolean) => {
if (!user.authToken) return;
try {
if (enabled) {
const initialized = await initialize();
if (initialized) {
await subscribe(user.authToken);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
}
setPushNotificationsEnabled(true);
}
} else {
await unsubscribe();
// For Electron, stop the notification receiver
if (isElectron) {
stopElectronReceiver();
}
// Call API to unsubscribe on server (for web browsers)
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
headers: getAuthHeaders(user.authToken)
});
setPushNotificationsEnabled(false);
}
} catch (error) {
console.error("Failed to toggle notifications:", error);
}
};
return (
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
<div className="fullscreen-wrapper">
<div id="settings-dialog-inner">
<div className="header">
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></mdui-button-icon>
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
</div>
<div id="settings-menu">
<mdui-list>
<mdui-list-item
icon="notifications--filled"
rounded
active={activePanel === "notifications-settings"}
onClick={() => handlePanelChange("notifications-settings")}
style={{ cursor: "pointer" }}
>
Уведомления
</mdui-list-item>
<mdui-list-item
icon="palette--filled"
rounded
active={activePanel === "appearance-settings"}
onClick={() => handlePanelChange("appearance-settings")}
style={{ cursor: "pointer" }}
>
Внешний вид
</mdui-list-item>
<mdui-list-item
icon="security--filled"
rounded
active={activePanel === "security-settings"}
onClick={() => handlePanelChange("security-settings")}
style={{ cursor: "pointer" }}
>
Безопасность
</mdui-list-item>
<mdui-list-item
icon="language--filled"
rounded
active={activePanel === "language-settings"}
onClick={() => handlePanelChange("language-settings")}
style={{ cursor: "pointer" }}
>
Язык
</mdui-list-item>
<mdui-list-item
icon="storage--filled"
rounded
active={activePanel === "storage-settings"}
onClick={() => handlePanelChange("storage-settings")}
style={{ cursor: "pointer" }}
>
Хранилище
</mdui-list-item>
<mdui-list-item
icon="help--filled"
rounded
active={activePanel === "help-settings"}
onClick={() => handlePanelChange("help-settings")}
style={{ cursor: "pointer" }}
>
Помощь
</mdui-list-item>
<mdui-list-item
icon="info--filled"
rounded
active={activePanel === "about-settings"}
onClick={() => handlePanelChange("about-settings")}
style={{ cursor: "pointer" }}
>
О приложении
</mdui-list-item>
</mdui-list>
<div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3>
{pushSupported && (
<mdui-switch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
>
Push уведомления
</mdui-switch>
)}
<mdui-switch checked>Новые сообщения</mdui-switch>
<mdui-switch checked>Звуковые уведомления</mdui-switch>
<mdui-switch>Уведомления о статусе</mdui-switch>
<mdui-switch checked>Email уведомления</mdui-switch>
</div>
<div id="appearance-settings" className={`settings-panel ${activePanel === "appearance-settings" ? "active" : ""}`}>
<h3>Внешний вид</h3>
<mdui-select label="Тема" variant="outlined">
<mdui-menu-item value="dark">Тёмная</mdui-menu-item>
<mdui-menu-item value="light">Светлая</mdui-menu-item>
<mdui-menu-item value="auto">Авто</mdui-menu-item>
</mdui-select>
<mdui-select label="Размер шрифта" variant="outlined">
<mdui-menu-item value="small">Маленький</mdui-menu-item>
<mdui-menu-item value="medium">Средний</mdui-menu-item>
<mdui-menu-item value="large">Большой</mdui-menu-item>
</mdui-select>
</div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<h3>Безопасность</h3>
<mdui-button variant="outlined">Изменить пароль</mdui-button>
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
<mdui-switch>Автоматический выход</mdui-switch>
</div>
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
<h3>Язык</h3>
<mdui-select label="Выберите язык" variant="outlined">
<mdui-menu-item value="ru">Русский</mdui-menu-item>
<mdui-menu-item value="en">English</mdui-menu-item>
<mdui-menu-item value="es">Español</mdui-menu-item>
</mdui-select>
</div>
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
<h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value={25}></mdui-linear-progress>
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div>
<div id="help-settings" className={`settings-panel ${activePanel === "help-settings" ? "active" : ""}`}>
<h3>Помощь</h3>
<mdui-button variant="outlined">Руководство пользователя</mdui-button>
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button>
<mdui-button variant="outlined">FAQ</mdui-button>
</div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
<h3>О приложении</h3>
<p>Версия: 1.0.0</p>
<p>© 2025 <span className="product-name">{PRODUCT_NAME}</span>. Все права защищены.</p>
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
<mdui-button variant="outlined">Условия использования</mdui-button>
</div>
</div>
</div>
</div>
</div>
</MaterialDialog>
);
}
-112
View File
@@ -1,112 +0,0 @@
import { useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state";
import { request } from "../../core/websocket";
import { API_BASE_URL } from "../../core/config";
import type { Message } from "../../core/types";
import { getAuthHeaders } from "../../auth/api";
import { delay } from "../../utils/utils";
export function useChat() {
const {
chat,
addMessage,
updateMessage,
removeMessage,
clearMessages,
setCurrentChat,
setActiveTab,
setDmUsers,
setActiveDm,
setIsChatSwitching,
user
} = useAppState();
const messagesLoadedRef = useRef(false);
// Load messages for the current chat
const loadMessages = useCallback(async () => {
if (!user.authToken || messagesLoadedRef.current) return;
try {
const response = await fetch(`${API_BASE_URL}/get_messages`, {
headers: getAuthHeaders(user.authToken)
});
if (response.ok) {
const data = await response.json();
if (data.messages && data.messages.length > 0) {
// Clear existing messages and add new ones
clearMessages();
data.messages.forEach((msg: Message) => {
addMessage(msg);
});
}
}
messagesLoadedRef.current = true;
} catch (error) {
console.error("Error loading messages:", error);
}
}, [user.authToken, addMessage, clearMessages]);
// Send a message
const sendMessage = useCallback(async (content: string) => {
if (!user.authToken || !content.trim()) return;
try {
const response = await request({
data: { content: content.trim() },
credentials: {
scheme: "Bearer",
credentials: user.authToken
},
type: "sendMessage"
});
if (response.error) {
console.error("Error sending message:", response.error);
}
} catch (error) {
console.error("Error sending message:", error);
}
}, [user.authToken]);
// WebSocket messages are now handled by the active panel
// No need for duplicate handling here
// Load messages only once when component mounts and user is authenticated
useEffect(() => {
if (user.authToken && !messagesLoadedRef.current) {
loadMessages();
}
}, [user.authToken, loadMessages]);
// Reset messages loaded flag and clear messages when chat changes
useEffect(() => {
(async () => {
setIsChatSwitching(true);
await delay(250);
messagesLoadedRef.current = false;
clearMessages(); // Clear messages when switching chats
loadMessages();
setIsChatSwitching(false);
})();
}, [chat.currentChat, clearMessages]);
return {
messages: chat.messages,
currentChat: chat.currentChat,
activeTab: chat.activeTab,
dmUsers: chat.dmUsers,
activeDm: chat.activeDm,
isChatSwitching: chat.isChatSwitching,
setIsChatSwitching,
sendMessage,
updateMessage,
removeMessage,
clearMessages,
setCurrentChat,
setActiveTab,
setDmUsers,
setActiveDm
};
}
-299
View File
@@ -1,299 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state";
import {
fetchUsers,
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
sendDMViaWebSocket
} from "../../api/dmApi";
import type { User, Message, DmEncryptedJSON } from "../../core/types";
import { websocket } from "../../core/websocket";
interface DMUser extends User {
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
}
export function useDM() {
const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState();
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const usersLoadedRef = useRef(false);
// Load last message and unread count for a specific user
const loadUserLastMessage = useCallback(async (dmUser: DMUser) => {
if (!user.authToken) return;
try {
// Get public key
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
// Get message history
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
if (messages.length === 0) return;
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
try {
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
console.log(lastPlaintext);
} catch (error) {
console.error("Failed to decrypt last message:", error);
}
// Calculate unread count
const lastReadId = getLastReadId(dmUser.id);
let unreadCount = 0;
for (const msg of messages) {
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
unreadCount++;
}
}
// Update user state
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
unreadCount,
publicKey
}
: u
));
} catch (error) {
console.error("Failed to load last message for user:", dmUser.id, error);
}
}, [user.authToken]);
// Load users when DM tab is active
const loadUsers = useCallback(async () => {
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
usersLoadedRef.current = true;
setIsLoadingUsers(true);
try {
const users = await fetchUsers(user.authToken);
console.log("Fetched users:", users);
const dmUsersWithState: DMUser[] = users.map(user => ({
...user,
unreadCount: 0,
lastMessage: undefined,
publicKey: null
}));
setDmUsersState(dmUsersWithState);
setDmUsers(users);
// Load last messages and unread counts for visible users
// Call loadUserLastMessage directly without dependency
for (const dmUser of dmUsersWithState) {
await loadUserLastMessage(dmUser);
}
} catch (error) {
console.error("Failed to load DM users:", error);
} finally {
setIsLoadingUsers(false);
}
}, [user.authToken, isLoadingUsers]);
// Reset users loaded flag when user changes
useEffect(() => {
usersLoadedRef.current = false;
}, [user.authToken]);
// Load DM history for active conversation
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
if (!user.authToken || isLoadingHistory) return;
setIsLoadingHistory(true);
try {
const messages = await fetchDMHistory(userId, user.authToken, 50);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
const text = await decryptDm(env, publicKey);
const isAuthor = env.senderId !== userId;
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
decryptedMessages.push({
id: env.id,
content: text,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false
});
if (env.senderId === userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (error) {
console.error("Error decrypting message:", error);
}
}
clearMessages();
decryptedMessages.forEach(msg => addMessage(msg));
// Update last read ID
if (maxIncomingId > 0) {
setLastReadId(userId, maxIncomingId);
// Clear unread count
setDmUsersState(prev => prev.map(u =>
u.id === userId ? { ...u, unreadCount: 0 } : u
));
}
} catch (error) {
console.error("Failed to load DM history:", error);
} finally {
setIsLoadingHistory(false);
}
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
// Send DM message
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
if (!user.authToken) return;
try {
await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken);
} catch (error) {
console.error("Failed to send DM:", error);
}
}, [user.authToken]);
// Start DM conversation
const startDMConversation = useCallback(async (dmUser: DMUser) => {
if (!user.authToken) return;
try {
// Get public key if not already loaded
let publicKey = dmUser.publicKey;
if (!publicKey) {
publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
}
// Set active DM
setActiveDm({
userId: dmUser.id,
username: dmUser.username,
publicKey
});
// Load conversation history
await loadDMHistory(dmUser.id, publicKey);
} catch (error) {
console.error("Failed to start DM conversation:", error);
}
}, [user.authToken, setActiveDm, loadDMHistory]);
// WebSocket message handler
useEffect(() => {
const handleWebSocketMessage = async (e: MessageEvent) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "dmNew") {
const { senderId, recipientId, ...envelope } = msg.data;
// If this is for the active DM conversation
if (chat.activeDm && (senderId === chat.activeDm.userId || recipientId === chat.activeDm.userId)) {
try {
const plaintext = await decryptDm(envelope, chat.activeDm.publicKey!);
const isAuthor = senderId !== chat.activeDm.userId;
addMessage({
id: envelope.id,
content: plaintext,
username: isAuthor ? (user.currentUser?.username || "Unknown") : (chat.activeDm.username || "Unknown"),
timestamp: envelope.timestamp,
is_read: false,
is_edited: false
});
// Update last read if it's from the other user
if (senderId === chat.activeDm.userId) {
setLastReadId(chat.activeDm.userId, Math.max(getLastReadId(chat.activeDm.userId), envelope.id));
}
} catch (error) {
console.error("Failed to decrypt incoming DM:", error);
}
} else {
// Update unread count for other users
const otherUserId = senderId;
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? { ...u, unreadCount: u.unreadCount + 1 }
: u
));
// Update last message preview
try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const plaintext = await decryptDm(envelope, publicKey);
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
lastMessage: plaintext.split(/\r?\n/).slice(0, 2).join("\n"),
publicKey
}
: u
));
}
} catch (error) {
console.error("Failed to update last message preview:", error);
}
}
}
} catch (error) {
console.error("Failed to handle WebSocket message:", error);
}
};
websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [chat.activeDm, user.currentUser, addMessage]);
// Force reload users (useful for refreshing the list)
const reloadUsers = useCallback(() => {
usersLoadedRef.current = false;
loadUsers();
}, [loadUsers]);
return {
dmUsers,
isLoadingUsers,
isLoadingHistory,
loadUsers,
reloadUsers,
startDMConversation,
sendDMMessage,
loadUserLastMessage
};
}
// Helper functions for localStorage
function getLastReadId(userId: number): number {
try {
const v = localStorage.getItem(`dmLastRead:${userId}`);
return v ? Number(v) : 0;
} catch {
return 0;
}
}
function setLastReadId(userId: number, id: number): void {
try {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {}
}

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