56 Commits

134 changed files with 6546 additions and 2505 deletions
+4
View File
@@ -0,0 +1,4 @@
View git diff between the branch i specified and HEAD. If no branch is specified,
default to main. Identify code that needs to be cleaned up, like debug logs,
unused variables etc. Think twice before removing or adding code, because you
mustn't alter the behavior.
+1
View File
@@ -0,0 +1 @@
Analyze my codebase and think how it could be better organized, like a better folder or code structure.
+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.
+39 -7
View File
@@ -4,17 +4,49 @@ 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.
## File Operations
- If possible, try to update files in a single edit when making multiple changes.
- Do NOT "cd" to the project directory.
## 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.
## Async Operations
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
make it async. The import is `<project>/frontend/src/utils/utils`.
- When you complete your task, remove unused imports if there are any.
make it async.
## Database
- NEVER create database migrations, they are auto-generated.
## Project Structure Awareness
- This is a React/TypeScript frontend with Python FastAPI backend
- Uses MDUI components for UI
- Has Electron support for desktop app
- Uses Zustand for state management
- Uses use-immer for immutable state updates
- Uses React Router for navigation
- Has WebSocket support for real-time features
- Uses encryption (tweetnacl) for security
## Performance & Efficiency
- Batch tool calls when possible to reduce latency
- Use semantic search before grep when looking for concepts
- Use TODOs for complex multi-step tasks to track progress
-2
View File
@@ -1,10 +1,8 @@
---
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.
3. The supporting text slot for MDUI lists is "description".
4. When working with lists/sets in states, use the "useImmer" hook.
+198 -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
@@ -203,6 +203,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
@@ -353,6 +355,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 +562,15 @@ 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/data
.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
+147
View File
@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite:///./data/database.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+78
View File
@@ -0,0 +1,78 @@
from logging.config import fileConfig
import logging
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
+31 -13
View File
@@ -1,12 +1,39 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from migration import run_auto_migration
from db import engine
from contextlib import asynccontextmanager
import subprocess
import sys
import os
from routes import account, messaging, profile, push
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup - run migration in separate process to avoid logging interference
try:
print("Starting database migration check...")
# Run migration in a separate process
subprocess.run(
[
sys.executable,
"-c",
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
],
cwd=os.path.dirname(os.path.abspath(__file__))
# No capture_output - let it stream to terminal in real-time
# No text=True - let it use the terminal's encoding
)
except Exception as e:
print(f"Failed to run database migrations: {e}")
raise
yield
# Shutdown (if needed in the future)
# logger.info("Application shutdown")
# Инициализация FastAPI
app = FastAPI(title="PixelChat")
app = FastAPI(title="FromChat", lifespan=lifespan)
# CORS
app.add_middleware(
@@ -21,13 +48,4 @@ app.add_middleware(
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
app.include_router(push.router, prefix="/push")
+511 -80
View File
@@ -1,92 +1,523 @@
from __future__ import annotations
"""
Database migration utility using Alembic.
This module handles running database migrations on startup.
"""
import os
from pathlib import Path
from typing import Optional
from traceback import format_exc
import hashlib
from sqlalchemy.engine import Engine
import logging
from alembic import command
from alembic.config import Config
from models import Base
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine
from constants import DATABASE_URL
import logging
logger = logging.getLogger(__name__)
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()
def run_migrations():
"""
Run database migrations using Alembic.
This function will upgrade the database to the latest migration.
Fully automated - handles all scenarios automatically.
"""
try:
# Upgrade existing migrations (if any) first
command.upgrade(cfg, "head")
except Exception:
print("[alembic] upgrade to head failed:\n" + format_exc())
# Get the directory where this script is located
current_dir = os.path.dirname(os.path.abspath(__file__))
# Create Alembic configuration
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
# Disable Alembic's logging configuration to avoid interfering with FastAPI
alembic_cfg.set_main_option("configure_logging", "false")
# Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Check if any migration files exist
versions_dir = os.path.join(current_dir, "alembic", "versions")
# 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 not os.path.exists(versions_dir):
os.makedirs(versions_dir)
if have_lock:
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if not migration_files:
logger.info("No migration files found. Creating initial migration...")
# Check if database exists and has tables
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import text
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
existing_tables = result.fetchall()
if existing_tables:
logger.info("Found existing database with tables. Creating migration to match current schema...")
# Create migration with autogenerate to detect differences
command.revision(alembic_cfg, autogenerate=True, message="Initial migration from existing database")
# Check if the generated migration is empty (common with existing databases)
versions_dir = os.path.join(current_dir, "alembic", "versions")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if migration_files:
latest_migration = max(migration_files)
migration_path = os.path.join(versions_dir, latest_migration)
# Check if migration is empty
with open(migration_path, 'r') as f:
content = f.read()
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content:
logger.info("Generated migration is empty. Creating complete schema migration...")
# Remove the empty migration
os.remove(migration_path)
# Create a complete migration
_create_complete_migration(alembic_cfg)
else:
logger.info("No existing tables found. Creating fresh migration...")
# Create fresh migration
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
logger.info("Initial migration created successfully.")
else:
# Migration files exist, check if we need to create a new migration for schema changes
logger.info("Migration files exist. Checking for pending schema changes...")
try:
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())
# Create a new migration to detect any schema changes
command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration for schema changes")
# Check if the new migration is empty (no changes detected)
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if migration_files:
latest_migration = max(migration_files)
migration_path = os.path.join(versions_dir, latest_migration)
# Check if migration is empty
with open(migration_path, 'r') as f:
content = f.read()
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content and 'op.drop_table' not in content and 'op.drop_column' not in content:
logger.info("No schema changes detected. Removing empty migration...")
# Remove the empty migration
os.remove(migration_path)
else:
logger.info("Schema changes detected. New migration created.")
except Exception as e:
logger.info(f"No new migrations needed or error creating migration: {e}")
pass
# Run the upgrade command
logger.info("Running database migrations...")
try:
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully.")
except Exception as upgrade_error:
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
logger.info("Found 'direct_creation' revision - resetting migration state...")
# Clear the alembic_version table and start fresh
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import text
connection.execute(text("DELETE FROM alembic_version"))
connection.commit()
# Try upgrade again
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.")
else:
raise upgrade_error
except Exception as e:
logger.error(f"Error running database migrations: {e}")
# Fully automated recovery - handle ALL error scenarios
logger.info("Attempting automated recovery...")
try:
# Clear the alembic_version table to reset state
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import text
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
connection.commit()
# Remove any existing migration files to start fresh
versions_dir = os.path.join(current_dir, "alembic", "versions")
for file in os.listdir(versions_dir):
if file.endswith('.py') and not file.startswith('__'):
os.remove(os.path.join(versions_dir, file))
# Create a completely fresh migration with full schema
logger.info("Creating fresh migration with complete schema...")
_create_complete_migration(alembic_cfg)
# Run the migration
command.upgrade(alembic_cfg, "head")
logger.info("Automated recovery completed successfully.")
except Exception as recovery_error:
logger.error(f"Automated recovery failed: {recovery_error}")
# Last resort: create database using SQLAlchemy directly
logger.info("Using fallback: creating database directly...")
_create_database_directly()
logger.info("Database created successfully using fallback method.")
def _create_complete_migration(alembic_cfg):
"""Create a complete migration file with all database schema."""
# Create a new migration file
command.revision(alembic_cfg, message="Complete schema migration")
# Get the latest migration file
versions_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "alembic", "versions")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
latest_migration = max(migration_files) if migration_files else None
if latest_migration:
migration_path = os.path.join(versions_dir, latest_migration)
_populate_migration_file(migration_path)
def _populate_migration_file(migration_path):
"""Populate a migration file with the complete database schema from models."""
# Generate the migration content dynamically from models
migration_content = _generate_migration_from_models()
# Read the current migration file
with open(migration_path, 'r') as f:
content = f.read()
# Add datetime import if needed
if "datetime.now" in migration_content and "from datetime import datetime" not in content:
# Insert the import after the existing imports
import re
content = re.sub(
r'(from alembic import op\nimport sqlalchemy as sa\n)',
r'\1from datetime import datetime\n',
content
)
# Replace the empty upgrade/downgrade functions
import re
# More flexible regex to match the actual content
content = re.sub(
r'def upgrade\(\) -> None:.*?pass.*?(?=\n\ndef downgrade|\n\nif __name__|\Z)',
migration_content,
content,
flags=re.DOTALL
)
# Write the updated content back
with open(migration_path, 'w') as f:
f.write(content)
def _generate_migration_from_models():
"""Generate migration content dynamically from SQLAlchemy models."""
from models import Base
import sqlalchemy as sa
from datetime import datetime
# Generate migration content using Alembic's op functions
upgrade_statements = []
downgrade_statements = []
# Get all tables from Base metadata
for table_name, table in Base.metadata.tables.items():
if table_name != 'alembic_version': # Skip alembic_version table
# Check if table exists and compare schema
schema_diff = _detect_schema_differences(table_name, table)
if schema_diff['table_exists']:
if schema_diff['needs_update']:
# Generate ALTER TABLE statements for existing table
upgrade_statements.append(f" # Update {table_name} table schema")
for statement in schema_diff['alter_statements']:
upgrade_statements.append(f" {statement}")
else:
# Table exists and is up to date - skip creating it
upgrade_statements.append(f" # Table {table_name} already exists and is up to date")
else:
# Generate CREATE TABLE for new table
table_code = _generate_table_creation_code(table_name, table)
upgrade_statements.append(f" # Create {table_name} table")
upgrade_statements.append(table_code)
# Only add to downgrade if table actually exists
if schema_diff['table_exists']:
downgrade_statements.append(f" # op.drop_table('{table_name}') # Skipped - table exists")
else:
downgrade_statements.append(f" op.drop_table('{table_name}')")
# Combine all statements
upgrade_content = "def upgrade() -> None:\n \"\"\"Upgrade schema.\"\"\"\n" + "\n".join(upgrade_statements)
downgrade_content = "def downgrade() -> None:\n \"\"\"Downgrade schema.\"\"\"\n" + "\n".join(downgrade_statements)
return upgrade_content + "\n\n" + downgrade_content
def _detect_schema_differences(table_name, expected_table):
"""Detect differences between existing table and expected schema."""
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import text, inspect
# Check if table exists
inspector = inspect(connection)
if table_name not in inspector.get_table_names():
return {
'table_exists': False,
'needs_update': False,
'alter_statements': []
}
# Get existing columns
existing_columns = inspector.get_columns(table_name)
existing_column_names = {col['name'] for col in existing_columns}
# Get expected columns
expected_column_names = {col.name for col in expected_table.columns}
# Check for missing columns
missing_columns = expected_column_names - existing_column_names
extra_columns = existing_column_names - expected_column_names
alter_statements = []
# Add missing columns
for column in expected_table.columns:
if column.name in missing_columns:
column_def = _generate_column_definition(column)
alter_statements.append(f"op.add_column('{table_name}', {column_def})")
# Add missing indexes
for index in expected_table.indexes:
if not index.unique:
cols = "', '".join([col.name for col in index.columns])
alter_statements.append(f"op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
return {
'table_exists': True,
'needs_update': len(alter_statements) > 0,
'alter_statements': alter_statements
}
def _generate_column_definition(column):
"""Generate column definition for ALTER TABLE."""
type_def = _get_column_type(column)
nullable = "nullable=True" if column.nullable else "nullable=False"
definition = f"sa.Column('{column.name}', {type_def}, {nullable}"
# Handle default values properly
if column.default is not None:
if hasattr(column.default, 'arg'):
# Handle callable defaults
if callable(column.default.arg):
definition += f", default=datetime.now"
else:
definition += f", default={repr(column.default.arg)}"
else:
definition += f", default={repr(column.default)}"
definition += ")"
return definition
def _generate_table_creation_code(table_name, table):
"""Generate op.create_table code for a SQLAlchemy table."""
lines = [f" op.create_table('{table_name}',"]
# Collect all table items (columns + constraints)
all_items = []
# Add columns
for column in table.columns:
column_def = f" sa.Column('{column.name}', {_get_column_type(column)}, nullable={column.nullable}"
if column.default is not None:
# Handle callable defaults properly
if hasattr(column.default, 'arg') and callable(column.default.arg):
column_def += f", default=datetime.now"
else:
column_def += f", default={repr(column.default)}"
column_def += ")"
all_items.append(column_def)
# Add constraints
for constraint in table.constraints:
if hasattr(constraint, 'columns'):
if constraint.__class__.__name__ == 'PrimaryKeyConstraint':
all_items.append(f" sa.PrimaryKeyConstraint('{constraint.columns.keys()[0]}')")
elif constraint.__class__.__name__ == 'UniqueConstraint':
cols = "', '".join(constraint.columns.keys())
all_items.append(f" sa.UniqueConstraint('{cols}')")
# Add foreign key constraints
for fk in table.foreign_keys:
all_items.append(f" sa.ForeignKeyConstraint(['{fk.parent.name}'], ['{fk.column.table.name}.{fk.column.name}'], )")
# Add all items with commas (except the last one)
for i, item in enumerate(all_items):
if i < len(all_items) - 1:
item += ","
lines.append(item)
lines.append(" )")
# Add indexes with IF NOT EXISTS equivalent using try/except
for index in table.indexes:
if not index.unique:
cols = "', '".join([col.name for col in index.columns])
lines.append(f" # Create index for {table_name}")
lines.append(f" try:")
lines.append(f" op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
lines.append(f" except Exception:")
lines.append(f" pass # Index may already exist")
return "\n".join(lines)
def _get_column_type(column):
"""Get SQLAlchemy column type string."""
type_name = column.type.__class__.__name__
if type_name == 'String':
return f"sa.String(length={column.type.length})"
elif type_name == 'Integer':
return "sa.Integer()"
elif type_name == 'Text':
return "sa.Text()"
elif type_name == 'Boolean':
return "sa.Boolean()"
elif type_name == 'DateTime':
return "sa.DateTime()"
else:
return f"sa.{type_name}()"
def _create_database_directly():
"""Fallback method: create database directly using SQLAlchemy."""
from models import Base
from db import engine
from sqlalchemy import text, inspect
# Check existing tables and update schema
with engine.connect() as connection:
inspector = inspect(connection)
existing_tables = inspector.get_table_names()
# For each model table, check if it needs updates
for table_name, table in Base.metadata.tables.items():
if table_name != 'alembic_version':
if table_name in existing_tables:
# Table exists, check for missing columns
existing_columns = {col['name'] for col in inspector.get_columns(table_name)}
expected_columns = {col.name for col in table.columns}
missing_columns = expected_columns - existing_columns
# Add missing columns
for column in table.columns:
if column.name in missing_columns:
# Convert to raw SQL for direct execution
sql_type = _get_sql_type(column)
nullable = "NULL" if column.nullable else "NOT NULL"
# Handle datetime columns without default (SQLite limitation)
if column.type.__class__.__name__ == 'DateTime':
# Add column without default, then update existing rows
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}"
try:
connection.execute(text(alter_sql))
logger.info(f"Added column {column.name} to {table_name}")
# Update existing rows with current timestamp
update_sql = f"UPDATE {table_name} SET {column.name} = CURRENT_TIMESTAMP WHERE {column.name} IS NULL"
connection.execute(text(update_sql))
logger.info(f"Updated {column.name} with current timestamp")
except Exception as e:
logger.error(f"Could not add column {column.name}: {e}")
else:
# Handle other column types with defaults
default_clause = ""
if column.default is not None:
if hasattr(column.default, 'arg') and callable(column.default.arg):
# Skip callable defaults for SQLite compatibility
pass
elif hasattr(column.default, 'arg'):
default_clause = f" DEFAULT {repr(column.default.arg)}"
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}{default_clause}"
try:
connection.execute(text(alter_sql))
logger.info(f"Added column {column.name} to {table_name}")
except Exception as e:
logger.error(f"Could not add column {column.name}: {e}")
else:
# Table doesn't exist, create it
logger.info(f"Creating table {table_name}")
# Create alembic_version table manually
connection.execute(text("""
CREATE TABLE IF NOT EXISTS alembic_version (
version_num VARCHAR(32) NOT NULL,
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)
"""))
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
connection.commit()
def _get_sql_type(column):
"""Get SQL type for direct SQL execution."""
type_name = column.type.__class__.__name__
if type_name == 'String':
return f"VARCHAR({column.type.length})"
elif type_name == 'Integer':
return "INTEGER"
elif type_name == 'Text':
return "TEXT"
elif type_name == 'Boolean':
return "BOOLEAN"
elif type_name == 'DateTime':
return "DATETIME"
else:
return "TEXT" # fallback
def check_migration_status():
"""
Check if the database needs migrations.
Returns True if migrations are needed, False otherwise.
"""
try:
# Create engine
engine = create_engine(DATABASE_URL)
# Check if alembic_version table exists
with engine.connect() as connection:
# Check if alembic_version table exists
from sqlalchemy import text
result = connection.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'")
)
alembic_table_exists = result.fetchone() is not None
if not alembic_table_exists:
return True
# Get current migration context
context = MigrationContext.configure(connection)
current_rev = context.get_current_revision()
# Get the latest revision from alembic
current_dir = os.path.dirname(os.path.abspath(__file__))
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
script_dir = command.ScriptDirectory.from_config(alembic_cfg)
head_rev = script_dir.get_current_head()
return current_rev != head_rev
except Exception as e:
logger.error(f"Error checking migration status: {e}")
return True # Assume migrations are needed if we can't check
if __name__ == "__main__":
# This allows running migrations directly
run_migrations()
-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
+73 -5
View File
@@ -1,8 +1,7 @@
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint
from sqlalchemy.orm import relationship
from datetime import datetime
from db import engine
from pydantic import BaseModel
Base = declarative_base()
@@ -37,6 +36,7 @@ class Message(Base):
author = relationship("User", back_populates="messages")
reply_to = relationship("Message", remote_side=[id])
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select")
class MessageFile(Base):
@@ -80,6 +80,7 @@ class DMEnvelope(Base):
reply_to_id = Column(Integer, nullable=True)
timestamp = Column(DateTime, default=datetime.now)
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
class DMFile(Base):
@@ -107,6 +108,39 @@ class PushSubscription(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Reaction(Base):
__tablename__ = "reaction"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
# Ensure unique combination of message, user, and emoji
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
class DMReaction(Base):
__tablename__ = "dm_reaction"
id = Column(Integer, primary_key=True, index=True)
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
# Ensure unique combination of dm_envelope, user, and emoji
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
# Pydantic модели
class LoginRequest(BaseModel):
username: str
@@ -121,7 +155,7 @@ class RegisterRequest(BaseModel):
class SendMessageRequest(BaseModel):
content: str
reply_to_id: int | None
reply_to_id: int | None = None
class EditMessageRequest(BaseModel):
@@ -167,5 +201,39 @@ class MessageResponse(BaseModel):
from_attributes = True
# Создание таблиц
Base.metadata.create_all(bind=engine)
class ReactionRequest(BaseModel):
message_id: int
emoji: str
class ReactionResponse(BaseModel):
id: int
message_id: int
user_id: int
emoji: str
timestamp: datetime
username: str
class Config:
from_attributes = True
class DMReactionRequest(BaseModel):
dm_envelope_id: int
emoji: str
class DMReactionResponse(BaseModel):
id: int
dm_envelope_id: int
user_id: int
emoji: str
timestamp: datetime
username: str
class Config:
from_attributes = True
# Tables are now created through Alembic migrations
# Base.metadata.create_all(bind=engine)
+1 -1
View File
@@ -9,4 +9,4 @@ python-multipart>=0.0.6
pywebpush>=1.14.0
cryptography>=41.0.0
alembic>=1.13.2
better-profanity>=0.7.0
better-profanity>=0.7.0
+5 -1
View File
@@ -116,9 +116,13 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
db.commit()
db.refresh(new_user)
token = create_token(new_user.id, new_user.username)
return {
"status": "success",
"message": "Регистрация прошла успешно Теперь вы можете войти."
"message": "Регистрация прошла успешно",
"token": token,
"user": convert_user(new_user)
}
@router.get("/crypto/public-key")
+239 -1
View File
@@ -10,7 +10,7 @@ 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 models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse
from push_service import push_service
from PIL import Image
import io
@@ -31,6 +31,23 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
def convert_message(msg: Message) -> dict:
# Group reactions by emoji
reactions_dict = {}
if msg.reactions:
for reaction in msg.reactions:
emoji = reaction.emoji
if emoji not in reactions_dict:
reactions_dict[emoji] = {
"emoji": emoji,
"count": 0,
"users": []
}
reactions_dict[emoji]["count"] += 1
reactions_dict[emoji]["users"].append({
"id": reaction.user_id,
"username": reaction.user.username
})
return {
"id": msg.id,
"content": msg.content,
@@ -40,6 +57,7 @@ def convert_message(msg: Message) -> dict:
"username": msg.author.username,
"profile_picture": msg.author.profile_picture,
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
"reactions": list(reactions_dict.values()),
"files": [
{
"path": f"/api/uploads/files/normal/{Path(f.path).name}",
@@ -51,6 +69,47 @@ def convert_message(msg: Message) -> dict:
]
}
def convert_dm_envelope(envelope: DMEnvelope) -> dict:
# Group reactions by emoji
reactions_dict = {}
if envelope.reactions:
for reaction in envelope.reactions:
emoji = reaction.emoji
if emoji not in reactions_dict:
reactions_dict[emoji] = {
"emoji": emoji,
"count": 0,
"users": []
}
reactions_dict[emoji]["count"] += 1
reactions_dict[emoji]["users"].append({
"id": reaction.user_id,
"username": reaction.user.username
})
return {
"id": envelope.id,
"senderId": envelope.sender_id,
"recipientId": envelope.recipient_id,
"iv": envelope.iv_b64,
"ciphertext": envelope.ciphertext_b64,
"salt": envelope.salt_b64,
"iv2": envelope.iv2_b64,
"wrappedMk": envelope.wrapped_mk_b64,
"timestamp": envelope.timestamp.isoformat(),
"reactions": list(reactions_dict.values()),
"files": [
{
"path": f"/api/uploads/files/encrypted/{Path(f.path).name}",
"id": f.id,
"name": f.name,
"dm_envelope_id": f.dm_envelope_id
}
for f in (envelope.files or [])
]
}
# для тех кто читает этот код я эти маты не писал
# мат писал ии а я сам не матерюсь))
# - denis0001-dev
@@ -436,6 +495,125 @@ async def delete_message(
return {"status": "success", "message_id": message_id}
@router.post("/add_reaction")
async def add_reaction(
request: ReactionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Check if message exists
message = db.query(Message).filter(Message.id == request.message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
# Check if reaction already exists
existing_reaction = db.query(Reaction).filter(
Reaction.message_id == request.message_id,
Reaction.user_id == current_user.id,
Reaction.emoji == request.emoji
).first()
if existing_reaction:
# Remove existing reaction (toggle off)
db.delete(existing_reaction)
action = "removed"
else:
# Add new reaction
new_reaction = Reaction(
message_id=request.message_id,
user_id=current_user.id,
emoji=request.emoji
)
db.add(new_reaction)
action = "added"
db.commit()
# Refresh message to get updated reactions
db.refresh(message)
# Broadcast reaction update
try:
from .messaging import messagingManager
await messagingManager.broadcast({
"type": "reactionUpdate",
"data": {
"message_id": request.message_id,
"emoji": request.emoji,
"action": action,
"user_id": current_user.id,
"username": current_user.username,
"reactions": convert_message(message)["reactions"]
}
})
except Exception:
pass
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
@router.post("/dm/add_reaction")
async def add_dm_reaction(
request: DMReactionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Check if DM envelope exists
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first()
if not envelope:
raise HTTPException(status_code=404, detail="DM envelope not found")
# Check if user is part of this DM conversation
if current_user.id not in [envelope.sender_id, envelope.recipient_id]:
raise HTTPException(status_code=403, detail="Not authorized to react to this message")
# Check if reaction already exists
existing_reaction = db.query(DMReaction).filter(
DMReaction.dm_envelope_id == request.dm_envelope_id,
DMReaction.user_id == current_user.id,
DMReaction.emoji == request.emoji
).first()
if existing_reaction:
# Remove existing reaction (toggle off)
db.delete(existing_reaction)
action = "removed"
else:
# Add new reaction
new_reaction = DMReaction(
dm_envelope_id=request.dm_envelope_id,
user_id=current_user.id,
emoji=request.emoji
)
db.add(new_reaction)
action = "added"
db.commit()
# Refresh envelope to get updated reactions
db.refresh(envelope)
# Broadcast reaction update to both participants
try:
from .messaging import messagingManager
await messagingManager.broadcast({
"type": "dmReactionUpdate",
"data": {
"dm_envelope_id": request.dm_envelope_id,
"emoji": request.emoji,
"action": action,
"user_id": current_user.id,
"username": current_user.username,
"reactions": convert_dm_envelope(envelope)["reactions"]
}
})
except Exception:
pass
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
class MessaggingSocketManager:
def __init__(self) -> None:
self.connections: list[WebSocket] = []
@@ -670,6 +848,66 @@ class MessaggingSocketManager:
"data": {"message_id": message_id}
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "addReaction":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
request_data = data["data"]
reaction_request = ReactionRequest(
message_id=request_data["message_id"],
emoji=request_data["emoji"]
)
response = await add_reaction(reaction_request, current_user, db)
# Broadcast reaction update
await self.broadcast({
"type": "reactionUpdate",
"data": {
"message_id": request_data["message_id"],
"emoji": request_data["emoji"],
"action": response["action"],
"user_id": current_user.id,
"username": current_user.username,
"reactions": response["reactions"]
}
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "addDmReaction":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
request_data = data["data"]
reaction_request = DMReactionRequest(
dm_envelope_id=request_data["dm_envelope_id"],
emoji=request_data["emoji"]
)
response = await add_dm_reaction(reaction_request, current_user, db)
# Broadcast reaction update
await self.broadcast({
"type": "dmReactionUpdate",
"data": {
"dm_envelope_id": request_data["dm_envelope_id"],
"emoji": request_data["emoji"],
"action": response["action"],
"user_id": current_user.id,
"username": current_user.username,
"reactions": response["reactions"]
}
})
await websocket.send_json({"type": type, "data": response})
except HTTPException as e:
await self.send_error(websocket, type, e)
+1 -11
View File
@@ -17,8 +17,6 @@ services:
target: /app
- action: rebuild
path: ../backend/requirements.txt
networks:
- main
frontend:
build:
@@ -40,15 +38,7 @@ services:
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
name: fromchat-data
+6 -1
View File
@@ -11,6 +11,7 @@ RUN --mount=type=cache,target=/root/.npm \
COPY frontend frontend
RUN npm run frontend:build
# 2. Build the static file server
FROM node:24 AS server
@@ -23,6 +24,10 @@ RUN --mount=type=cache,target=/root/.npm \
# 2.2. Copy the code
COPY deployment/frontend/ .
# 2.3. Build
RUN npm run build
# 3. Put it all together
FROM node:24-slim
@@ -44,4 +49,4 @@ COPY --from=server --chown=app /server .
# 4. Final command
ENV STATIC_FILE_PATH=/app
ENTRYPOINT ["npm", "run", "start"]
ENTRYPOINT ["npm", "run", "start:prod"]
+9 -1
View File
@@ -3,10 +3,18 @@
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
"start": "ts-node server.ts",
"build": "tsc -b",
"start:prod": "node dist/server.js"
},
"dependencies": {
"express": "^5.1.0",
"http-proxy-middleware": "^3.0.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.0",
"ts-node": "^10.9.0"
}
}
-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}`);
});
+28
View File
@@ -0,0 +1,28 @@
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { resolve } from 'path';
const app = express();
const port = process.env.PORT || 3000;
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
const filePath = process.env.STATIC_FILE_PATH || ".";
// API proxy middleware
app.use('/api', createProxyMiddleware({
target: backendHost,
changeOrigin: true,
pathRewrite: { '^/api': '' },
ws: true
}));
// Serve static files
app.use(express.static(resolve(filePath)));
// SPA routing - catch all handler for client-side routing
app.use((_req, res) => {
res.sendFile(resolve(filePath, 'index.html'));
});
app.listen(port, () => {
console.log(`Server launched on http://localhost:${port}`);
});
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./",
"declaration": true,
"sourceMap": true
},
"include": [
"server.ts"
],
"exclude": [
"node_modules",
"dist"
]
}
+1 -2
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
import path from "node:path";
import { NotificationShowOptions } from '../electron';
import type { NotificationShowOptions } from '../electron.d.ts';
let mainWindow: BrowserWindow | null = null;
@@ -13,7 +13,6 @@ app.whenReady().then(() => {
preload: path.join(import.meta.dirname, "preload.mjs")
},
titleBarStyle: "hidden",
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
trafficLightPosition: {
x: 16 - 4,
y: 16 - 4
+141
View File
@@ -0,0 +1,141 @@
import js from "@eslint/js";
import typescript from "@typescript-eslint/eslint-plugin";
import typescriptParser from "@typescript-eslint/parser";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
js.configs.recommended,
{
files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true
}
}
},
plugins: {
"@typescript-eslint": typescript,
"react": react,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"jsx-a11y": jsxA11y
},
rules: {
// TypeScript rules
...typescript.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "off",
// React rules
...react.configs.recommended.rules,
"react/react-in-jsx-scope": "off", // Not needed with React 17+
"react/prop-types": "off", // Using TypeScript instead
"react/jsx-uses-react": "off", // Not needed with React 17+
"react/jsx-uses-vars": "error",
"react/jsx-no-undef": "error",
"react/jsx-key": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-pascal-case": "error",
"react/no-array-index-key": "off",
"react/no-danger": "off",
"react/no-deprecated": "error",
"react/no-direct-mutation-state": "error",
"react/no-unescaped-entities": "error",
"react/no-unknown-property": "error",
"react/require-render-return": "error",
"react/self-closing-comp": "error",
"react/jsx-wrap-multilines": "error",
"react/jsx-closing-bracket-location": "off",
"react/jsx-closing-tag-location": "error",
"react/jsx-curly-spacing": ["error", "never"],
"react/jsx-equals-spacing": ["error", "never"],
"react/jsx-first-prop-new-line": ["off", "multiline-multiprop"],
"react/jsx-max-props-per-line": ["error", { maximum: 2, when: "multiline" }],
"react/jsx-no-bind": "off",
"react/jsx-no-literals": "off",
"react/jsx-sort-props": "off",
// React Hooks rules
...reactHooks.configs.recommended.rules,
// React Refresh rules
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true }
],
// Accessibility rules
...jsxA11y.configs.recommended.rules,
"jsx-a11y/alt-text": "off",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/img-redundant-alt": "warn",
"jsx-a11y/no-access-key": "error",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/scope": "error",
"jsx-a11y/tabindex-no-positive": "error",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/anchor-is-valid": "off",
// General JavaScript/TypeScript rules
"no-console": "off",
"no-debugger": "error",
"no-unused-vars": "off", // Handled by TypeScript version
"prefer-const": "error",
"no-var": "error",
"no-undef": "off", // Handled by TypeScript version
"eqeqeq": ["error", "always"],
"curly": "off", // Changed from error to warn
"brace-style": ["off", "1tbs"],
"comma-dangle": "warn", // Changed from error to warn
"comma-spacing": ["error", { before: false, after: true }],
"comma-style": ["error", "last"],
"computed-property-spacing": ["error", "never"],
"func-call-spacing": ["off", "never"],
"key-spacing": ["error", { beforeColon: false, afterColon: true }],
"keyword-spacing": ["error", { before: true, after: true }],
"object-curly-spacing": ["error", "always"],
"semi-spacing": ["error", { before: false, after: true }],
"space-before-blocks": "error",
"space-before-function-paren": ["off", "never"],
"space-in-parens": ["error", "never"],
"space-infix-ops": "error",
"space-unary-ops": ["error", { words: true, nonwords: false }],
"quotes": "warn", // Changed from error to warn
"max-len": ["warn", { code: 150, ignoreUrls: true, ignoreStrings: true }],
"no-empty": "off"
},
settings: {
react: {
version: "detect"
}
}
},
{
ignores: [
"node_modules/**",
"dist/**",
"build/**",
"out/**",
"*.min.js",
"coverage/**",
".nyc_output/**",
"backend/**",
"deployment/**",
"web-calls/**"
]
}
];
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading...</title>
<link rel="icon" href="./src/resources/images/logo.png" />
<link rel="icon" href="./src/images/logo.png" />
</head>
<body>
<div id="root"></div>
+47
View File
@@ -0,0 +1,47 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ElectronTitleBar } from "./Electron";
import { useAppState } from "./pages/chat/state";
import { useEffect, useState, lazy } from "react";
import ProtectedRoute from "./pages/ProtectedRoute";
import NotFoundPage from "./pages/not-found/NotFoundPage";
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
// Lazy load route components
const HomePage = lazy(() => import("./pages/home/HomePage"));
const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
export default function App() {
const { restoreUserFromStorage } = useAppState();
const [authReady, setAuthReady] = useState(false);
// Restore user from localStorage on app initialization
useEffect(() => {
restoreUserFromStorage().finally(() => {
setAuthReady(true);
});
}, [restoreUserFromStorage]);
return authReady && (
<BrowserRouter>
<ElectronTitleBar />
<div id="main-wrapper">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/download-app" element={<DownloadAppPage />} />
<Route path="/">
<Route path="chat" element={
<ProtectedRoute>
<ChatPage />
</ProtectedRoute>
} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</BrowserRouter>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { PRODUCT_NAME } from "./core/config";
import { isElectron } from "./core/electron/electron";
export function ElectronTitleBar() {
return isElectron && (
<div id="electron-title-bar">
{window.electronInterface.platform === "darwin" && <div className="macos-padding" />}
<div id="window-title">{PRODUCT_NAME}</div>
</div>
)
}
-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;
}
@@ -1,9 +1,26 @@
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";
import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils";
import { API_BASE_URL } from "@/core/config";
/**
* Generates authentication headers for API requests
* @param {boolean} json - Whether to include JSON content type header
* @returns {Headers} Headers object with authentication and content type
*/
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
const headers: Headers = {};
if (json) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}
let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: Uint8Array | null = null;
@@ -18,15 +35,12 @@ async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
}
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)
headers: getAuthHeaders(token, true),
body: JSON.stringify({
publicKey: b64(publicKey)
} satisfies UploadPublicKeyRequest)
});
}
@@ -1,12 +1,12 @@
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";
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./authApi";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
@@ -46,7 +46,13 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
return data.messages || [];
}
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
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");
@@ -81,7 +87,13 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
});
}
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
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");
@@ -1,6 +1,6 @@
import { getAuthHeaders } from "../auth/api";
import { API_BASE_URL } from "../core/config";
import type { UserProfile } from "../core/types";
import { getAuthHeaders } from "./authApi";
import { API_BASE_URL } from "@/core/config";
import type { UserProfile } from "@/core/types";
export interface ProfileData {
profile_picture?: string;
@@ -33,7 +33,7 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
return null;
} catch (error) {
console.error('Error loading profile:', error);
console.error("Error loading profile:", error);
return null;
}
}
@@ -44,10 +44,10 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
try {
const formData = new FormData();
formData.append('profile_picture', file, 'profile_picture.jpg');
formData.append("profile_picture", file, "profile_picture.jpg");
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
method: 'POST',
method: "POST",
body: formData,
headers: getAuthHeaders(token, false)
});
@@ -57,7 +57,7 @@ export async function uploadProfilePicture(token: string, file: Blob): Promise<U
}
return null;
} catch (error) {
console.error('Upload error:', error);
console.error("Upload error:", error);
return null;
}
}
@@ -74,17 +74,17 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
};
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'PUT',
method: "PUT",
headers: {
...getAuthHeaders(token),
'Content-Type': 'application/json'
"Content-Type": "application/json"
},
body: JSON.stringify(backendData)
});
return response.ok;
} catch (error) {
console.error('Error updating profile:', error);
console.error("Error updating profile:", error);
return false;
}
}
@@ -95,14 +95,14 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
export async function updateBio(token: string, bio: string): Promise<boolean> {
try {
const response = await fetch(`${API_BASE_URL}/user/bio`, {
method: 'PUT',
method: "PUT",
headers: getAuthHeaders(token),
body: JSON.stringify({ bio })
});
return response.ok;
} catch (error) {
console.error('Error updating bio:', error);
console.error("Error updating bio:", error);
return false;
}
}
@@ -122,7 +122,7 @@ export async function fetchUserProfile(token: string, username: string): Promise
return null;
} catch (error) {
console.error('Error fetching user profile:', error);
console.error("Error fetching user profile:", error);
return null;
}
}
@@ -1,8 +1,8 @@
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";
import { id } from "@/utils/utils";
import useCombinedRefs from "@/core/hooks/useCombinedRefs";
export interface BaseDialogProps {
onOpenChange: (value: boolean) => void;
@@ -12,7 +12,9 @@ export interface BaseDialogProps {
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
export function MaterialDialog(props: FullDialogProps) {
// eslint-disable-next-line react-hooks/refs
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
const { open, onOpenChange } = props;
useEffect(() => {
const dialog = dialogRef.current;
@@ -22,8 +24,8 @@ export function MaterialDialog(props: FullDialogProps) {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "open") {
const isOpen = dialog.hasAttribute("open");
if (isOpen !== props.open) {
props.onOpenChange(isOpen);
if (isOpen !== open) {
onOpenChange(isOpen);
}
}
});
@@ -39,7 +41,8 @@ export function MaterialDialog(props: FullDialogProps) {
return () => {
observer.disconnect();
};
}, [dialogRef.current, props.open, props.onOpenChange]);
}, [open, onOpenChange, dialogRef]);
// eslint-disable-next-line react-hooks/refs
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
}
@@ -20,18 +20,18 @@ export function RichTextArea({
placeholder,
className,
rows = 1,
autoComplete = "off",
autoComplete = "off"
}: RichTextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const heightRef = useRef<number | null>(null);
const getStyleValue = (computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number => {
const raw = (computedStyle as any)[prop] as string | number | undefined;
if (raw == null) return 0;
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
const raw = computedStyle[prop] as string | number | undefined;
if (raw === null) return 0;
const str = String(raw);
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
};
}
const calculateTextareaStyles = useCallback(() => {
const textarea = textareaRef.current;
@@ -116,9 +116,7 @@ export function RichTextArea({
textarea.style.overflowY = overflowing ? "hidden" : "";
}, [calculateTextareaStyles]);
const useEnhancedEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
useEnhancedEffect(() => {
useLayoutEffect(() => {
syncHeight();
}, [syncHeight, text]);
@@ -142,13 +140,13 @@ export function RichTextArea({
};
}, [syncHeight]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
// Keep height responsive during rapid uncontrolled input bursts
syncHeight();
onTextChange(e.target.value);
};
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
const isCtrlEnter = e.key === "Enter" && (e.ctrlKey || e.metaKey);
const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
@@ -175,7 +173,7 @@ export function RichTextArea({
return;
}
}
};
}
return (
<>
@@ -204,7 +202,7 @@ export function RichTextArea({
height: "auto",
minHeight: 0,
maxHeight: "none",
overflow: "hidden",
overflow: "hidden"
}}
rows={1}
/>
@@ -3,7 +3,9 @@ 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> })} />
return (
<mdui-text-field
autocomplete="off"
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
);
}
@@ -10,6 +10,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
useEffect(() => {
if (visible) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldRender(true);
setIsAnimating(true);
// Wait for content to render, then measure
@@ -33,6 +34,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
requestAnimationFrame(() => {
// Read layout to ensure the previous height assignment is flushed
if (containerRef.current) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
containerRef.current.offsetHeight;
}
// Use a second frame to ensure the measured pixel height is applied before collapsing
@@ -50,7 +52,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
}
}, duration * 1000);
}
}, [visible, shouldRender]);
}, [visible, shouldRender, duration, onFinish]);
return (visible || shouldRender || isAnimating) && (
<div
@@ -7,6 +7,7 @@ export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, chi
useEffect(() => {
if (visible) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldRender(true);
setOpacity(0);
@@ -36,6 +37,7 @@ export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, chi
transition: `opacity ${duration}s ease`,
...props.style
}}
>{children}</div>
>{children}
</div>
);
}
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
export interface BaseAnimatedPropertyProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
visible: any;
duration?: number;
onFinish?: () => void
@@ -1,4 +1,4 @@
@use "common/material" as *;
@use "../../css/material" as *;
#electron-title-bar {
display: none;
@@ -5,7 +5,9 @@
* @version 1.0.0
*/
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
import "./electron.scss";
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface !== undefined;
if (isElectron) {
console.log("Running in Electron");
@@ -1,4 +1,4 @@
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
import { useRef, useCallback, type RefCallback, type Ref } from "react";
// Определяем тип для ref, который может быть либо функцией, либо объектом
type PossibleRef<T> = Ref<T> | undefined;
@@ -14,7 +14,7 @@ export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallb
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
if (typeof ref === "function") {
// Если ref - это функция, вызываем её
ref(node);
} else {
@@ -0,0 +1,13 @@
import { Navigate } from "react-router-dom";
import { MINIMUM_WIDTH } from "../config";
import useWindowSize from "./useWindowSize";
export default function useDownloadAppScreen() {
const { width } = useWindowSize();
const isMobile = width < MINIMUM_WIDTH;
return {
isMobile,
navigate: isMobile ? <Navigate to="/download-app" replace /> : null
};
}
@@ -1,7 +1,8 @@
import { API_BASE_URL } from "../core/config";
import { isElectron } from "../electron/electron";
import { websocket } from "../core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "../core/types";
import { API_BASE_URL } from "@/core/config";
import { isElectron } from "@/core/electron/electron";
import { websocket } from "@/core/websocket";
import type { Message, NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
import serviceWorker from "./service-worker?worker&url";
export interface PushSubscriptionData {
endpoint: string;
@@ -17,7 +18,6 @@ export interface NotificationPayload {
icon?: string;
image?: string;
tag?: string;
data?: any;
}
// Global state
@@ -103,7 +103,7 @@ async function sendSubscriptionToServer(token: string): Promise<boolean> {
}
}
async function showMessageNotification(message: any): Promise<void> {
async function showMessageNotification(message: Message): Promise<void> {
try {
await showNotification({
title: `New message from ${message.username}`,
@@ -111,20 +111,14 @@ async function showMessageNotification(message: any): Promise<void> {
? message.content.substring(0, 100) + "..."
: message.content,
icon: message.profile_picture || "/logo.png",
tag: `message_${message.id}`,
data: {
type: "public_message",
message_id: message.id,
sender_id: message.user_id,
sender_username: message.username
}
tag: `message_${message.id}`
});
} catch (error) {
console.error("Failed to show message notification:", error);
}
}
async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
async function handleWebSocketMessage(response: WebSocketMessage<object>): Promise<void> {
// Handle notifications for new messages
if (response.type === "newMessage" && response.data) {
const newResponse = response as NewMessageWebSocketMessage;
@@ -152,7 +146,7 @@ export async function initialize(): Promise<boolean> {
}
try {
registration = await navigator.serviceWorker.register("/assets/serviceWorker.js");
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
console.log("Service Worker registered successfully");
const permission = await Notification.requestPermission();
@@ -188,12 +182,7 @@ export async function subscribe(token: string): Promise<boolean> {
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
if (isElectron) {
try {
return await window.electronInterface.notifications.show({
title: payload.title,
body: payload.body,
icon: payload.icon,
tag: payload.tag
});
return await window.electronInterface.notifications.show(payload);
} catch (error) {
console.error("Failed to show Electron notification:", error);
return false;
@@ -243,14 +232,14 @@ export async function startElectronReceiver(): Promise<void> {
// Add our own message listener to the existing WebSocket
messageListener = (event: MessageEvent) => {
try {
const response: WebSocketMessage<any> = JSON.parse(event.data);
const response: WebSocketMessage<object> = JSON.parse(event.data);
handleWebSocketMessage(response);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
console.error("Failed to parse WebSocket message:", error);
}
};
websocket.addEventListener('message', messageListener);
websocket.addEventListener("message", messageListener);
}
export function stopElectronReceiver(): void {
@@ -262,7 +251,7 @@ export function stopElectronReceiver(): void {
// Remove our message listener
if (messageListener) {
websocket.removeEventListener('message', messageListener);
websocket.removeEventListener("message", messageListener);
messageListener = null;
}
}
@@ -8,7 +8,7 @@ interface NotificationPayload {
icon?: string;
image?: string;
tag?: string;
data?: any;
data?: object;
}
interface NotificationAction {
@@ -22,7 +22,7 @@ interface NotificationOptions {
badge: string;
image?: string;
tag: string;
data?: any;
data?: object;
actions: NotificationAction[];
requireInteraction: boolean;
silent: boolean;
+81 -11
View File
@@ -51,6 +51,15 @@ export interface Rect extends Size2D {
* @property {string} [profile_picture] - URL to sender's profile picture
* @property {Message} [reply_to] - The message this is replying to
*/
export interface Reaction {
emoji: string;
count: number;
users: Array<{
id: number;
username: string;
}>;
}
export interface Message {
id: number;
username: string;
@@ -61,9 +70,19 @@ export interface Message {
profile_picture?: string;
reply_to?: Message;
files?: Attachment[];
reactions?: Reaction[];
runtimeData?: {
dmEnvelope?: DmEnvelope;
sendingState?: {
status: "sending" | "sent" | "failed";
tempId?: string; // Temporary ID for tracking until server confirms
retryData?: {
content: string;
replyToId?: number;
files?: File[];
};
};
}
}
@@ -193,6 +212,7 @@ export interface DmEnvelope extends BaseDmEnvelope {
senderId: number;
files?: DmFile[];
timestamp: string;
reactions?: Reaction[];
}
export interface DmFile {
@@ -219,7 +239,7 @@ export interface FetchDMResponse {
}
export interface DmEncryptedJSON {
type: "text",
type: "text";
data: {
content: string;
reply_to_id?: number;
@@ -290,13 +310,13 @@ export interface DMEditPayload {
// Requests
export interface DMEditRequest extends WebSocketMessage {
type: "dmEdit",
type: "dmEdit";
credentials: WebSocketCredentials;
data: DMEditPayload
}
export interface SendMessageRequest extends WebSocketMessage {
type: "sendMessage",
type: "sendMessage";
credentials: WebSocketCredentials;
data: {
content: string;
@@ -304,44 +324,94 @@ export interface SendMessageRequest extends WebSocketMessage {
}
}
export interface AddReactionRequest extends WebSocketMessage {
type: "addReaction";
credentials: WebSocketCredentials;
data: {
message_id: number;
emoji: string;
}
}
export interface AddDmReactionRequest extends WebSocketMessage {
type: "addDmReaction";
credentials: WebSocketCredentials;
data: {
dm_envelope_id: number;
emoji: string;
}
}
// Messages
export interface DMNewWebSocketMessage extends WebSocketMessage {
type: "dmNew",
type: "dmNew";
data: DmEnvelope
}
export interface DMEditedWebSocketMessage extends WebSocketMessage {
type: "dmEdited",
type: "dmEdited";
data: DMEditPayload
}
export interface DMDeletedWebSocketMessage extends WebSocketMessage {
type: "dmDeleted",
type: "dmDeleted";
data: {
id: number;
}
}
export interface MessageEditedWebSocketMessage extends WebSocketMessage {
type: "messageEdited",
type: "messageEdited";
data: Partial<Message> & { id: number }
}
export interface MessageDeletedWebSocketMessage extends WebSocketMessage {
type: "messageDeleted",
type: "messageDeleted";
data: {
message_id: number;
}
}
export interface NewMessageWebSocketMessage extends WebSocketMessage {
type: "newMessage",
type: "newMessage";
data: Message
}
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
type: "reactionUpdate";
data: {
message_id: number;
emoji: string;
action: "added" | "removed";
user_id: number;
username: string;
reactions: Reaction[];
}
}
export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
type: "dmReactionUpdate",
data: {
dm_envelope_id: number;
emoji: string;
action: "added" | "removed";
user_id: number;
username: string;
reactions: Reaction[];
}
}
// Shared types
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage
export type DMWebSocketMessage =
DMNewWebSocketMessage |
DMEditedWebSocketMessage |
DMDeletedWebSocketMessage |
DMReactionUpdateWebSocketMessage;
export type ChatWebSocketMessage =
MessageEditedWebSocketMessage |
MessageDeletedWebSocketMessage |
NewMessageWebSocketMessage |
ReactionUpdateWebSocketMessage;
// -----------
// Encrypted message JSON (plaintext structure before encryption)
+8 -8
View File
@@ -7,7 +7,7 @@
import { API_WS_BASE_URL } from "./config";
import type { WebSocketMessage } from "./types";
import { delay } from "../utils/utils";
import { delay } from "@/utils/utils";
/**
* Creates a new WebSocket connection to the chat server
@@ -33,17 +33,17 @@ 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;
let globalMessageHandler: ((response: WebSocketMessage<object>) => void) | null = null;
/**
* Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages
*/
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<object>) => void) | null): void {
globalMessageHandler = handler;
}
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
export function request<Request, Response = object>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => {
function requestInner() {
@@ -58,7 +58,7 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
setTimeout(() => reject("Request timed out"), 10000);
}
if (websocket.readyState == 0) {
if (websocket.readyState === 0) {
websocket.addEventListener("open", requestInner);
setTimeout(() => reject("Request timed out"), 10000);
} else {
@@ -79,10 +79,10 @@ async function onError() {
await delay(3000);
websocket = create();
let listener: () => void | null;
let listener: (() => void) | null = null;
listener = () => {
console.log("WebSocket successfully reconnected!");
websocket.removeEventListener("open", listener);
websocket.removeEventListener("open", listener!);
}
websocket.addEventListener("open", listener);
@@ -95,7 +95,7 @@ async function onError() {
websocket.addEventListener("message", (e) => {
try {
const response: WebSocketMessage<any> = JSON.parse(e.data);
const response: WebSocketMessage<object> = JSON.parse(e.data);
// Route message to global handler if set
if (globalMessageHandler) {
@@ -30,72 +30,6 @@ 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 {
@@ -1,18 +1,10 @@
@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 "animations";
@use "components";
@use "colors" as *;
@use "material" as *;
@use "lib/fonts/montserrat";
@use "lib/fonts/material-symbols";
@use "fonts/montserrat";
@use "fonts/material-symbols";
* {

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+5 -7
View File
@@ -5,17 +5,15 @@
* @version 1.0.0
*/
import './resources/css/style.scss';
import "mdui/mdui.css";
import "./css/style.scss";
import "./utils/material";
import "./core/init";
import "./electron/electron";
import { createRoot } from 'react-dom/client';
import App from './ui/App';
import { StrictMode } from 'react';
import "./core/electron/electron";
import { createRoot } from "react-dom/client";
import App from "./App";
import { StrictMode } from "react";
// Initialize React app
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
+21
View File
@@ -0,0 +1,21 @@
import { useEffect } from "react";
import { useAppState } from "./chat/state";
import { useNavigate } from "react-router-dom";
interface ProtectedRouteProps {
children: React.ReactNode;
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user } = useAppState();
const navigate = useNavigate();
useEffect(() => {
if (!user.authToken) {
navigate("/login");
return;
}
}, [user.authToken, user.currentUser, navigate]);
return <>{children}</>;
}
@@ -24,8 +24,8 @@ export interface AuthHeaderProps {
}
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
const iconType = typeof icon == "string" ? "filled" : icon.type;
const iconName = typeof icon == "string" ? icon : icon.name;
const iconType = typeof icon === "string" ? "filled" : icon.type;
const iconName = typeof icon === "string" ? icon : icon.name;
return (
<div className="auth-header">
@@ -36,4 +36,21 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
<p>{subtitle}</p>
</div>
)
}
export type AlertType = "success" | "danger"
export interface Alert {
type: AlertType;
message: string;
}
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
return (
<div>
{alerts.slice(-3).map((alert, i) => {
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
})}
</div>
)
}
@@ -1,28 +1,35 @@
import { useImmer } from "use-immer";
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
import { AuthContainer, AuthHeader } from "../components/Auth";
import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types";
import { ensureKeysOnLogin } from "../../auth/crypto";
import { API_BASE_URL } from "../../core/config";
import { AlertsContainer, type Alert, type AlertType } from "./Auth";
import { AuthContainer, AuthHeader } from "./Auth";
import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types";
import { ensureKeysOnLogin } from "@/core/api/authApi";
import { API_BASE_URL } from "@/core/config";
import { useRef } from "react";
import type { TextField } from "mdui/components/text-field";
import { useAppState } from "../state";
import { MaterialTextField } from "../components/core/TextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/push-notifications";
import { isElectron } from "../../electron/electron";
import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/TextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { useNavigate } from "react-router-dom";
import "./auth.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
export default function LoginScreen() {
export default function LoginPage() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setCurrentPage = useAppState(state => state.setCurrentPage);
const setUser = useAppState(state => state.setUser);
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const navigate = useNavigate();
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
if (navigateDownloadApp) return navigateDownloadApp;
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => {
alerts.push({ type: type, message: message });
});
}
return (
<AuthContainer>
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
@@ -45,12 +52,12 @@ export default function LoginScreen() {
const request: LoginRequest = {
username: username,
password: password
}
};
const response = await fetch(`${API_BASE_URL}/login`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json"
},
body: JSON.stringify(request)
});
@@ -67,7 +74,7 @@ export default function LoginScreen() {
console.error("Key setup failed:", e);
}
setCurrentPage("chat");
navigate("/chat");
// Initialize notifications
try {
@@ -95,7 +102,7 @@ export default function LoginScreen() {
const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
}
} catch (error) {
} catch {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
@@ -130,7 +137,7 @@ export default function LoginScreen() {
<a
href="#"
className="link"
onClick={() => setCurrentPage("register")}>
onClick={() => navigate("/register")}>
Зарегистрируйтесь
</a>
</p>
@@ -138,4 +145,4 @@ export default function LoginScreen() {
</div>
</AuthContainer>
)
}
}
@@ -1,27 +1,34 @@
import { useImmer } from "use-immer";
// import { showLogin } from "../../navigation";
import { AuthContainer, AuthHeader } from "../components/Auth";
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
import { AuthContainer, AuthHeader } from "./Auth";
import { AlertsContainer, type Alert, type AlertType } from "./Auth";
import { useRef } from "react";
import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest } from "../../core/types";
import { API_BASE_URL } from "../../core/config";
import { delay } from "../../utils/utils";
import { useAppState } from "../state";
import { MaterialTextField } from "../components/core/TextField";
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
import { API_BASE_URL } from "@/core/config";
import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/TextField";
import { ensureKeysOnLogin } from "@/core/api/authApi";
import { useNavigate } from "react-router-dom";
import "./auth.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
export default function RegisterScreen() {
export default function RegisterPage() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setCurrentPage = useAppState(state => state.setCurrentPage);
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const setUser = useAppState(state => state.setUser);
const navigate = useNavigate();
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
const confirmPasswordElement = useRef<TextField>(null);
if (navigateDownloadApp) return navigateDownloadApp;
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => {
alerts.push({ type: type, message: message });
});
}
return (
<AuthContainer>
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
@@ -63,23 +70,31 @@ export default function RegisterScreen() {
}
const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json"
},
body: JSON.stringify(request)
});
if (response.ok) {
// Registration successful
showAlert("success", "Регистрация прошла успешно! Теперь вы можете войти.");
await delay(2000);
setCurrentPage("login");
const data: LoginResponse = await response.json();
// Store the JWT token first
setUser(data.token, data.user);
// Setup keys with the token we just received
try {
await ensureKeysOnLogin(password, data.token);
} catch {
console.error("Key setup failed:", e);
}
navigate("/chat");
} else {
const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Ошибка при регистрации");
}
} catch (error) {
} catch {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
@@ -127,7 +142,7 @@ export default function RegisterScreen() {
href="#"
id="login-link"
className="link"
onClick={() => setCurrentPage("login")}>
onClick={() => navigate("/login")}>
Войдите
</a>
</p>
@@ -135,4 +150,4 @@ export default function RegisterScreen() {
</div>
</AuthContainer>
)
}
}
@@ -1,5 +1,5 @@
@use "common/colors" as *;
@use "common/material" as *;
@use "../../css/colors" as *;
@use "../../css/material" as *;
.auth-container {
display: flex;
@@ -0,0 +1,107 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Animation for reactions appearing/disappearing
@keyframes messageReactionsFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes reactionFadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes reactionFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.8);
}
}
// Context menu wrapper animations
@keyframes contextMenuEnter {
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes contextMenuEnterLeft {
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes contextMenuEnterUp {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes contextMenuEnterUpLeft {
to {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
}
}
@keyframes contextMenuClose {
to {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes contextMenuCloseLeft {
to {
opacity: 0;
transform: translateX(-20px) scale(0.8);
}
}
@keyframes contextMenuCloseUp {
to {
opacity: 0;
transform: translateY(20px) scale(0.8);
}
}
@keyframes contextMenuCloseUpLeft {
to {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.8);
}
}
@keyframes emojiMenuEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@@ -0,0 +1,318 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chat-input-wrapper {
position: relative;
margin: 0 10px 10px 10px;
.input-group {
display: flex;
background: $color-dark-surface-container;
border-radius: 30px;
flex-direction: column;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.contextual-preview {
padding: 12px 16px 0 16px;
display: flex;
align-items: flex-start;
gap: 16px;
mdui-icon {
align-self: center;
box-sizing: content-box;
}
.reply-cancel {
margin-left: auto;
}
}
.attachments-preview {
align-items: center;
.attachments-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.chat-input {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.buttons, .left-buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.left-buttons {
.emoji-btn {
margin: 10px;
color: $color-dark-on-surface-variant;
transition: color 0.2s ease;
flex-shrink: 0;
align-self: flex-end;
&:hover {
color: $color-dark-primary;
}
}
}
.message-input {
flex: 1;
padding: 20px 0;
border: none;
border-radius: 25px;
font-size: 1rem;
outline: none;
caret-color: $color-dark-primary;
color: $color-dark-on-surface;
background: transparent;
resize: none;
font: inherit;
font-size: 13pt;
height: 100%;
width: 100%;
&::placeholder {
color: $color-dark-on-surface-variant;
opacity: 0.7;
}
}
.buttons {
.send-btn {
margin: 10px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
color: $color-dark-on-primary;
border: 1px solid rgba($color-dark-primary, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
align-self: flex-end;
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
&:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba($color-dark-primary, 0.6);
}
}
}
}
}
}
// Emoji Menu Styles
.emoji-menu {
$transition: cubic-bezier(0.4, 0, 0.2, 1);
background: $color-dark-surface-container;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(20px);
width: 320px;
height: 400px;
overflow: hidden;
display: flex;
flex-direction: column;
transform-origin: bottom left;
opacity: 0;
transform: translateY(30px);
transition: transform 0.25s $transition, opacity 0.25s $transition;
user-select: none;
&.open {
opacity: 1;
transform: translateY(0);
}
.emoji-menu-header {
background: $color-dark-surface-container-high;
border-bottom: 1px solid rgba($color-dark-outline-variant, 0.2);
position: sticky;
top: 0;
z-index: 1;
.emoji-category-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
padding: 8px;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container;
border-radius: 2px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $color-dark-surface-container-high;
}
.emoji-category-tab {
background: transparent;
border: none;
border-radius: 10px;
padding: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 1.2rem;
min-width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&:hover {
background: $color-dark-surface-container;
}
&.active {
background: $color-dark-primary-container;
color: $color-dark-on-primary-container;
transform: scale(1.05);
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $color-dark-primary-container;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 8px;
}
&.active::before {
opacity: 1;
}
span {
position: relative;
z-index: 1;
}
}
}
}
.emoji-grid {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 3px;
}
.emoji-category-section {
.emoji-category-title {
position: sticky;
top: 0;
padding-top: 5px;
padding-bottom: 5px;
padding-left: 12px;
padding-right: 12px;
font-size: 0.85rem;
font-weight: 600;
color: $color-dark-on-surface-variant;
z-index: 2;
margin: 0;
backdrop-filter: blur(10px);
}
.emoji-category-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2px;
padding: 8px;
}
}
.emoji-item {
$size: 30px;
background: transparent;
border: none;
border-radius: 6px;
padding: 5px;
cursor: pointer;
transition: all 0.15s ease;
font-size: $size;
width: $size;
height: $size;
box-sizing: content-box;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: $color-dark-surface-container-high;
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
.emoji-empty-state {
padding: 20px;
text-align: center;
color: $color-dark-on-surface-variant;
font-size: 0.9rem;
}
// Integrated mode styles (inside reaction bar)
&.integrated {
position: relative !important;
width: 320px !important;
height: 400px !important;
transform: none !important;
opacity: 1 !important;
box-shadow: none;
border: none;
background: $color-dark-surface-container;
overflow: visible;
}
}
@@ -0,0 +1,270 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Reaction bar styles (standalone)
.reaction-bar {
background: $color-dark-surface-container;
border: 1px solid $color-dark-outline;
border-radius: 24px;
padding: 8px;
opacity: 1;
transition: all 0.15s ease;
backdrop-filter: blur(8px);
transform: translateY(0);
&.closing {
opacity: 0;
transform: scale(0.8);
}
}
// Emoji menu wrapper inside reaction bar
.emoji-menu-wrapper {
width: 320px;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
// Context menu wrapper with animations
.context-menu-wrapper {
position: relative;
display: block;
// Animation states
&.entering {
opacity: 0;
transform: scale(0.8);
animation: contextMenuEnter 0.2s ease forwards;
}
&.entering-left {
opacity: 0;
transform: translateX(-20px) scale(0.8);
animation: contextMenuEnterLeft 0.2s ease forwards;
}
&.entering-up {
opacity: 0;
transform: translateY(20px) scale(0.8);
animation: contextMenuEnterUp 0.2s ease forwards;
}
&.entering-up-left {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.8);
animation: contextMenuEnterUpLeft 0.2s ease forwards;
}
&.closing {
opacity: 1;
transform: scale(1);
animation: contextMenuClose 0.2s ease forwards;
}
&.closing-left {
opacity: 1;
transform: translateX(0) scale(1);
animation: contextMenuCloseLeft 0.2s ease forwards;
}
&.closing-up {
opacity: 1;
transform: translateY(0) scale(1);
animation: contextMenuCloseUp 0.2s ease forwards;
}
&.closing-up-left {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
animation: contextMenuCloseUpLeft 0.2s ease forwards;
}
}
.context-menu {
background: $color-dark-surface;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
padding: 0.5rem 0;
min-width: 160px;
z-index: 1000;
&.entering {
animation: fadeInDown 0.2s ease forwards;
}
&.entering-left {
animation: fadeInLeft 0.2s ease forwards;
}
&.entering-up {
animation: fadeInUp 0.2s ease forwards;
}
&.entering-up-left {
animation: fadeInUpLeft 0.2s ease forwards;
}
&.closing {
animation: fadeOutUp 0.2s ease forwards;
}
&.closing-left {
animation: fadeOutRight 0.2s ease forwards;
}
&.closing-up {
animation: fadeOutDown 0.2s ease forwards;
}
&.closing-up-left {
animation: fadeOutDownRight 0.2s ease forwards;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
color: $color-dark-on-surface;
font-size: 0.9rem;
transition: background-color 0.2s ease;
&:hover {
background-color: $color-dark-surface-container;
}
.material-symbols {
font-size: 1.1rem;
color: $color-dark-on-surface-variant;
}
}
}
// Reaction bar inside context menu wrapper
.context-menu-reaction-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: $color-dark-surface-container;
border: 1px solid $color-dark-outline;
border-radius: 16px;
position: absolute;
bottom: 100%;
justify-content: center;
margin-bottom: 10px;
transition: width 0.3s ease-out, height 0.3s ease-out;
&.left {
left: 0;
transform: translateX(0);
}
&.right {
right: 0;
transform: translateX(0);
}
&.expanded {
padding: 0;
overflow: hidden;
width: 320px;
height: 400px;
border-radius: 16px;
// Default: expand downward from the reaction bar's bottom edge
position: absolute;
bottom: auto;
top: 0;
left: 0;
transform: translateY(0);
&.expand-upward {
// Expand upward from the reaction bar's top edge
bottom: 100%;
top: auto;
margin-bottom: 10px;
margin-top: 0;
transform: translateY(0);
}
.emoji-menu-wrapper {
animation: emojiMenuEnter 0.5s ease;
}
}
}
.reaction-bar-content {
display: flex;
align-items: center;
gap: 4px;
transition: opacity 0.3s ease-out;
&.faded {
opacity: 0;
}
}
.reaction-emoji-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: transparent;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
font-size: 18px;
&:hover {
background: var(--mdui-color-surface-container-high);
transform: scale(1.3);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
}
.reaction-expand-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--mdui-color-outline);
border-radius: 16px;
background: var(--mdui-color-surface);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
background: var(--mdui-color-surface-container-high);
border-color: var(--mdui-color-primary);
transform: scale(1.1);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
.material-symbols {
font-size: 18px;
color: var(--mdui-color-on-surface);
transition: transform 0.2s ease;
}
&:hover .material-symbols {
transform: rotate(90deg);
}
}
+49
View File
@@ -0,0 +1,49 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
#chat-interface {
height: 100%;
background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%);
position: relative;
&::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba($color-dark-primary, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.15) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
.chat-container {
display: flex;
width: 100%;
flex-direction: column;
overflow: hidden;
.chat-main {
flex-grow: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
overflow: hidden;
}
}
}
// Panel chat styles
// контейнер чата и панели с чатами
.all-container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
@@ -1,13 +1,56 @@
@use "common/colors" as *;
@use "common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// контейнер чата и панели с чатами
.all-container {
.header {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
background-color: $color-dark-surface-container;
color: white;
padding: 16px 16px;
justify-content: end;
width: fit-content;
z-index: 1000;
position: absolute;
top: 0;
right: 0;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.logo {
font-size: 1.8rem;
font-weight: 700;
display: flex;
align-items: center;
}
#logouts {
display: none;
list-style: none;
gap: 10px;
li {
a {
color: white;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
padding: 10px;
border-radius: 10px;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
}
}
}
}
#profile {
@@ -147,4 +190,45 @@
padding-right: 16px;
margin-top: auto;
}
}
}
// ChatHeader component styles
.chat-header-left {
.product-name {
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.profile {
a {
display: flex;
align-items: center;
text-decoration: none;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba($color-dark-primary, 0.4);
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 0 25px rgba($color-dark-primary, 0.5);
border-color: rgba($color-dark-primary, 0.6);
}
}
}
}
}
@@ -0,0 +1,59 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Reaction styles
.message-reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
margin-left: 10px;
margin-right: 10px;
animation: messageReactionsFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.reaction-button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: none;
border-radius: 16px;
background-color: $color-dark-surface-container;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
font-size: 1px;
min-height: 28px;
animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.removing {
animation: reactionFadeOut 0.2s ease forwards;
}
&:hover {
background-color: $color-dark-surface-container-high;
transform: scale(1.05);
}
&.reacted {
background-color: $color-dark-primary-container;
border-color: $color-dark-primary;
color: $color-dark-on-primary-container;
&:hover {
background-color: color.adjust($color-dark-primary-container, $lightness: 20%);
}
}
}
.reaction-emoji {
font-size: 17px;
line-height: 1;
}
.reaction-count {
font-size: 12px;
font-weight: 500;
line-height: 1;
}
+352
View File
@@ -0,0 +1,352 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.quote.contextual-content > .quote-inner {
display: flex;
flex-direction: column;
gap: 4px;
.reply-username {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.reply-text {
overflow: hidden;
text-overflow: ellipsis;
}
}
.message {
margin-bottom: 1rem;
max-width: 70%;
position: relative;
width: fit-content;
display: flex;
align-items: flex-end;
gap: 8px;
.message-inner {
border-radius: 12px;
position: relative;
word-wrap: break-word;
overflow-wrap: anywhere;
word-break: break-word;
width: fit-content;
max-width: 100%;
display: inline-block;
.message-profile-pic {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-bottom: 4px;
margin: 10px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
.message-username {
font-weight: 600;
margin-bottom: 0.3rem;
font-size: 0.9rem;
transition: color 0.2s ease;
margin: 10px;
&:hover {
color: $color-dark-primary;
text-decoration: underline;
}
}
.message-content {
word-wrap: break-word;
margin: 10px 10px 0 10px;
white-space: pre-wrap;
> p:first-child {
margin-block-start: 0;
}
> p:last-child {
margin-block-end: 0;
}
}
.quote.reply-preview {
user-select: none;
margin: 10px;
}
.message-attachments {
padding: 5px 0 0 0;
overflow: hidden;
.attachment {
a {
text-decoration: none;
}
.attachement-image {
max-width: 200px;
border-radius: 8px;
cursor: pointer;
margin-left: 3px;
margin-right: 3px;
margin-bottom: 3px;
&:last-child {
margin-bottom: 0;
}
&.loading {
filter: blur(10px);
transition: filter 200ms ease;
}
}
.attachement-image.placeholder {
background: $color-dark-surface-container-highest;
pointer-events: none;
}
.image-wrapper {
position: relative;
display: inline-block;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.08);
backdrop-filter: blur(6px);
border-radius: 8px;
}
.preload-image {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.with-icon-gap {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
}
.message-time {
font-size: 0.7rem;
color: $color-dark-on-surface-variant;
margin-top: 0.3rem;
text-align: right;
user-select: none;
margin: 4px 8px 8px 8px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
.message-status-indicator {
display: flex;
align-items: center;
width: 16px;
height: 16px;
.error-icon {
color: #f44336;
font-size: 16px;
}
.success-icon {
color: #4caf50;
font-size: 16px;
}
mdui-circular-progress {
width: 16px;
height: 16px;
}
}
}
}
&.received .message-inner {
background: $color-dark-surface-container;
color: $color-dark-on-surface;
border-top-left-radius: 5px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid rgba($color-dark-outline-variant, 0.4);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba($color-dark-primary, 0.05), rgba($color-dark-tertiary, 0.03));
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
}
&.received .message-time {
color: $color-dark-on-surface-variant;
font-weight: 500;
.message-status-indicator {
.success-icon {
color: $color-dark-on-surface-variant;
filter: brightness(1.1);
}
.error-icon {
color: #ff6b6b;
filter: brightness(1.1);
}
}
}
&.sent {
margin-left: auto;
flex-direction: row-reverse;
.message-inner {
background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
color: $color-dark-on-primary;
border-top-right-radius: 5px;
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
border: 1px solid rgba($color-dark-primary, 0.5);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08));
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
}
.message-time {
color: $color-dark-on-primary;
font-weight: 500;
.message-status-indicator {
.success-icon {
color: $color-dark-on-primary;
filter: brightness(1.2);
}
.error-icon {
color: #ff6b6b;
filter: brightness(1.2);
}
}
}
}
}
.message-profile-pic {
img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
&.loading {
opacity: 0.6;
cursor: default;
}
}
}
.message-username {
&.loading {
opacity: 0.6;
cursor: default;
}
}
// Fullscreen Image Viewer
.fullscreen-image-overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
z-index: 9999;
opacity: 1;
transition: opacity 0.3s ease;
&.closing {
opacity: 0;
}
.fullscreen-animated-image {
position: absolute;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease;
}
.fullscreen-controls {
position: absolute;
display: flex;
gap: 8px;
&.top-right {
top: 12px;
right: 12px;
}
}
.progress-wrapper {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
}
@@ -1,6 +1,8 @@
@use "common/colors" as *;
@use "common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Profile styles
#profile-dialog .content {
display: flex;
flex-direction: column;
@@ -246,4 +248,4 @@
padding-top: 16px;
border-top: 1px solid $color-dark-outline;
}
}
}
@@ -0,0 +1,131 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chat-main {
.chat-header {
padding: 16px;
background: rgba($color-dark-surface-container, 0.8);
backdrop-filter: blur(20px);
display: flex;
align-items: center;
box-shadow: 0 4px 20px rgba($color-dark-primary, 0.1);
position: relative;
z-index: 1;
.chat-header-avatar {
width: 45px;
height: 45px;
border-radius: 20%;
object-fit: cover;
margin-right: 1rem;
}
.chat-header-info {
display: flex;
.info-chat {
display: flex;
flex-direction: column;
h4 {
font-size: 1.1rem;
margin: 0 0 0.2rem;
}
p {
margin: 0;
font-size: 0.8rem;
color: #718096;
}
}
.online-status {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: $success;
margin-right: 5px;
}
a {
display: flex;
flex-direction: row;
text-decoration: none;
color: white;
justify-content: end;
padding: 0;
margin: 0;
position: absolute;
right: 2%;
top: 2%;
&:hover {
border: none;
}
}
}
}
.chat-messages {
flex: 1;
padding: 10px 20px;
overflow-y: auto;
position: relative;
z-index: 1;
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 20px;
}
}
.file-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
backdrop-filter: blur(20px);
.file-overlay-wrapper {
border-radius: 30px;
outline: 3px dashed $color-dark-primary;
outline-offset: -20px;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
.file-overlay-inner {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: rgba(18, 18, 18, 0.8);
border: 1px solid $color-dark-surface-container-high;
border-radius: 12px;
color: $color-dark-on-surface;
mdui-icon {
color: $color-dark-primary;
}
}
}
}
}
@@ -1,6 +1,8 @@
@use "common/colors" as *;
@use "common/material" as *;
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Settings styles
#settings-dialog {
.fullscreen-wrapper {
display: flex;
@@ -96,4 +98,4 @@
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
// Import all component-specific styles
@use "layout";
@use "left-panel";
@use "right-panel";
@use "message";
@use "chat-input";
@use "message-reactions";
@use "context-menu";
@use "profile-dialog";
@use "settings-dialog";
@use "animations";
@@ -1,16 +1,16 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state";
import { useAppState } from "@/pages/chat/state";
import {
fetchUsers,
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
sendDMViaWebSocket
} from "../../api/dmApi";
import type { User, Message, DmEncryptedJSON } from "../../core/types";
import { websocket } from "../../core/websocket";
} from "../../../core/api/dmApi";
import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket";
interface DMUser extends User {
export interface DMUser extends User {
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
@@ -101,7 +101,7 @@ export function useDM() {
} finally {
setIsLoadingUsers(false);
}
}, [user.authToken, isLoadingUsers]);
}, [user.authToken, isLoadingUsers, loadUserLastMessage, setDmUsers]);
// Reset users loaded flag when user changes
useEffect(() => {
@@ -198,7 +198,7 @@ export function useDM() {
// WebSocket message handler
useEffect(() => {
const handleWebSocketMessage = async (e: MessageEvent) => {
async function handleWebSocketMessage(e: MessageEvent) {
try {
const msg = JSON.parse(e.data);
if (msg.type === "dmNew") {
@@ -258,11 +258,12 @@ export function useDM() {
} catch (error) {
console.error("Failed to handle WebSocket message:", error);
}
};
}
websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [chat.activeDm, user.currentUser, addMessage]);
}, [chat.activeDm, user.currentUser, addMessage, user.authToken]);
// Force reload users (useful for refreshing the list)
const reloadUsers = useCallback(() => {
@@ -1,9 +1,9 @@
import { useState, useCallback, useEffect } from "react";
import { useAppState } from "../state";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../api/profileApi";
import { showSuccess, showError } from "../../utils/notification";
import { useAppState } from "@/pages/chat/state";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/profileApi";
import { showSuccess, showError } from "@/utils/notification";
export function useProfile() {
export default function useProfile() {
const { user } = useAppState();
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -20,8 +20,8 @@ export function useProfile() {
setProfileData(data);
}
} catch (error) {
console.error('Error loading profile:', error);
showError('Ошибка при загрузке профиля');
console.error("Error loading profile:", error);
showError("Ошибка при загрузке профиля");
} finally {
setIsLoading(false);
}
@@ -37,15 +37,15 @@ export function useProfile() {
if (success) {
// Reload profile data to get updated information
await loadProfileData();
showSuccess('Профиль обновлен!');
showSuccess("Профиль обновлен!");
return true;
} else {
showError('Ошибка при обновлении профиля');
showError("Ошибка при обновлении профиля");
return false;
}
} catch (error) {
console.error('Error updating profile:', error);
showError('Ошибка при обновлении профиля');
console.error("Error updating profile:", error);
showError("Ошибка при обновлении профиля");
return false;
} finally {
setIsUpdating(false);
@@ -65,15 +65,15 @@ export function useProfile() {
...prev,
profile_picture: result.profile_picture_url
} : null);
showSuccess('Фото профиля обновлено!');
showSuccess("Фото профиля обновлено!");
return true;
} else {
showError('Ошибка при загрузке фото');
showError("Ошибка при загрузке фото");
return false;
}
} catch (error) {
console.error('Error uploading profile picture:', error);
showError('Ошибка при загрузке фото');
console.error("Error uploading profile picture:", error);
showError("Ошибка при загрузке фото");
return false;
} finally {
setIsUpdating(false);
@@ -1,16 +1,15 @@
import { create } from "zustand";
import type { Message, User } from "../core/types";
import { request } from "../core/websocket";
import { MessagePanel } from "./panels/MessagePanel";
import { PublicChatPanel } from "./panels/PublicChatPanel";
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
import { getAuthHeaders } from "../auth/api";
import { restoreKeys } from "../auth/crypto";
import { API_BASE_URL } from "../core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "../utils/push-notifications";
import { isElectron } from "../electron/electron";
import type { Message, User } from "@/core/types";
import { request } from "@/core/websocket";
import { MessagePanel } from "./ui/right/panels/MessagePanel";
import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel";
import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel";
import { getAuthHeaders } from "@/core/api/authApi";
import { restoreKeys } from "@/core/api/authApi";
import { API_BASE_URL } from "@/core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
type Page = "login" | "register" | "chat"
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
interface ActiveDM {
@@ -25,10 +24,12 @@ interface ChatState {
activeTab: ChatTabs;
dmUsers: User[];
activeDm: ActiveDM | null;
isChatSwitching: boolean;
isSwitching: boolean;
setIsSwitching: (value: boolean) => void;
activePanel: MessagePanel | null;
publicChatPanel: PublicChatPanel | null;
dmPanel: DMPanel | null;
pendingPanel?: MessagePanel | null;
}
export interface UserState {
@@ -37,9 +38,6 @@ export interface UserState {
}
interface AppState {
currentPage: Page;
setCurrentPage: (page: Page) => void;
// Chat state
chat: ChatState;
addMessage: (message: Message) => void;
@@ -50,23 +48,20 @@ interface AppState {
setDmUsers: (users: User[]) => void;
setActiveDm: (dm: ChatState["activeDm"]) => void;
clearMessages: () => void;
setIsChatSwitching: (value: boolean) => void;
setActivePanel: (panel: MessagePanel | null) => void;
setPendingPanel: (panel: MessagePanel | null) => void;
applyPendingPanel: () => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
switchToTab: (tab: ChatTabs) => Promise<void>;
// User state
user: UserState;
setUser: (token: string, user: User) => void;
logout: () => void;
restoreUserFromStorage: () => void;
restoreUserFromStorage: () => Promise<void>;
}
export const useAppState = create<AppState>((set, get) => ({
currentPage: "login", // default page
setCurrentPage: (page: Page) => set({ currentPage: page }),
// Chat state
chat: {
messages: [],
@@ -74,17 +69,18 @@ export const useAppState = create<AppState>((set, get) => ({
activeTab: "chats",
dmUsers: [],
activeDm: null,
isChatSwitching: false,
isSwitching: false,
setIsSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isSwitching: value
}
})),
activePanel: null,
publicChatPanel: null,
dmPanel: null
dmPanel: null,
pendingPanel: null
},
setIsChatSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isChatSwitching: value
}
})),
addMessage: (message: Message) => set((state) => {
// Check if message already exists to prevent duplicates
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
@@ -159,10 +155,10 @@ export const useAppState = create<AppState>((set, get) => ({
// Store credentials in localStorage
try {
localStorage.setItem('authToken', token);
localStorage.setItem('currentUser', JSON.stringify(user));
localStorage.setItem("authToken", token);
localStorage.setItem("currentUser", JSON.stringify(user));
} catch (error) {
console.error('Failed to store credentials in localStorage:', error);
console.error("Failed to store credentials in localStorage:", error);
}
try {
@@ -181,23 +177,22 @@ export const useAppState = create<AppState>((set, get) => ({
logout: () => {
// Clear localStorage
try {
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
localStorage.removeItem("authToken");
localStorage.removeItem("currentUser");
} catch (error) {
console.error('Failed to clear localStorage:', error);
console.error("Failed to clear localStorage:", error);
}
set(() => ({
user: {
currentUser: null,
authToken: null
},
currentPage: "login"
}
}));
},
restoreUserFromStorage: async () => {
try {
const token = localStorage.getItem('authToken');
const token = localStorage.getItem("authToken");
if (token) {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
@@ -212,8 +207,7 @@ export const useAppState = create<AppState>((set, get) => ({
user: {
currentUser: user,
authToken: token
},
currentPage: "chat"
}
}));
try {
@@ -250,10 +244,10 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
} catch (error) {
console.error('Failed to restore user from localStorage:', error);
console.error("Failed to restore user from localStorage:", error);
// Clear invalid data
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
localStorage.removeItem("authToken");
localStorage.removeItem("currentUser");
}
},
@@ -264,15 +258,38 @@ export const useAppState = create<AppState>((set, get) => ({
activePanel: panel
}
})),
// Stash a panel to be applied after switch-out animation ends
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
chat: {
...state.chat,
pendingPanel: panel
}
})),
// Apply pending panel atomically and update related fields
applyPendingPanel: () => set((state) => ({
chat: {
...state.chat,
activePanel: state.chat.pendingPanel || state.chat.activePanel,
// when switching to public chat, keep reference if type matches
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
? (state.chat.pendingPanel as PublicChatPanel)
: state.chat.publicChatPanel,
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
? (state.chat.pendingPanel as DMPanel)
: state.chat.dmPanel,
// update currentChat from panel title if available
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
pendingPanel: null
}
})),
switchToPublicChat: async (chatName: string) => {
const state = get();
const { user, chat } = state;
const { user, chat } = get();
if (!user.authToken) return;
// Start chat switching animation
state.setIsChatSwitching(true);
chat.setIsSwitching(true);
// Create or get public chat panel
let publicChatPanel = chat.publicChatPanel;
@@ -281,37 +298,33 @@ export const useAppState = create<AppState>((set, get) => ({
} else {
publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken);
// Reset messages for the new chat
publicChatPanel.clearMessages();
}
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
// Activate panel
await publicChatPanel.activate();
// Update state
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
...state.chat,
activePanel: publicChatPanel,
publicChatPanel: publicChatPanel,
currentChat: chatName,
pendingPanel: publicChatPanel,
activeTab: "chats"
}
}));
// End animation
state.setIsChatSwitching(false);
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
},
switchToDM: async (dmData: DMPanelData) => {
const state = get();
const { user, chat } = state;
const { user, chat } = get();
if (!user.authToken) return;
// Start chat switching animation
state.setIsChatSwitching(true);
chat.setIsSwitching(true);
// Create or get DM panel
let dmPanel = chat.dmPanel;
@@ -319,23 +332,21 @@ export const useAppState = create<AppState>((set, get) => ({
dmPanel = new DMPanel(user);
} else {
dmPanel.setAuthToken(user.authToken);
// Reset messages for the new DM
dmPanel.clearMessages();
}
// Set DM data
dmPanel.setDMData(dmData);
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
// Activate panel
await dmPanel.activate();
// Update state
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
...state.chat,
activePanel: dmPanel,
dmPanel: dmPanel,
pendingPanel: dmPanel,
activeDm: {
userId: dmData.userId,
username: dmData.username,
@@ -345,19 +356,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}));
// End animation
state.setIsChatSwitching(false);
},
switchToTab: async (tab: ChatTabs) => {
const state = get();
state.setActiveTab(tab);
if (tab === "chats") {
await state.switchToPublicChat("Общий чат");
} else if (tab === "dms") {
// DM tab - no specific panel until user is selected
state.setActivePanel(null);
}
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
}
}));
+18
View File
@@ -0,0 +1,18 @@
import { LeftPanel } from "./left/LeftPanel";
import { RightPanel } from "./right/RightPanel";
import "@/pages/chat/css/chat.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
if (navigateDownloadApp) return navigateDownloadApp;
return (
<div id="chat-interface">
<div className="all-container">
<LeftPanel />
<RightPanel />
</div>
</div>
);
}
@@ -1,8 +1,8 @@
import { PRODUCT_NAME } from "../../../core/config";
import { useProfile } from "../../hooks/useProfile";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { PRODUCT_NAME } from "@/core/config";
import useProfile from "@/pages/chat/hooks/useProfile";
import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
import { ProfileDialog } from "./profile/ProfileDialog";
export function ChatHeader() {
const { profileData } = useProfile();
@@ -1,8 +1,8 @@
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";
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { useAppState } from "@/pages/chat/state";
import { fetchUserPublicKey } from "@/core/api/dmApi";
import defaultAvatar from "@/images/default-avatar.png";
export function DMUsersList() {
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
@@ -34,7 +34,7 @@ export function DMUsersList() {
);
}
const handleUserClick = async (user: any) => {
async function handleUserClick(user: DMUser) {
if (!user.publicKey) {
// Get public key if not already loaded
const authToken = useAppState.getState().user.authToken;
@@ -60,7 +60,7 @@ export function DMUsersList() {
return (
<mdui-list>
{dmUsers.map((user) => (
{dmUsers.map((user: DMUser) => (
<mdui-list-item
key={user.id}
headline={user.username}
@@ -1,12 +1,12 @@
import { PRODUCT_NAME } from "../../../core/config";
import { useAppState } from "../../state";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { PRODUCT_NAME } from "@/core/config";
import { useAppState } from "@/pages/chat/state";
import defaultAvatar from "@/images/default-avatar.png";
import { useState, type FormEvent } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
import { SettingsDialog } from "../settings/SettingsDialog";
import { ProfileDialog } from "./profile/ProfileDialog";
import { SettingsDialog } from "./settings/SettingsDialog";
import { DMUsersList } from "./DMUsersList";
import type { Tabs } from "mdui";
import type { ChatTabs } from "../../state";
import type { ChatTabs as ChatTabsType } from "@/pages/chat/state";
function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false);
@@ -19,16 +19,15 @@ function BottomAppBar() {
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="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
<mdui-button-icon icon="group_add--filled" />
<div style={{ flexGrow: 1 }} />
<mdui-button-icon
icon="logout--filled"
id="logout-btn"
onClick={handleLogout}
title="Выйти"
></mdui-button-icon>
<mdui-fab icon="edit--filled"></mdui-fab>
title="Выйти" />
<mdui-fab icon="edit--filled" />
</mdui-bottom-app-bar>
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
</>
@@ -37,17 +36,16 @@ function BottomAppBar() {
function ChatTabs() {
const { chat, switchToTab, switchToPublicChat } = useAppState();
const { chat, setActiveTab, switchToPublicChat } = useAppState();
const { activeTab } = chat;
const handleChatClick = async (chatName: string) => {
async function handleChatClick(chatName: string) {
await switchToPublicChat(chatName);
};
}
const handleTabChange = async (e: FormEvent<Tabs>) => {
const tab = (e.target as Tabs).value as ChatTabs;
await switchToTab(tab);
};
function handleTabChange(e: FormEvent<Tabs>) {
setActiveTab((e.target as Tabs).value as ChatTabsType);
}
return (
<div className="chat-tabs">
@@ -4,10 +4,10 @@ export function CropperDialog() {
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
<mdui-button-icon icon="close" id="cropper-close" />
</div>
<div className="cropper-container">
<div id="cropper-area"></div>
<div id="cropper-area" />
</div>
<div className="cropper-actions">
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "../../../core/types";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "@/core/types";
interface ImageCropperProps {
onCrop: (croppedImageData: string) => void;
@@ -17,13 +17,13 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
useEffect(() => {
const img = imageRef.current;
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({
@@ -36,9 +36,9 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
}
function handleReaderLoad() {
if (imageRef.current) {
if (img) {
setSrc(reader.result as string);
imageRef.current.addEventListener("load", handleImageLoad);
img.addEventListener("load", handleImageLoad);
}
}
@@ -48,10 +48,10 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
return () => {
reader.abort();
reader.removeEventListener("load", handleReaderLoad);
imageRef.current?.removeEventListener("load", handleImageLoad);
img?.removeEventListener("load", handleImageLoad);
}
}
}, [imageFile]);
}, [imageFile, imageRef]);
function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return;
@@ -100,10 +100,11 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
};
function handleCrop() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const img = imageRef.current;
if (!canvasRef.current || !img || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Set canvas size to crop area
@@ -112,47 +113,48 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
// Draw cropped portion
ctx.drawImage(
imageRef.current,
img,
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, cropArea.width, cropArea.height
);
// Convert to data URL
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
const croppedImageData = canvas.toDataURL("image/jpeg", 0.9);
onCrop(croppedImageData);
};
function drawCropArea() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const drawCropArea = useCallback(() => {
const img = imageRef.current;
if (!canvasRef.current || !img || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
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);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Draw crop overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
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.globalCompositeOperation = "source-over";
ctx.strokeStyle = "#fff";
ctx.lineWidth = 2;
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
};
}, [cropArea, isLoaded, imageRef]);
useEffect(() => {
drawCropArea();
}, [cropArea, isLoaded]);
}, [cropArea, isLoaded, drawCropArea]);
if (!imageFile) return null;
@@ -163,10 +165,10 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
width={400}
height={400}
style={{
cursor: isDragging ? 'grabbing' : 'grab',
border: '1px solid #ccc',
maxWidth: '100%',
height: 'auto'
cursor: isDragging ? "grabbing" : "grab",
border: "1px solid #ccc",
maxWidth: "100%",
height: "auto"
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@@ -176,7 +178,7 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
<img
ref={imageRef}
src={src}
style={{ display: 'none' }}
style={{ display: "none" }}
alt="Crop source"
/>
<div className="cropper-actions">
@@ -1,11 +1,11 @@
import { useState, useEffect, useRef, type FormEvent } from "react";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import defaultAvatar from "@/images/default-avatar.png";
import type { TextField } from "mdui/components/text-field";
import type { DialogProps } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { useProfile } from "../../hooks/useProfile";
import type { DialogProps } from "@/core/types";
import { MaterialDialog } from "@/core/components/Dialog";
import useProfile from "@/pages/chat/hooks/useProfile";
import { ImageCropper } from "./ImageCropper";
import { MaterialTextField } from "../core/TextField";
import { MaterialTextField } from "@/core/components/TextField";
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
@@ -20,6 +20,7 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
// Update form fields when profile data changes
useEffect(() => {
if (profileData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setUsername(profileData.nickname || "");
setDescription(profileData.description || "");
}
@@ -40,7 +41,7 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) {
if (file && file.type.startsWith("image/")) {
setSelectedImage(file);
setShowCropper(true);
}
@@ -57,25 +58,25 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.value = "";
}
}
} catch (error) {
console.error('Error processing cropped image:', error);
console.error("Error processing cropped image:", error);
}
};
const handleCropCancel = () => {
function handleCropCancel() {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.value = "";
}
};
}
const handleUploadClick = () => {
function handleUploadClick() {
fileInputRef.current?.click();
};
}
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
@@ -1,31 +1,24 @@
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 { useState, useMemo } from "react";
import { PRODUCT_NAME, API_BASE_URL } from "@/core/config";
import type { DialogProps } from "@/core/types";
import { MaterialDialog } from "@/core/components/Dialog";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { useAppState } from "@/pages/chat/state";
import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "../../../auth/api";
import { getAuthHeaders } from "@/core/api/authApi";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings");
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
const [pushSupported, setPushSupported] = useState(false);
const pushSupported = useMemo(() => isSupported(), []);
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(pushSupported);
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) => {
function handlePanelChange(panelId: string) {
setActivePanel(panelId);
};
}
const handlePushNotificationToggle = async (enabled: boolean) => {
async function handlePushNotificationToggle(enabled: boolean) {
if (!user.authToken) return;
try {
@@ -66,7 +59,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<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-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)} />
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
</div>
<div id="settings-menu">
@@ -185,7 +178,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<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-linear-progress value={25} />
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div>
@@ -1,10 +1,11 @@
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 { useState, useEffect, useRef } from "react";
import { MaterialDialog } from "@/core/components/Dialog";
import { RichTextArea } from "@/core/components/RichTextArea";
import type { Message } from "@/core/types";
import Quote from "@/core/components/Quote";
import AnimatedHeight from "@/core/components/animations/AnimatedHeight";
import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu";
interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void;
@@ -18,6 +19,7 @@ interface ChatInputWrapperProps {
onClearEdit?: () => void;
onCloseEdit?: () => void;
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
}
export function ChatInputWrapper(
@@ -32,24 +34,30 @@ export function ChatInputWrapper(
editVisible = false,
onClearEdit,
onCloseEdit,
onProvideFileAdder
onProvideFileAdder,
messagePanelRef
}: ChatInputWrapperProps
) {
const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
const [errorOpen, setErrorOpen] = useState(false);
const [emojiMenuOpen, setEmojiMenuOpen] = useState(false);
const [emojiMenuPosition, setEmojiMenuPosition] = useState({ x: 0, y: 0 });
const chatInputWrapperRef = useRef<HTMLDivElement>(null);
// Expose a way for parent to programmatically add files
useEffect(() => {
if (onProvideFileAdder) {
const addFiles = (files: File[]) => {
if (!files || files.length === 0) return;
setSelectedFiles(draft => { draft.push(...files) });
setSelectedFiles(draft => {
draft.push(...files)
});
};
onProvideFileAdder(addFiles);
}
}, [onProvideFileAdder]);
}, [onProvideFileAdder, setSelectedFiles]);
// When entering edit mode, preload the message content
useEffect(() => {
@@ -60,7 +68,32 @@ export function ChatInputWrapper(
setAttachmentsVisible(selectedFiles.length > 0);
}, [selectedFiles]);
const handleSubmit = async (e: React.FormEvent | Event) => {
function handleEmojiButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation();
if (!emojiMenuOpen) {
if (chatInputWrapperRef.current && messagePanelRef?.current) {
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
const panelRect = messagePanelRef.current.getBoundingClientRect();
// Position menu 10px from message panel edge and 10px above the chat input
// The animation will start 30px below this position
setEmojiMenuPosition({
x: panelRect.left + 10, // 10px from message panel edge
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
});
setEmojiMenuOpen(true);
}
} else {
setEmojiMenuOpen(false);
}
}
function handleEmojiSelect(emoji: string) {
setMessage(prev => prev + emoji);
}
async function handleSubmit(e: React.FormEvent | Event) {
e.preventDefault();
const hasText = Boolean(message.trim());
const hasFiles = selectedFiles.length > 0;
@@ -82,20 +115,22 @@ export function ChatInputWrapper(
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 || [])) });
setSelectedFiles(draft => {
draft.push(...Array.from(input.files || []))
});
});
input.click();
}
return (
<div className="chat-input-wrapper">
<div className="chat-input-wrapper" ref={chatInputWrapperRef}>
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}>
{editingMessage && (
@@ -105,7 +140,7 @@ export function ChatInputWrapper(
<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>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit} />
</div>
)}
</AnimatedHeight>
@@ -117,7 +152,7 @@ export function ChatInputWrapper(
<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>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply} />
</div>
)}
</AnimatedHeight>
@@ -131,25 +166,35 @@ export function ChatInputWrapper(
key={i}
variant="input"
end-icon="close"
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
title={`${file.name} (${Math.round(file.size / 1024 / 1024)} MB)`}
onClick={() => {
if (selectedFiles.length == 1) {
if (selectedFiles.length === 1) {
setAttachmentsVisible(false);
} else {
setSelectedFiles(draft => { draft.splice(i) })
setSelectedFiles(draft => {
draft.splice(i);
});
}
}}
>
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
<mdui-icon slot="icon" name="attach_file" />
<span className="name">{file.name}</span>
</mdui-chip>
))}
</div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)} />
</div>
)}
</AnimatedHeight>
<div className="chat-input">
<div className="left-buttons">
<mdui-button-icon
icon="mood"
onClick={handleEmojiButtonClick}
onMouseDown={e => e.stopPropagation()}
onMouseUp={e => e.stopPropagation()}
className="emoji-btn" />
</div>
<RichTextArea
className="message-input"
id="message-input"
@@ -160,7 +205,7 @@ export function ChatInputWrapper(
onTextChange={(value) => setMessage(value)}
onEnter={handleSubmit} />
<div className="buttons">
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn" />
<button type="submit" className="send-btn">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button>
@@ -172,6 +217,14 @@ export function ChatInputWrapper(
<div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
</MaterialDialog>
<EmojiMenu
isOpen={emojiMenuOpen}
onClose={() => setEmojiMenuOpen(false)}
onEmojiSelect={handleEmojiSelect}
position={emojiMenuPosition}
mode="standalone"
/>
</div>
);
}
@@ -1,14 +1,15 @@
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 { useAppState } from "@/pages/chat/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 { fetchUserProfile } from "@/core/api/profileApi";
import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils";
import { MaterialDialog } from "../core/Dialog";
import { delay } from "@/utils/utils";
import { MaterialDialog } from "@/core/components/Dialog";
import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
interface ChatMessagesProps {
messages?: MessageType[];
@@ -17,15 +18,23 @@ interface ChatMessagesProps {
onReplySelect?: (message: MessageType) => void;
onEditSelect?: (message: MessageType) => void;
onDelete?: (id: number) => void;
onRetryMessage?: (messageId: number) => void;
dmRecipientPublicKey?: string;
}
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) {
const { messages: hookMessages } = useChat();
export function ChatMessages({
messages = [],
children,
isDm = false,
onReplySelect,
onEditSelect,
onDelete,
onRetryMessage,
dmRecipientPublicKey }: ChatMessagesProps
) {
const { user } = useAppState();
// Use prop messages if provided, otherwise use hook messages
const messages = propMessages || hookMessages;
// Use prop messages (panels provide their own messages)
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
@@ -41,6 +50,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
useEffect(() => {
if (!deleteDialogOpen) {
setToBeDeleted(null);
@@ -92,20 +102,6 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
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);
}
@@ -117,16 +113,60 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
setDeleteDialogOpen(true);
}
function handleRetry(message: MessageType) {
if (onRetryMessage) {
onRetryMessage(message.id);
}
}
async function handleReactionClick(messageId: number, emoji: string) {
if (!user.authToken) return;
try {
if (isDm) {
// For DM messages, we need to find the dm_envelope_id from the message
const message = messages.find(m => m.id === messageId);
const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id;
if (dmEnvelopeId) {
await request<AddDmReactionRequest["data"]>({
type: "addDmReaction",
credentials: { scheme: "Bearer", credentials: user.authToken },
data: {
dm_envelope_id: dmEnvelopeId,
emoji: emoji
}
});
}
} else {
// For regular chat messages
await request<AddReactionRequest["data"]>({
type: "addReaction",
credentials: { scheme: "Bearer", credentials: user.authToken },
data: {
message_id: messageId,
emoji: emoji
}
});
}
} catch (error) {
console.error("Failed to add reaction:", error);
}
}
return (
<>
<div className="chat-messages" id="chat-messages">
{messages.map((message) => (
{messages.map((message: MessageType) => (
<Message
key={message.id}
message={message}
isAuthor={message.username === user.currentUser?.username}
onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick}
isLoadingProfile={isLoadingProfile}
isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} />
@@ -162,11 +202,15 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
onEdit={handleEdit}
onReply={handleReply}
onDelete={handleDelete}
onRetry={handleRetry}
onReactionClick={handleReactionClick}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange}
/>
)}
</>
);
}
@@ -0,0 +1,194 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
import type { Size2D } from "@/core/types";
interface BaseEmojiMenuProps {
isOpen: boolean;
onClose: () => void;
onEmojiSelect: (emoji: string) => void;
}
interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps {
position: Size2D;
mode: "standalone";
}
interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps {
mode: "integrated";
}
type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps;
export function EmojiMenu(props: EmojiMenuProps) {
const { isOpen, onClose, onEmojiSelect, mode } = props;
const position = mode === "standalone" ? props.position : undefined;
const [activeCategory, setActiveCategory] = useState("recent");
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const scrollToCategory = useCallback((categoryName: string) => {
const element = categoryRefs.current.get(categoryName);
if (element && scrollRef.current) {
element.scrollIntoView({
behavior: "smooth",
block: "start"
});
}
}, [categoryRefs, scrollRef]);
const scrollTabIntoView = useCallback((categoryName: string) => {
const tabElement = tabRefs.current.get(categoryName);
if (tabElement && tabsRef.current) {
const tabsRect = tabsRef.current.getBoundingClientRect();
const tabRect = tabElement.getBoundingClientRect();
// Check if tab is outside the visible area
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
tabElement.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center"
});
}
}
}, [tabRefs, tabsRef]);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
// Find which category is currently visible
for (const [categoryName, element] of categoryRefs.current) {
if (element) {
const rect = element.getBoundingClientRect();
const containerRect = scrollRef.current.getBoundingClientRect();
// Check if category header is in view
if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) {
if (activeCategory !== categoryName) {
setActiveCategory(categoryName);
scrollTabIntoView(categoryName);
}
break;
}
}
}
}, [activeCategory, scrollTabIntoView]);
useEffect(() => {
if (isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRecentEmojis(getRecentEmojis());
}
}, [isOpen]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
function handleEmojiClick(emoji: string) {
addRecentEmoji(emoji);
onEmojiSelect(emoji);
onClose();
};
return (
<div
ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
style={mode === "standalone" && position ? {
position: "fixed",
left: position.x,
bottom: position.y,
zIndex: 1000,
pointerEvents: isOpen ? "auto" : "none"
} : {
pointerEvents: isOpen ? "auto" : "none"
}}
>
<div className="emoji-menu-header">
<div ref={tabsRef} className="emoji-category-tabs">
{EMOJI_CATEGORIES.map((category) => (
<button
key={category.name}
ref={(el) => {
if (el) tabRefs.current.set(category.name, el);
}}
className={`emoji-category-tab ${activeCategory === category.name ? "active" : ""}`}
onClick={() => scrollToCategory(category.name)}
title={category.name}
>
<span>{category.icon}</span>
</button>
))}
</div>
</div>
<div
ref={scrollRef}
className="emoji-grid"
onScroll={handleScroll}
>
{EMOJI_CATEGORIES.map((category) => {
const emojis = category.name === "recent" ? recentEmojis : category.emojis;
return (
<div
key={category.name}
ref={(el) => {
if (el) categoryRefs.current.set(category.name, el);
}}
className="emoji-category-section"
>
<h3 className="emoji-category-title">
{category.name.charAt(0).toUpperCase() + category.name.slice(1)}
</h3>
{emojis.length > 0 ? (
<div className="emoji-category-grid">
{emojis.map((emoji, index) => (
<button
key={`${category.name}-${index}`}
className="emoji-item"
onClick={() => handleEmojiClick(emoji)}
title={emoji}
>
{emoji}
</button>
))}
</div>
) : (
<div className="emoji-empty-state">
<span>No {category.name} emojis</span>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -1,23 +1,141 @@
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 { formatTime, id } from "@/utils/utils";
import type { Attachment, Message as MessageType, Reaction } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png";
import Quote from "@/core/components/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 { useEffect, useState, useRef, useCallback } from "react";
import { getCurrentKeys } from "@/core/api/authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { getAuthHeaders } from "@/core/api/authApi";
import { useAppState } from "@/pages/chat/state";
import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
interface MessageReactionsProps {
reactions?: Reaction[];
onReactionClick: (emoji: string) => void;
messageId?: number; // Add messageId to ensure unique keys
}
function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
const { user } = useAppState();
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
const [isVisible, setIsVisible] = useState(false);
// Handle reactions with animation
useEffect(() => {
if (!reactions || reactions.length === 0) {
// If we have visible reactions, animate them out
if (visibleReactions.length > 0) {
visibleReactions.forEach(reaction => {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
});
// After animation completes, hide the component
setTimeout(() => {
setVisibleReactions([]);
setAnimatingReactions(new Set());
setIsVisible(false);
}, 200);
} else {
// No visible reactions, hide immediately
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsVisible(false);
}
return;
}
// Show the component when we have reactions
setIsVisible(true);
// Deduplicate reactions by emoji (safety measure)
const uniqueReactions = reactions.reduce((acc, reaction) => {
const existing = acc.find(r => r.emoji === reaction.emoji);
if (existing) {
// Keep the one with the higher count
if (reaction.count > existing.count) {
acc[acc.indexOf(existing)] = reaction;
}
} else {
acc.push(reaction);
}
return acc;
}, [] as Reaction[]);
// Animate out removed reactions
visibleReactions.forEach(reaction => {
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
setTimeout(() => {
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
setAnimatingReactions(prev => {
const newSet = new Set(prev);
newSet.delete(reaction.emoji);
return newSet;
});
}, 200);
}
});
// Update existing reactions and add new ones
setVisibleReactions(prev => {
const updated = [...prev];
// Update existing reactions
uniqueReactions.forEach(reaction => {
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
if (existingIndex !== -1) {
updated[existingIndex] = reaction;
} else {
// Add new reaction only if it doesn't already exist
if (!updated.some(r => r.emoji === reaction.emoji)) {
updated.push(reaction);
}
}
});
return updated;
});
}, [reactions, visibleReactions]);
// Don't render if not visible
if (!isVisible) {
return null;
}
return (
<div className="message-reactions">
{visibleReactions.map((reaction, index) => {
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
const isAnimating = animatingReactions.has(reaction.emoji);
return (
<button
key={`${messageId || "unknown"}-${reaction.emoji}-${reaction.count}-${index}`}
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")}
>
<span className="reaction-emoji">{reaction.emoji}</span>
<span className="reaction-count">{reaction.count}</span>
</button>
);
})}
</div>
);
}
interface MessageProps {
message: MessageType;
isAuthor: boolean;
onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
onReactionClick?: (messageId: number, emoji: string) => void;
isLoadingProfile?: boolean;
isDm?: boolean;
dmRecipientPublicKey?: string;
@@ -30,7 +148,16 @@ interface Rect {
height: number
}
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
export function Message({
message,
isAuthor,
onProfileClick,
onContextMenu,
onReactionClick,
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());
@@ -48,39 +175,8 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
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> => {
const decryptFile = useCallback(async (file: Attachment): Promise<string | null> => {
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
debugger;
console.warn("Conditions not met")
return null;
}
@@ -133,9 +229,39 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
} finally {
// no-op decrypt indicator removed from UI
}
};
}, [decryptedFiles, isDm, user.authToken, dmRecipientPublicKey, dmEnvelope, updateDecryptedFiles]);
const handleImageClick = async (file: Attachment, imageElement: HTMLImageElement) => {
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, decryptFile, updateDecryptedFiles]);
async function handleImageClick(file: Attachment, imageElement: HTMLImageElement) {
// Use decrypted URL if available, otherwise decrypt first
const decryptedUrl = decryptedFiles.get(file.path);
if (decryptedUrl) {
@@ -150,7 +276,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}
};
const computeEndRect = (naturalWidth: number, naturalHeight: number): Rect => {
function computeEndRect(naturalWidth: number, naturalHeight: number): Rect {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const maxWidth = Math.floor(viewportWidth * 0.9);
@@ -165,7 +291,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
return { left, top, width, height };
};
const openFullscreenFromThumb = (imgEl: HTMLImageElement, src: string, name: string) => {
function 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();
@@ -186,7 +312,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
};
};
const closeFullscreen = () => {
function closeFullscreen() {
// Reverse animation
setIsAnimatingOpen(false);
// Wait for transition to finish
@@ -198,7 +324,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}, 300);
};
const downloadImage = async () => {
async function downloadImage() {
if (!fullscreenImage) return;
const { src, name } = fullscreenImage;
try {
@@ -232,7 +358,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}
};
const downloadFile = async (file: Attachment) => {
async function downloadFile(file: Attachment) {
try {
updateDownloadingPaths(draft => {
draft.add(file.path);
@@ -286,7 +412,6 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
onContextMenu={handleContextMenu}
>
<div className="message-inner">
{/* Add profile picture for received messages */}
{!isAuthor && !isDm && (
<div className="message-profile-pic">
<img
@@ -307,12 +432,13 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
<div
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
role="link"
tabIndex={0}
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>
@@ -330,6 +456,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
const decryptedUrl = decryptedFiles.get(file.path);
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
const isDownloading = downloadingPaths.has(file.path);
const isSending = message.runtimeData?.sendingState?.status === "sending";
return (
<div className="attachment" key={idx}>
@@ -342,10 +469,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
src={imageSrc}
alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)}
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
onLoad={() => updateLoadedImages(draft => {
draft.add(file.path);
})}
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
/>
{!loadedImages.has(file.path) && (
{(!loadedImages.has(file.path) || isSending) && (
<div className="loading-overlay">
<mdui-circular-progress />
</div>
@@ -373,22 +502,43 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
</mdui-list>
)}
<Reactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
/>
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
<span className="material-symbols outlined" />
)}
{isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator">
{message.runtimeData.sendingState.status === "sending" && (
<mdui-circular-progress style={{ width: "16px", height: "16px" }} />
)}
{message.runtimeData.sendingState.status === "failed" && (
<span className="material-symbols error-icon">error</span>
)}
{message.runtimeData.sendingState.status === "sent" && (
<span className="material-symbols success-icon">check</span>
)}
</span>
)}
</div>
</div>
</div>
{/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && (
<div
{fullscreenImage && createPortal(
<div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
onClick={closeFullscreen}>
onClick={closeFullscreen}
role="dialog">
<img
src={fullscreenImage.src}
alt={fullscreenImage.name}
@@ -401,7 +551,10 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}}
onClick={e => e.stopPropagation()}
/>
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="fullscreen-controls top-right"
onClick={e => e.stopPropagation()}>
<mdui-button-icon icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? (
<div className="progress-wrapper">
@@ -411,7 +564,8 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
<mdui-button-icon icon="download" onClick={downloadImage} />
)}
</div>
</div>
</div>,
id("root")
)}
</>
);
@@ -0,0 +1,343 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { Message, Size2D } from "@/core/types";
import { EmojiMenu } from "./EmojiMenu";
interface MessageContextMenuProps {
message: Message;
isAuthor: boolean;
onEdit: (message: Message) => void;
onReply: (message: Message) => void;
onDelete: (message: Message) => void;
onRetry?: (message: Message) => void;
onReactionClick?: (messageId: number, emoji: string) => Promise<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,
onRetry,
onReactionClick,
position,
isOpen,
onOpenChange
}: MessageContextMenuProps) {
// Internal state for closing animation
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState("entering");
const [reactionBarPosition, setReactionBarPosition] = useState<"left" | "right">("left");
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
const [expandUpward, setExpandUpward] = useState(false);
const [contextMenuHeight, setContextMenuHeight] = useState<number | null>(null);
// Refs for measuring actual dimensions
const wrapperRef = useRef<HTMLDivElement>(null);
const reactionBarRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const emojiMenuRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => {
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
// Reset emoji menu state after context menu animation completes
setIsEmojiMenuExpanded(false);
setInitialDimensions(null);
setExpandUpward(false);
setContextMenuHeight(null);
}, 200); // Match the animation duration from _animations.scss
}, [animationClass, onOpenChange]);
// Calculate smart positioning when component opens
useEffect(() => {
if (isOpen) {
// Use a small delay to ensure elements are rendered before measuring
const frameId = requestAnimationFrame(() => {
if (wrapperRef.current && reactionBarRef.current && contextMenuRef.current) {
// Get actual dimensions from DOM elements
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
const contextMenuRect = contextMenuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate shared/combined rect dimensions
const sharedRect = {
width: Math.max(reactionBarRect.width, contextMenuRect.width),
height: reactionBarRect.height + contextMenuRect.height
};
let x = position.x;
let y = position.y;
let animation = "entering";
let reactionPosition: "left" | "right" = "left";
// Check if shared rect would overflow and adjust position
if (x + sharedRect.width > viewportWidth) {
x = position.x - contextMenuRect.width - 25;
animation = "entering-left";
reactionPosition = "right";
} else {
reactionPosition = "left";
}
// Ensure menu doesn't go off the left edge
if (x < 0) {
x = 0;
}
// Check if shared rect would overflow bottom edge
if (y + sharedRect.height > viewportHeight) {
y = viewportHeight - sharedRect.height;
animation = "entering-up";
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
setReactionBarPosition(reactionPosition);
}
});
return () => cancelAnimationFrame(frameId);
}
}, [isOpen, position, isAuthor]);
// Effect to handle clicks outside the context menu
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (isOpen && !isClosing) {
// Check if the click is on a context menu element or reaction bar
const target = event.target as Element;
if (!target.closest(".context-menu") && !target.closest(".context-menu-reaction-bar")) {
handleClose();
}
}
};
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && isOpen && !isClosing) {
handleClose();
}
};
function 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, handleClose]);
interface Action {
label: string;
icon: string;
onClick: () => void;
show: boolean;
}
// Check if message is sending or failed
const isSending = message.runtimeData?.sendingState?.status === "sending";
const isFailed = message.runtimeData?.sendingState?.status === "failed";
const isSendingOrFailed = isSending || isFailed;
const actions: Action[] = [
{
label: "Reply",
icon: "reply",
onClick: () => {
onReply(message);
handleClose();
},
show: !isSendingOrFailed
},
{
label: "Edit",
icon: "edit",
onClick: () => {
onEdit(message);
handleClose();
},
show: isAuthor && !isSendingOrFailed
},
{
label: "Retry",
icon: "refresh",
onClick: () => {
if (onRetry) {
onRetry(message);
}
handleClose();
},
show: isAuthor && isFailed && !!onRetry
},
{
label: "Delete",
icon: "delete",
onClick: () => {
onDelete(message);
handleClose();
},
show: isAuthor
}
];
// Quick reactions for the reaction bar
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
async function handleReactionClick(emoji: string) {
if (onReactionClick) {
await onReactionClick(message.id, emoji);
}
handleClose();
}
function handleExpandClick() {
if (!reactionBarRef.current || !wrapperRef.current) return;
// Measure the actual dimensions of the reaction bar content
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
const wrapperRect = wrapperRef.current.getBoundingClientRect();
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
setContextMenuHeight(wrapperRect.height);
// Check if expanding downward would cause overflow
// Calculate space from the reaction bar's bottom edge downward
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - reactionBarRect.bottom;
const emojiMenuHeight = 400;
// Only expand upward if there's not enough space below for the emoji menu
const shouldExpandUpward = spaceBelow < emojiMenuHeight;
setExpandUpward(shouldExpandUpward);
// Use requestAnimationFrame to ensure the dimensions are applied before expansion
requestAnimationFrame(() => {
setIsEmojiMenuExpanded(true);
});
}
function handleEmojiSelect(emoji: string) {
if (onReactionClick) {
onReactionClick(message.id, emoji);
}
handleClose();
}
return isOpen && (
<div
ref={wrapperRef}
className={`context-menu-wrapper ${animationClass}`}
style={{
position: "fixed",
top: calculatedPosition.y,
left: calculatedPosition.x,
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}
role="menu"
tabIndex={0}>
{/* Reaction Bar */}
<div
ref={reactionBarRef}
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
style={isEmojiMenuExpanded && !expandUpward ? {
position: "fixed",
top: `${(-(contextMenuHeight || 0) + 95)}px`,
width: "320px",
height: "400px",
zIndex: 1001
} : initialDimensions && !isEmojiMenuExpanded ? {
width: `${initialDimensions.width}px`,
height: `${initialDimensions.height}px`
} : {}}>
{!isEmojiMenuExpanded ? (
<div className="reaction-bar-content">
{QUICK_REACTIONS.map((emoji, index) => (
<button
key={index}
className="reaction-emoji-button"
onClick={async () => await handleReactionClick(emoji)}
title={emoji}
>
{emoji}
</button>
))}
<button
className="reaction-expand-button"
onClick={handleExpandClick}
title="More emojis"
>
<span className="material-symbols">add</span>
</button>
</div>
) : (
<div
ref={emojiMenuRef}
className="emoji-menu-wrapper">
<EmojiMenu
isOpen={true}
onClose={handleClose}
onEmojiSelect={handleEmojiSelect}
mode="integrated"
/>
</div>
)}
</div>
{/* Context Menu */}
<div
ref={contextMenuRef}
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
{actions.map((action, i) => (
action.show && (
<div
className="context-menu-item"
onClick={action.onClick}
key={i}
role="menuitem"
tabIndex={0}
>
<span className="material-symbols">{action.icon}</span>
{action.label}
</div>
)
))}
</div>
</div>
)
}
@@ -0,0 +1,330 @@
import { useState, useEffect, useRef } from "react";
import { useAppState } from "@/pages/chat/state";
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
import { setGlobalMessageHandler } from "@/core/websocket";
import type { Message, WebSocketMessage } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png";
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
import type { DMPanel } from "./panels/DMPanel";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
}
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, chat } = useAppState();
const messagePanelRef = useRef<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const [switchIn, setSwitchIn] = useState(false);
const [switchOut, setSwitchOut] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const previousMessageCountRef = useRef(0);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
const [editMessage, setEditMessage] = useState<Message | null>(null);
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
// Drag & drop
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const addFilesRef = useRef<null | ((files: File[]) => void)>(null);
useEffect(() => {
if (!panel || !panelState) return;
return () => {
dragCounterRef.current = 0;
setIsDragging(false);
};
}, [panel, panelState]);
useEffect(() => {
if (replyTo) {
setReplyToVisible(true);
}
}, [replyTo]);
useEffect(() => {
if (editMessage) {
setEditVisible(true);
}
}, [editMessage]);
// Handle panel state changes
useEffect(() => {
if (panel) {
setPanelState(panel.getState());
// Store the handler for cleanup
panel.onStateChange = (newState: MessagePanelState) => {
setPanelState(newState);
};
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler((message: WebSocketMessage<object>) => panel.handleWebSocketMessage(message));
}
} else {
setPanelState(null);
setGlobalMessageHandler(null);
}
return () => {
if (panel) {
if (panel.onStateChange) {
panel.onStateChange = null;
}
if (typeof panel.destroy === "function") {
panel.destroy();
}
}
};
}, [panel]);
// Handle chat switching animation with event listeners
useEffect(() => {
if (chat.isSwitching) {
setSwitchOut(true);
// Use animation event listeners instead of hardcoded delays
function handleAnimationEnd(event: Event) {
const animationEvent = event as AnimationEvent;
if (animationEvent.animationName === "fadeOutUp") {
// Apply pending panel exactly at the boundary between animations
applyPendingPanel();
setSwitchOut(false);
setSwitchIn(true);
} else if (animationEvent.animationName === "fadeInDown") {
setSwitchIn(false);
// End the chat switching state
chat.setIsSwitching(false);
}
}
// Add event listener to document to catch all animation events
document.addEventListener("animationend", handleAnimationEnd);
// Cleanup function
return () => {
document.removeEventListener("animationend", handleAnimationEnd);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chat.isSwitching, chat.setIsSwitching, applyPendingPanel]);
// Load messages when panel changes and animation is not running
useEffect(() => {
if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return;
const panelState = chat.activePanel.getState();
if (panelState.messages.length === 0 && !panelState.isLoading) {
chat.activePanel.loadMessages();
}
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]);
// Scroll to bottom only when new messages are added
useEffect(() => {
if (!panelState || chat.isSwitching || switchOut || switchIn) return;
const currentMessageCount = panelState.messages.length;
const previousMessageCount = previousMessageCountRef.current;
const el = messagesEndRef.current;
if (!el) return;
if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) {
el.scrollIntoView({ behavior: "instant", block: "end" });
} else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
const id = requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "end" });
});
return () => cancelAnimationFrame(id);
}
// Update the previous message count
previousMessageCountRef.current = currentMessageCount;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div
ref={messagePanelRef}
className="chat-main"
id="chat-inner"
onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current += 1;
// Only show overlay when actual files are dragged
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
if (hasFiles) setIsDragging(true);
} : undefined}
onDragOver={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
} : undefined}
onDragLeave={panel ? (e) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) setIsDragging(false);
} : undefined}
onDrop={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files || []);
if (files.length > 0 && addFilesRef.current) {
addFilesRef.current(files);
}
setIsDragging(false);
dragCounterRef.current = 0;
} : undefined}>
<div className="chat-header">
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={panel?.handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }} />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<p>
<span className={`online-status ${panelState?.online ? "online" : ""}`} />
{panelState ? (
panelState.online ? "Online" : "Offline"
) : (
"Выберите чат, чтобы начать переписку"
)}
</p>
</div>
</div>
</div>
{panelState?.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка сообщений...
</div>
</div>
) : panelState && panel ? (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
) : (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите чат на боковой панели, чтобы начать переписку
</div>
</div>
)}
{panel && (
<>
<AnimatedOpacity
visible={isDragging}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
<mdui-icon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</AnimatedOpacity>
<ChatInputWrapper
onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null);
}}
onSaveEdit={(content) => {
if (editMessage) {
panel.handleEditMessage(editMessage.id, content);
setEditMessage(null);
}
}}
replyTo={replyTo}
replyToVisible={replyToVisible}
onClearReply={() => {
setPendingAction(null);
setReplyToVisible(false);
}}
onCloseReply={() => {
setReplyTo(null);
if (pendingAction && pendingAction.type === "edit") {
setEditMessage(pendingAction.message);
setPendingAction(null);
}
}}
editingMessage={editMessage}
editVisible={editVisible}
onClearEdit={() => {
setPendingAction(null);
setEditVisible(false);
}}
onCloseEdit={() => {
setEditMessage(null);
if (pendingAction && pendingAction.type === "reply") {
setReplyTo(pendingAction.message);
setPendingAction(null);
}
}}
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
messagePanelRef={messagePanelRef}
/>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,8 @@
import { useAppState } from "@/pages/chat/state";
import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() {
const { chat } = useAppState();
return <MessagePanelRenderer panel={chat.activePanel} />
}
@@ -1,8 +1,8 @@
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";
import type { DialogProps } from "@/core/types";
import type { UserProfile } from "@/core/types";
import { MaterialDialog } from "@/core/components/Dialog";
import { formatTime } from "@/utils/utils";
import defaultAvatar from "@/images/default-avatar.png";
interface UserProfileDialogProps extends DialogProps {
userProfile: UserProfile | null;
@@ -13,8 +13,7 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
<div className="content">
<div className="profile-picture-section">
<img
className="profile-picture"
alt="Profile Picture"
className="profile-picture"
src={userProfile.profile_picture || defaultAvatar}
onError={(e) => {
const target = e.target as HTMLImageElement;
@@ -28,17 +27,17 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
{userProfile.online ? (
<>
<span className="online-indicator"></span> Онлайн
<span className="online-indicator" /> Онлайн
</>
) : (
<>
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
<span className="offline-indicator" /> Последний заход {formatTime(userProfile.last_seen)}
</>
)}
</div>
</div>
<div className="bio-section">
<label>О себе:</label>
<span>О себе:</span>
<div className="bio-display">
{userProfile.bio || "No bio available."}
</div>
@@ -55,7 +54,7 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
</div>
<div className="profile-actions">
<mdui-button id="dm-button" variant="filled">
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
<mdui-icon slot="icon" name="chat--filled" />
Send Message
</mdui-button>
</div>

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