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: When working with this project, follow these rules:
## Core Behavior
- NEVER do anything i didn't ask you for! - 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 - 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: need to typecheck or build the app, in that case:
- To typecheck, run `npm run frontend:typecheck`. - To typecheck, run `npm run frontend:typecheck`.
- To build, run `npm run frontend:build`. - 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". ## Async Operations
- Do NOT "cd" to the project directory.
- If possible, try to update files in a single edit.
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async, - 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`. make it async.
- When you complete your task, remove unused imports if there are any.
## 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 alwaysApply: true
--- ---
When you work with UI: When you work with UI:
1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML. 1. Use MDUI components 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". 3. The supporting text slot for MDUI lists is "description".
4. When working with lists/sets in states, use the "useImmer" hook. 4. When working with lists/sets in states, use the "useImmer" hook.
+198 -8
View File
@@ -1,6 +1,6 @@
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig # 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 # 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,macos,node,osx # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,osx,node,macos,react,reactnative
### macOS ### ### macOS ###
# General # General
@@ -203,6 +203,8 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/
lib64/
parts/ parts/
sdist/ sdist/
var/ var/
@@ -353,6 +355,194 @@ poetry.toml
# LSP config files # LSP config files
pyrightconfig.json 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 ### ### VisualStudioCode ###
.vscode/* .vscode/*
!.vscode/settings.json !.vscode/settings.json
@@ -372,15 +562,15 @@ pyrightconfig.json
.history .history
.ionide .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) # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
data backend/data
.vite .vite
*.db *.db
package-lock.json package-lock.json
dist-electron backend/alembic/**
backend/migrations/** !backend/alembic/env.py
!backend/migrations/env.py !backend/alembic/script.py.mako
!backend/migrations/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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from migration import run_auto_migration from contextlib import asynccontextmanager
from db import engine import subprocess
import sys
import os
from routes import account, messaging, profile, push 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 # Инициализация FastAPI
app = FastAPI(title="PixelChat") app = FastAPI(title="FromChat", lifespan=lifespan)
# CORS # CORS
app.add_middleware( app.add_middleware(
@@ -21,13 +48,4 @@ app.add_middleware(
app.include_router(account.router) app.include_router(account.router)
app.include_router(messaging.router) app.include_router(messaging.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push") 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
+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 import os
from pathlib import Path import logging
from typing import Optional
from traceback import format_exc
import hashlib
from sqlalchemy.engine import Engine
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from models import Base from sqlalchemy import create_engine
from constants import DATABASE_URL from constants import DATABASE_URL
import logging
logger = logging.getLogger(__name__)
MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" def run_migrations():
LOCK_FILE = MIGRATIONS_DIR / ".autogen.lock" """
SCHEMA_HASH_FILE = MIGRATIONS_DIR / ".schema.hash" Run database migrations using Alembic.
This function will upgrade the database to the latest migration.
Fully automated - handles all scenarios automatically.
def _ensure_alembic_layout() -> None: """
"""Create a minimal Alembic environment if missing."""
versions = MIGRATIONS_DIR / "versions"
versions.mkdir(parents=True, exist_ok=True)
def _alembic_config() -> Config:
cfg = Config()
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Provide a minimal ini section so env.py can read config_ini_section
cfg.config_file_name = "alembic.ini"
cfg.set_section_option("alembic", "sqlalchemy.url", DATABASE_URL)
return cfg
def _model_schema_fingerprint() -> str:
"""Compute a deterministic fingerprint of the current SQLAlchemy model schema."""
parts: list[str] = []
md = Base.metadata
for table in sorted(md.tables.values(), key=lambda t: t.name):
parts.append(f"T:{table.name}")
for col in sorted(table.columns, key=lambda c: c.name):
col_type = str(col.type)
parts.append(f"C:{col.name}:{col_type}:N{int(bool(col.nullable))}")
digest = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
return digest
def run_auto_migration(engine: Engine) -> None:
"""Use Alembic to autogenerate and apply migrations automatically on startup."""
# Ensure env present
_ensure_alembic_layout()
cfg = _alembic_config()
try: try:
# Upgrade existing migrations (if any) first # Get the directory where this script is located
command.upgrade(cfg, "head") current_dir = os.path.dirname(os.path.abspath(__file__))
except Exception:
print("[alembic] upgrade to head failed:\n" + format_exc()) # 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 if not os.path.exists(versions_dir):
try: os.makedirs(versions_dir)
# Avoid concurrent autogenerate on dev server reloads
try:
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.close(fd)
have_lock = True
except FileExistsError:
have_lock = False
if have_lock: 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: try:
new_hash = _model_schema_fingerprint() # Create a new migration to detect any schema changes
old_hash = SCHEMA_HASH_FILE.read_text(encoding="utf-8").strip() if SCHEMA_HASH_FILE.exists() else "" command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration for schema changes")
if new_hash != old_hash:
command.revision(cfg, message="auto", autogenerate=True) # Check if the new migration is empty (no changes detected)
command.upgrade(cfg, "head") migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
# Update stored fingerprint if migration_files:
SCHEMA_HASH_FILE.write_text(new_hash, encoding="utf-8") latest_migration = max(migration_files)
finally: migration_path = os.path.join(versions_dir, latest_migration)
try:
LOCK_FILE.unlink(missing_ok=True) # Check if migration is empty
except Exception: with open(migration_path, 'r') as f:
pass content = f.read()
except Exception: 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:
print("[alembic] autogenerate failed:\n" + format_exc()) 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.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 sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from db import engine
from pydantic import BaseModel from pydantic import BaseModel
Base = declarative_base() Base = declarative_base()
@@ -37,6 +36,7 @@ class Message(Base):
author = relationship("User", back_populates="messages") author = relationship("User", back_populates="messages")
reply_to = relationship("Message", remote_side=[id]) reply_to = relationship("Message", remote_side=[id])
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select") files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select")
class MessageFile(Base): class MessageFile(Base):
@@ -80,6 +80,7 @@ class DMEnvelope(Base):
reply_to_id = Column(Integer, nullable=True) reply_to_id = Column(Integer, nullable=True)
timestamp = Column(DateTime, default=datetime.now) timestamp = Column(DateTime, default=datetime.now)
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select") files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
class DMFile(Base): class DMFile(Base):
@@ -107,6 +108,39 @@ class PushSubscription(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Reaction(Base):
__tablename__ = "reaction"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
# Ensure unique combination of message, user, and emoji
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
class DMReaction(Base):
__tablename__ = "dm_reaction"
id = Column(Integer, primary_key=True, index=True)
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
# Ensure unique combination of dm_envelope, user, and emoji
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
# Pydantic модели # Pydantic модели
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
@@ -121,7 +155,7 @@ class RegisterRequest(BaseModel):
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
content: str content: str
reply_to_id: int | None reply_to_id: int | None = None
class EditMessageRequest(BaseModel): class EditMessageRequest(BaseModel):
@@ -167,5 +201,39 @@ class MessageResponse(BaseModel):
from_attributes = True from_attributes = True
# Создание таблиц class ReactionRequest(BaseModel):
Base.metadata.create_all(bind=engine) 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 pywebpush>=1.14.0
cryptography>=41.0.0 cryptography>=41.0.0
alembic>=1.13.2 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.commit()
db.refresh(new_user) db.refresh(new_user)
token = create_token(new_user.id, new_user.username)
return { return {
"status": "success", "status": "success",
"message": "Регистрация прошла успешно Теперь вы можете войти." "message": "Регистрация прошла успешно",
"token": token,
"user": convert_user(new_user)
} }
@router.get("/crypto/public-key") @router.get("/crypto/public-key")
+239 -1
View File
@@ -10,7 +10,7 @@ from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME 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 push_service import push_service
from PIL import Image from PIL import Image
import io import io
@@ -31,6 +31,23 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
def convert_message(msg: Message) -> dict: 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 { return {
"id": msg.id, "id": msg.id,
"content": msg.content, "content": msg.content,
@@ -40,6 +57,7 @@ def convert_message(msg: Message) -> dict:
"username": msg.author.username, "username": msg.author.username,
"profile_picture": msg.author.profile_picture, "profile_picture": msg.author.profile_picture,
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None, "reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
"reactions": list(reactions_dict.values()),
"files": [ "files": [
{ {
"path": f"/api/uploads/files/normal/{Path(f.path).name}", "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 # - denis0001-dev
@@ -436,6 +495,125 @@ async def delete_message(
return {"status": "success", "message_id": message_id} 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: class MessaggingSocketManager:
def __init__(self) -> None: def __init__(self) -> None:
self.connections: list[WebSocket] = [] self.connections: list[WebSocket] = []
@@ -670,6 +848,66 @@ class MessaggingSocketManager:
"data": {"message_id": message_id} "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}) await websocket.send_json({"type": type, "data": response})
except HTTPException as e: except HTTPException as e:
await self.send_error(websocket, type, e) await self.send_error(websocket, type, e)
+1 -11
View File
@@ -17,8 +17,6 @@ services:
target: /app target: /app
- action: rebuild - action: rebuild
path: ../backend/requirements.txt path: ../backend/requirements.txt
networks:
- main
frontend: frontend:
build: build:
@@ -40,15 +38,7 @@ services:
target: /server/server.js target: /server/server.js
- action: rebuild - action: rebuild
path: package.json path: package.json
networks:
- main
- default
volumes: volumes:
data: data:
name: fromchat-data name: fromchat-data
networks:
main:
driver: bridge
internal: true # isolate from the outside world
+6 -1
View File
@@ -11,6 +11,7 @@ RUN --mount=type=cache,target=/root/.npm \
COPY frontend frontend COPY frontend frontend
RUN npm run frontend:build RUN npm run frontend:build
# 2. Build the static file server # 2. Build the static file server
FROM node:24 AS server FROM node:24 AS server
@@ -23,6 +24,10 @@ RUN --mount=type=cache,target=/root/.npm \
# 2.2. Copy the code # 2.2. Copy the code
COPY deployment/frontend/ . COPY deployment/frontend/ .
# 2.3. Build
RUN npm run build
# 3. Put it all together # 3. Put it all together
FROM node:24-slim FROM node:24-slim
@@ -44,4 +49,4 @@ COPY --from=server --chown=app /server .
# 4. Final command # 4. Final command
ENV STATIC_FILE_PATH=/app 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", "version": "1.0.0",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "ts-node server.ts",
"build": "tsc -b",
"start:prod": "node dist/server.js"
}, },
"dependencies": { "dependencies": {
"express": "^5.1.0", "express": "^5.1.0",
"http-proxy-middleware": "^3.0.5" "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 { app, BrowserWindow, Notification, ipcMain } from 'electron';
import path from "node:path"; import path from "node:path";
import { NotificationShowOptions } from '../electron'; import type { NotificationShowOptions } from '../electron.d.ts';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@@ -13,7 +13,6 @@ app.whenReady().then(() => {
preload: path.join(import.meta.dirname, "preload.mjs") preload: path.join(import.meta.dirname, "preload.mjs")
}, },
titleBarStyle: "hidden", titleBarStyle: "hidden",
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
trafficLightPosition: { trafficLightPosition: {
x: 16 - 4, x: 16 - 4,
y: 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 charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading...</title> <title>Loading...</title>
<link rel="icon" href="./src/resources/images/logo.png" /> <link rel="icon" href="./src/images/logo.png" />
</head> </head>
<body> <body>
<div id="root"></div> <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 type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types";
import { getAuthHeaders } from "./api"; import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { generateX25519KeyPair } from "../utils/crypto/asymmetric"; import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../utils/crypto/backup"; import { b64, ub64 } from "@/utils/utils";
import { b64, ub64 } from "../utils/utils"; import { API_BASE_URL } from "@/core/config";
import type { BackupBlob, UploadPublicKeyRequest } 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;
}
let currentPublicKey: Uint8Array | null = null; let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: 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> { 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`, { await fetch(`${API_BASE_URL}/crypto/public-key`, {
method: "POST", method: "POST",
headers, headers: getAuthHeaders(token, true),
body: JSON.stringify(payload) body: JSON.stringify({
publicKey: b64(publicKey)
} satisfies UploadPublicKeyRequest)
}); });
} }
@@ -1,12 +1,12 @@
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../auth/api"; import { getAuthHeaders } from "./authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "../utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/symmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "../utils/crypto/kdf"; import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "../auth/crypto"; import { getCurrentKeys } from "./authApi";
import { request } from "../core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "../core/types"; import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types";
import { b64, ub64 } from "../utils/utils"; import { b64, ub64 } from "@/utils/utils";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> { export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys(); const keys = getCurrentKeys();
@@ -46,7 +46,13 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
return data.messages || []; 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(); const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); 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(); const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); if (!keys) throw new Error("Keys not initialized");
@@ -1,6 +1,6 @@
import { getAuthHeaders } from "../auth/api"; import { getAuthHeaders } from "./authApi";
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "@/core/config";
import type { UserProfile } from "../core/types"; import type { UserProfile } from "@/core/types";
export interface ProfileData { export interface ProfileData {
profile_picture?: string; profile_picture?: string;
@@ -33,7 +33,7 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
return null; return null;
} catch (error) { } catch (error) {
console.error('Error loading profile:', error); console.error("Error loading profile:", error);
return null; 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> { export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
try { try {
const formData = new FormData(); 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`, { const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
method: 'POST', method: "POST",
body: formData, body: formData,
headers: getAuthHeaders(token, false) headers: getAuthHeaders(token, false)
}); });
@@ -57,7 +57,7 @@ export async function uploadProfilePicture(token: string, file: Blob): Promise<U
} }
return null; return null;
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error("Upload error:", error);
return null; return null;
} }
} }
@@ -74,17 +74,17 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
}; };
const response = await fetch(`${API_BASE_URL}/user/profile`, { const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'PUT', method: "PUT",
headers: { headers: {
...getAuthHeaders(token), ...getAuthHeaders(token),
'Content-Type': 'application/json' "Content-Type": "application/json"
}, },
body: JSON.stringify(backendData) body: JSON.stringify(backendData)
}); });
return response.ok; return response.ok;
} catch (error) { } catch (error) {
console.error('Error updating profile:', error); console.error("Error updating profile:", error);
return false; 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> { export async function updateBio(token: string, bio: string): Promise<boolean> {
try { try {
const response = await fetch(`${API_BASE_URL}/user/bio`, { const response = await fetch(`${API_BASE_URL}/user/bio`, {
method: 'PUT', method: "PUT",
headers: getAuthHeaders(token), headers: getAuthHeaders(token),
body: JSON.stringify({ bio }) body: JSON.stringify({ bio })
}); });
return response.ok; return response.ok;
} catch (error) { } catch (error) {
console.error('Error updating bio:', error); console.error("Error updating bio:", error);
return false; return false;
} }
} }
@@ -122,7 +122,7 @@ export async function fetchUserProfile(token: string, username: string): Promise
return null; return null;
} catch (error) { } catch (error) {
console.error('Error fetching user profile:', error); console.error("Error fetching user profile:", error);
return null; return null;
} }
} }
@@ -1,8 +1,8 @@
import type { Dialog as MduiDialog } from "mdui/components/dialog"; import type { Dialog as MduiDialog } from "mdui/components/dialog";
import { useEffect, type Ref } from "react" import { useEffect, type Ref } from "react"
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { id } from "../../../utils/utils"; import { id } from "@/utils/utils";
import useCombinedRefs from "../../hooks/useCombinedRefs"; import useCombinedRefs from "@/core/hooks/useCombinedRefs";
export interface BaseDialogProps { export interface BaseDialogProps {
onOpenChange: (value: boolean) => void; onOpenChange: (value: boolean) => void;
@@ -12,7 +12,9 @@ export interface BaseDialogProps {
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps; export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
export function MaterialDialog(props: FullDialogProps) { export function MaterialDialog(props: FullDialogProps) {
// eslint-disable-next-line react-hooks/refs
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref); const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
const { open, onOpenChange } = props;
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; const dialog = dialogRef.current;
@@ -22,8 +24,8 @@ export function MaterialDialog(props: FullDialogProps) {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "open") { if (mutation.type === "attributes" && mutation.attributeName === "open") {
const isOpen = dialog.hasAttribute("open"); const isOpen = dialog.hasAttribute("open");
if (isOpen !== props.open) { if (isOpen !== open) {
props.onOpenChange(isOpen); onOpenChange(isOpen);
} }
} }
}); });
@@ -39,7 +41,8 @@ export function MaterialDialog(props: FullDialogProps) {
return () => { return () => {
observer.disconnect(); 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")); return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
} }
@@ -20,18 +20,18 @@ export function RichTextArea({
placeholder, placeholder,
className, className,
rows = 1, rows = 1,
autoComplete = "off", autoComplete = "off"
}: RichTextAreaProps) { }: RichTextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null); const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const heightRef = useRef<number | null>(null); const heightRef = useRef<number | null>(null);
const getStyleValue = (computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number => { function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
const raw = (computedStyle as any)[prop] as string | number | undefined; const raw = computedStyle[prop] as string | number | undefined;
if (raw == null) return 0; if (raw === null) return 0;
const str = String(raw); const str = String(raw);
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0; return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
}; }
const calculateTextareaStyles = useCallback(() => { const calculateTextareaStyles = useCallback(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
@@ -116,9 +116,7 @@ export function RichTextArea({
textarea.style.overflowY = overflowing ? "hidden" : ""; textarea.style.overflowY = overflowing ? "hidden" : "";
}, [calculateTextareaStyles]); }, [calculateTextareaStyles]);
const useEnhancedEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; useLayoutEffect(() => {
useEnhancedEffect(() => {
syncHeight(); syncHeight();
}, [syncHeight, text]); }, [syncHeight, text]);
@@ -142,13 +140,13 @@ export function RichTextArea({
}; };
}, [syncHeight]); }, [syncHeight]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
// Keep height responsive during rapid uncontrolled input bursts // Keep height responsive during rapid uncontrolled input bursts
syncHeight(); syncHeight();
onTextChange(e.target.value); 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 isCtrlEnter = e.key === "Enter" && (e.ctrlKey || e.metaKey);
const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey; const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
@@ -175,7 +173,7 @@ export function RichTextArea({
return; return;
} }
} }
}; }
return ( return (
<> <>
@@ -204,7 +202,7 @@ export function RichTextArea({
height: "auto", height: "auto",
minHeight: 0, minHeight: 0,
maxHeight: "none", maxHeight: "none",
overflow: "hidden", overflow: "hidden"
}} }}
rows={1} rows={1}
/> />
@@ -3,7 +3,9 @@ import type { TextField } from "mdui/components/text-field";
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field"> type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) { export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
return <mdui-text-field return (
autocomplete="off" <mdui-text-field
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} /> autocomplete="off"
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
);
} }
@@ -10,6 +10,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldRender(true); setShouldRender(true);
setIsAnimating(true); setIsAnimating(true);
// Wait for content to render, then measure // Wait for content to render, then measure
@@ -33,6 +34,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
requestAnimationFrame(() => { requestAnimationFrame(() => {
// Read layout to ensure the previous height assignment is flushed // Read layout to ensure the previous height assignment is flushed
if (containerRef.current) { if (containerRef.current) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
containerRef.current.offsetHeight; containerRef.current.offsetHeight;
} }
// Use a second frame to ensure the measured pixel height is applied before collapsing // 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); }, duration * 1000);
} }
}, [visible, shouldRender]); }, [visible, shouldRender, duration, onFinish]);
return (visible || shouldRender || isAnimating) && ( return (visible || shouldRender || isAnimating) && (
<div <div
@@ -7,6 +7,7 @@ export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, chi
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldRender(true); setShouldRender(true);
setOpacity(0); setOpacity(0);
@@ -36,6 +37,7 @@ export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, chi
transition: `opacity ${duration}s ease`, transition: `opacity ${duration}s ease`,
...props.style ...props.style
}} }}
>{children}</div> >{children}
</div>
); );
} }
@@ -1,6 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export interface BaseAnimatedPropertyProps { export interface BaseAnimatedPropertyProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
visible: any; visible: any;
duration?: number; duration?: number;
onFinish?: () => void onFinish?: () => void
@@ -1,4 +1,4 @@
@use "common/material" as *; @use "../../css/material" as *;
#electron-title-bar { #electron-title-bar {
display: none; display: none;
@@ -5,7 +5,9 @@
* @version 1.0.0 * @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) { if (isElectron) {
console.log("Running in Electron"); 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, который может быть либо функцией, либо объектом // Определяем тип для ref, который может быть либо функцией, либо объектом
type PossibleRef<T> = Ref<T> | undefined; type PossibleRef<T> = Ref<T> | undefined;
@@ -14,7 +14,7 @@ export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallb
refs.forEach((ref) => { refs.forEach((ref) => {
if (!ref) return; if (!ref) return;
if (typeof ref === 'function') { if (typeof ref === "function") {
// Если ref - это функция, вызываем её // Если ref - это функция, вызываем её
ref(node); ref(node);
} else { } 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 { API_BASE_URL } from "@/core/config";
import { isElectron } from "../electron/electron"; import { isElectron } from "@/core/electron/electron";
import { websocket } from "../core/websocket"; import { websocket } from "@/core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "../core/types"; import type { Message, NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
import serviceWorker from "./service-worker?worker&url";
export interface PushSubscriptionData { export interface PushSubscriptionData {
endpoint: string; endpoint: string;
@@ -17,7 +18,6 @@ export interface NotificationPayload {
icon?: string; icon?: string;
image?: string; image?: string;
tag?: string; tag?: string;
data?: any;
} }
// Global state // 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 { try {
await showNotification({ await showNotification({
title: `New message from ${message.username}`, title: `New message from ${message.username}`,
@@ -111,20 +111,14 @@ async function showMessageNotification(message: any): Promise<void> {
? message.content.substring(0, 100) + "..." ? message.content.substring(0, 100) + "..."
: message.content, : message.content,
icon: message.profile_picture || "/logo.png", icon: message.profile_picture || "/logo.png",
tag: `message_${message.id}`, tag: `message_${message.id}`
data: {
type: "public_message",
message_id: message.id,
sender_id: message.user_id,
sender_username: message.username
}
}); });
} catch (error) { } catch (error) {
console.error("Failed to show message notification:", 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 // Handle notifications for new messages
if (response.type === "newMessage" && response.data) { if (response.type === "newMessage" && response.data) {
const newResponse = response as NewMessageWebSocketMessage; const newResponse = response as NewMessageWebSocketMessage;
@@ -152,7 +146,7 @@ export async function initialize(): Promise<boolean> {
} }
try { try {
registration = await navigator.serviceWorker.register("/assets/serviceWorker.js"); registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
console.log("Service Worker registered successfully"); console.log("Service Worker registered successfully");
const permission = await Notification.requestPermission(); 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> { export async function showNotification(payload: NotificationPayload): Promise<boolean> {
if (isElectron) { if (isElectron) {
try { try {
return await window.electronInterface.notifications.show({ return await window.electronInterface.notifications.show(payload);
title: payload.title,
body: payload.body,
icon: payload.icon,
tag: payload.tag
});
} catch (error) { } catch (error) {
console.error("Failed to show Electron notification:", error); console.error("Failed to show Electron notification:", error);
return false; return false;
@@ -243,14 +232,14 @@ export async function startElectronReceiver(): Promise<void> {
// Add our own message listener to the existing WebSocket // Add our own message listener to the existing WebSocket
messageListener = (event: MessageEvent) => { messageListener = (event: MessageEvent) => {
try { try {
const response: WebSocketMessage<any> = JSON.parse(event.data); const response: WebSocketMessage<object> = JSON.parse(event.data);
handleWebSocketMessage(response); handleWebSocketMessage(response);
} catch (error) { } 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 { export function stopElectronReceiver(): void {
@@ -262,7 +251,7 @@ export function stopElectronReceiver(): void {
// Remove our message listener // Remove our message listener
if (messageListener) { if (messageListener) {
websocket.removeEventListener('message', messageListener); websocket.removeEventListener("message", messageListener);
messageListener = null; messageListener = null;
} }
} }
@@ -8,7 +8,7 @@ interface NotificationPayload {
icon?: string; icon?: string;
image?: string; image?: string;
tag?: string; tag?: string;
data?: any; data?: object;
} }
interface NotificationAction { interface NotificationAction {
@@ -22,7 +22,7 @@ interface NotificationOptions {
badge: string; badge: string;
image?: string; image?: string;
tag: string; tag: string;
data?: any; data?: object;
actions: NotificationAction[]; actions: NotificationAction[];
requireInteraction: boolean; requireInteraction: boolean;
silent: 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 {string} [profile_picture] - URL to sender's profile picture
* @property {Message} [reply_to] - The message this is replying to * @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 { export interface Message {
id: number; id: number;
username: string; username: string;
@@ -61,9 +70,19 @@ export interface Message {
profile_picture?: string; profile_picture?: string;
reply_to?: Message; reply_to?: Message;
files?: Attachment[]; files?: Attachment[];
reactions?: Reaction[];
runtimeData?: { runtimeData?: {
dmEnvelope?: DmEnvelope; 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; senderId: number;
files?: DmFile[]; files?: DmFile[];
timestamp: string; timestamp: string;
reactions?: Reaction[];
} }
export interface DmFile { export interface DmFile {
@@ -219,7 +239,7 @@ export interface FetchDMResponse {
} }
export interface DmEncryptedJSON { export interface DmEncryptedJSON {
type: "text", type: "text";
data: { data: {
content: string; content: string;
reply_to_id?: number; reply_to_id?: number;
@@ -290,13 +310,13 @@ export interface DMEditPayload {
// Requests // Requests
export interface DMEditRequest extends WebSocketMessage { export interface DMEditRequest extends WebSocketMessage {
type: "dmEdit", type: "dmEdit";
credentials: WebSocketCredentials; credentials: WebSocketCredentials;
data: DMEditPayload data: DMEditPayload
} }
export interface SendMessageRequest extends WebSocketMessage { export interface SendMessageRequest extends WebSocketMessage {
type: "sendMessage", type: "sendMessage";
credentials: WebSocketCredentials; credentials: WebSocketCredentials;
data: { data: {
content: string; 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 // Messages
export interface DMNewWebSocketMessage extends WebSocketMessage { export interface DMNewWebSocketMessage extends WebSocketMessage {
type: "dmNew", type: "dmNew";
data: DmEnvelope data: DmEnvelope
} }
export interface DMEditedWebSocketMessage extends WebSocketMessage { export interface DMEditedWebSocketMessage extends WebSocketMessage {
type: "dmEdited", type: "dmEdited";
data: DMEditPayload data: DMEditPayload
} }
export interface DMDeletedWebSocketMessage extends WebSocketMessage { export interface DMDeletedWebSocketMessage extends WebSocketMessage {
type: "dmDeleted", type: "dmDeleted";
data: { data: {
id: number; id: number;
} }
} }
export interface MessageEditedWebSocketMessage extends WebSocketMessage { export interface MessageEditedWebSocketMessage extends WebSocketMessage {
type: "messageEdited", type: "messageEdited";
data: Partial<Message> & { id: number } data: Partial<Message> & { id: number }
} }
export interface MessageDeletedWebSocketMessage extends WebSocketMessage { export interface MessageDeletedWebSocketMessage extends WebSocketMessage {
type: "messageDeleted", type: "messageDeleted";
data: { data: {
message_id: number; message_id: number;
} }
} }
export interface NewMessageWebSocketMessage extends WebSocketMessage { export interface NewMessageWebSocketMessage extends WebSocketMessage {
type: "newMessage", type: "newMessage";
data: Message 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 // Shared types
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage export type DMWebSocketMessage =
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage DMNewWebSocketMessage |
DMEditedWebSocketMessage |
DMDeletedWebSocketMessage |
DMReactionUpdateWebSocketMessage;
export type ChatWebSocketMessage =
MessageEditedWebSocketMessage |
MessageDeletedWebSocketMessage |
NewMessageWebSocketMessage |
ReactionUpdateWebSocketMessage;
// ----------- // -----------
// Encrypted message JSON (plaintext structure before encryption) // Encrypted message JSON (plaintext structure before encryption)
+8 -8
View File
@@ -7,7 +7,7 @@
import { API_WS_BASE_URL } from "./config"; import { API_WS_BASE_URL } from "./config";
import type { WebSocketMessage } from "./types"; import type { WebSocketMessage } from "./types";
import { delay } from "../utils/utils"; import { delay } from "@/utils/utils";
/** /**
* Creates a new WebSocket connection to the chat server * Creates a new WebSocket connection to the chat server
@@ -33,17 +33,17 @@ export let websocket: WebSocket = create();
* Global WebSocket message handler reference * Global WebSocket message handler reference
* This will be set by the active panel to handle incoming messages * 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 * Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages * @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; 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); console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
function requestInner() { function requestInner() {
@@ -58,7 +58,7 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
setTimeout(() => reject("Request timed out"), 10000); setTimeout(() => reject("Request timed out"), 10000);
} }
if (websocket.readyState == 0) { if (websocket.readyState === 0) {
websocket.addEventListener("open", requestInner); websocket.addEventListener("open", requestInner);
setTimeout(() => reject("Request timed out"), 10000); setTimeout(() => reject("Request timed out"), 10000);
} else { } else {
@@ -79,10 +79,10 @@ async function onError() {
await delay(3000); await delay(3000);
websocket = create(); websocket = create();
let listener: () => void | null; let listener: (() => void) | null = null;
listener = () => { listener = () => {
console.log("WebSocket successfully reconnected!"); console.log("WebSocket successfully reconnected!");
websocket.removeEventListener("open", listener); websocket.removeEventListener("open", listener!);
} }
websocket.addEventListener("open", listener); websocket.addEventListener("open", listener);
@@ -95,7 +95,7 @@ async function onError() {
websocket.addEventListener("message", (e) => { websocket.addEventListener("message", (e) => {
try { try {
const response: WebSocketMessage<any> = JSON.parse(e.data); const response: WebSocketMessage<object> = JSON.parse(e.data);
// Route message to global handler if set // Route message to global handler if set
if (globalMessageHandler) { if (globalMessageHandler) {
@@ -30,72 +30,6 @@ button, input {
font: inherit; 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 styles
.dialog-content { .dialog-content {
h3 { h3 {
@@ -1,18 +1,10 @@
@use "auth"; @use "animations";
@use "chat"; @use "components";
@use "profile"; @use "colors" as *;
@use "settings"; @use "material" as *;
@use "panelchat";
@use "common/animations";
@use "common/components";
@use "common/colors" as *;
@use "common/material" as *;
@use "electron";
@use "dialogs/reply";
@use "download-app";
@use "lib/fonts/montserrat"; @use "fonts/montserrat";
@use "lib/fonts/material-symbols"; @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 * @version 1.0.0
*/ */
import './resources/css/style.scss'; import "./css/style.scss";
import "mdui/mdui.css";
import "./utils/material"; import "./utils/material";
import "./core/init"; import "./core/init";
import "./electron/electron"; import "./core/electron/electron";
import { createRoot } from 'react-dom/client'; import { createRoot } from "react-dom/client";
import App from './ui/App'; import App from "./App";
import { StrictMode } from 'react'; import { StrictMode } from "react";
// Initialize React app
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <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) { export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
const iconType = typeof icon == "string" ? "filled" : icon.type; const iconType = typeof icon === "string" ? "filled" : icon.type;
const iconName = typeof icon == "string" ? icon : icon.name; const iconName = typeof icon === "string" ? icon : icon.name;
return ( return (
<div className="auth-header"> <div className="auth-header">
@@ -36,4 +36,21 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
<p>{subtitle}</p> <p>{subtitle}</p>
</div> </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 { useImmer } from "use-immer";
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts"; import { AlertsContainer, type Alert, type AlertType } from "./Auth";
import { AuthContainer, AuthHeader } from "../components/Auth"; import { AuthContainer, AuthHeader } from "./Auth";
import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types"; import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types";
import { ensureKeysOnLogin } from "../../auth/crypto"; import { ensureKeysOnLogin } from "@/core/api/authApi";
import { API_BASE_URL } from "../../core/config"; import { API_BASE_URL } from "@/core/config";
import { useRef } from "react"; import { useRef } from "react";
import type { TextField } from "mdui/components/text-field"; import type { TextField } from "mdui/components/text-field";
import { useAppState } from "../state"; import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "../components/core/TextField"; import { MaterialTextField } from "@/core/components/TextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/push-notifications"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "../../electron/electron"; 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 [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setCurrentPage = useAppState(state => state.setCurrentPage);
const setUser = useAppState(state => state.setUser); const setUser = useAppState(state => state.setUser);
const navigate = useNavigate();
function showAlert(type: AlertType, message: string) { const { navigate: navigateDownloadApp } = useDownloadAppScreen();
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const usernameElement = useRef<TextField>(null); const usernameElement = useRef<TextField>(null);
const passwordElement = 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 ( return (
<AuthContainer> <AuthContainer>
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" /> <AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
@@ -45,12 +52,12 @@ export default function LoginScreen() {
const request: LoginRequest = { const request: LoginRequest = {
username: username, username: username,
password: password password: password
} };
const response = await fetch(`${API_BASE_URL}/login`, { const response = await fetch(`${API_BASE_URL}/login`, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json"
}, },
body: JSON.stringify(request) body: JSON.stringify(request)
}); });
@@ -67,7 +74,7 @@ export default function LoginScreen() {
console.error("Key setup failed:", e); console.error("Key setup failed:", e);
} }
setCurrentPage("chat"); navigate("/chat");
// Initialize notifications // Initialize notifications
try { try {
@@ -95,7 +102,7 @@ export default function LoginScreen() {
const data: ErrorResponse = await response.json(); const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Неверное имя пользователя или пароль"); showAlert("danger", data.message || "Неверное имя пользователя или пароль");
} }
} catch (error) { } catch {
showAlert("danger", "Ошибка соединения с сервером"); showAlert("danger", "Ошибка соединения с сервером");
} }
}}> }}>
@@ -130,7 +137,7 @@ export default function LoginScreen() {
<a <a
href="#" href="#"
className="link" className="link"
onClick={() => setCurrentPage("register")}> onClick={() => navigate("/register")}>
Зарегистрируйтесь Зарегистрируйтесь
</a> </a>
</p> </p>
@@ -138,4 +145,4 @@ export default function LoginScreen() {
</div> </div>
</AuthContainer> </AuthContainer>
) )
} }
@@ -1,27 +1,34 @@
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
// import { showLogin } from "../../navigation"; import { AuthContainer, AuthHeader } from "./Auth";
import { AuthContainer, AuthHeader } from "../components/Auth"; import { AlertsContainer, type Alert, type AlertType } from "./Auth";
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
import { useRef } from "react"; import { useRef } from "react";
import { TextField } from "mdui/components/text-field"; import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest } from "../../core/types"; import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
import { API_BASE_URL } from "../../core/config"; import { API_BASE_URL } from "@/core/config";
import { delay } from "../../utils/utils"; import { useAppState } from "@/pages/chat/state";
import { useAppState } from "../state"; import { MaterialTextField } from "@/core/components/TextField";
import { MaterialTextField } from "../components/core/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 [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setCurrentPage = useAppState(state => state.setCurrentPage); const setUser = useAppState(state => state.setUser);
const navigate = useNavigate();
function showAlert(type: AlertType, message: string) { const { navigate: navigateDownloadApp } = useDownloadAppScreen();
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const usernameElement = useRef<TextField>(null); const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null); const passwordElement = useRef<TextField>(null);
const confirmPasswordElement = 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 ( return (
<AuthContainer> <AuthContainer>
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" /> <AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
@@ -63,23 +70,31 @@ export default function RegisterScreen() {
} }
const response = await fetch(`${API_BASE_URL}/register`, { const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json"
}, },
body: JSON.stringify(request) body: JSON.stringify(request)
}); });
if (response.ok) { if (response.ok) {
// Registration successful const data: LoginResponse = await response.json();
showAlert("success", "Регистрация прошла успешно! Теперь вы можете войти."); // Store the JWT token first
await delay(2000); setUser(data.token, data.user);
setCurrentPage("login");
// Setup keys with the token we just received
try {
await ensureKeysOnLogin(password, data.token);
} catch {
console.error("Key setup failed:", e);
}
navigate("/chat");
} else { } else {
const data: ErrorResponse = await response.json(); const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Ошибка при регистрации"); showAlert("danger", data.message || "Ошибка при регистрации");
} }
} catch (error) { } catch {
showAlert("danger", "Ошибка соединения с сервером"); showAlert("danger", "Ошибка соединения с сервером");
} }
}}> }}>
@@ -127,7 +142,7 @@ export default function RegisterScreen() {
href="#" href="#"
id="login-link" id="login-link"
className="link" className="link"
onClick={() => setCurrentPage("login")}> onClick={() => navigate("/login")}>
Войдите Войдите
</a> </a>
</p> </p>
@@ -135,4 +150,4 @@ export default function RegisterScreen() {
</div> </div>
</AuthContainer> </AuthContainer>
) )
} }
@@ -1,5 +1,5 @@
@use "common/colors" as *; @use "../../css/colors" as *;
@use "common/material" as *; @use "../../css/material" as *;
.auth-container { .auth-container {
display: flex; 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 "../../../css/colors" as *;
@use "common/material" as *; @use "../../../css/material" as *;
@use "sass:color";
.header {
// контейнер чата и панели с чатами
.all-container {
display: flex; display: flex;
flex-direction: row; background-color: $color-dark-surface-container;
width: 100%; color: white;
height: 100%; 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 { #profile {
@@ -147,4 +190,45 @@
padding-right: 16px; padding-right: 16px;
margin-top: auto; 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 "../../../css/colors" as *;
@use "common/material" as *; @use "../../../css/material" as *;
@use "sass:color";
// Profile styles
#profile-dialog .content { #profile-dialog .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -246,4 +248,4 @@
padding-top: 16px; padding-top: 16px;
border-top: 1px solid $color-dark-outline; 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 "../../../css/colors" as *;
@use "common/material" as *; @use "../../../css/material" as *;
@use "sass:color";
// Settings styles
#settings-dialog { #settings-dialog {
.fullscreen-wrapper { .fullscreen-wrapper {
display: flex; 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 { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state"; import { useAppState } from "@/pages/chat/state";
import { import {
fetchUsers, fetchUsers,
fetchUserPublicKey, fetchUserPublicKey,
fetchDMHistory, fetchDMHistory,
decryptDm, decryptDm,
sendDMViaWebSocket sendDMViaWebSocket
} from "../../api/dmApi"; } from "../../../core/api/dmApi";
import type { User, Message, DmEncryptedJSON } from "../../core/types"; import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "../../core/websocket"; import { websocket } from "@/core/websocket";
interface DMUser extends User { export interface DMUser extends User {
lastMessage?: string; lastMessage?: string;
unreadCount: number; unreadCount: number;
publicKey?: string | null; publicKey?: string | null;
@@ -101,7 +101,7 @@ export function useDM() {
} finally { } finally {
setIsLoadingUsers(false); setIsLoadingUsers(false);
} }
}, [user.authToken, isLoadingUsers]); }, [user.authToken, isLoadingUsers, loadUserLastMessage, setDmUsers]);
// Reset users loaded flag when user changes // Reset users loaded flag when user changes
useEffect(() => { useEffect(() => {
@@ -198,7 +198,7 @@ export function useDM() {
// WebSocket message handler // WebSocket message handler
useEffect(() => { useEffect(() => {
const handleWebSocketMessage = async (e: MessageEvent) => { async function handleWebSocketMessage(e: MessageEvent) {
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === "dmNew") { if (msg.type === "dmNew") {
@@ -258,11 +258,12 @@ export function useDM() {
} catch (error) { } catch (error) {
console.error("Failed to handle WebSocket message:", error); console.error("Failed to handle WebSocket message:", error);
} }
}; }
websocket.addEventListener("message", handleWebSocketMessage); websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("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) // Force reload users (useful for refreshing the list)
const reloadUsers = useCallback(() => { const reloadUsers = useCallback(() => {
@@ -1,9 +1,9 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { useAppState } from "../state"; import { useAppState } from "@/pages/chat/state";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../api/profileApi"; import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/profileApi";
import { showSuccess, showError } from "../../utils/notification"; import { showSuccess, showError } from "@/utils/notification";
export function useProfile() { export default function useProfile() {
const { user } = useAppState(); const { user } = useAppState();
const [profileData, setProfileData] = useState<ProfileData | null>(null); const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -20,8 +20,8 @@ export function useProfile() {
setProfileData(data); setProfileData(data);
} }
} catch (error) { } catch (error) {
console.error('Error loading profile:', error); console.error("Error loading profile:", error);
showError('Ошибка при загрузке профиля'); showError("Ошибка при загрузке профиля");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -37,15 +37,15 @@ export function useProfile() {
if (success) { if (success) {
// Reload profile data to get updated information // Reload profile data to get updated information
await loadProfileData(); await loadProfileData();
showSuccess('Профиль обновлен!'); showSuccess("Профиль обновлен!");
return true; return true;
} else { } else {
showError('Ошибка при обновлении профиля'); showError("Ошибка при обновлении профиля");
return false; return false;
} }
} catch (error) { } catch (error) {
console.error('Error updating profile:', error); console.error("Error updating profile:", error);
showError('Ошибка при обновлении профиля'); showError("Ошибка при обновлении профиля");
return false; return false;
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
@@ -65,15 +65,15 @@ export function useProfile() {
...prev, ...prev,
profile_picture: result.profile_picture_url profile_picture: result.profile_picture_url
} : null); } : null);
showSuccess('Фото профиля обновлено!'); showSuccess("Фото профиля обновлено!");
return true; return true;
} else { } else {
showError('Ошибка при загрузке фото'); showError("Ошибка при загрузке фото");
return false; return false;
} }
} catch (error) { } catch (error) {
console.error('Error uploading profile picture:', error); console.error("Error uploading profile picture:", error);
showError('Ошибка при загрузке фото'); showError("Ошибка при загрузке фото");
return false; return false;
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
@@ -1,16 +1,15 @@
import { create } from "zustand"; import { create } from "zustand";
import type { Message, User } from "../core/types"; import type { Message, User } from "@/core/types";
import { request } from "../core/websocket"; import { request } from "@/core/websocket";
import { MessagePanel } from "./panels/MessagePanel"; import { MessagePanel } from "./ui/right/panels/MessagePanel";
import { PublicChatPanel } from "./panels/PublicChatPanel"; import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel";
import { DMPanel, type DMPanelData } from "./panels/DMPanel"; import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel";
import { getAuthHeaders } from "../auth/api"; import { getAuthHeaders } from "@/core/api/authApi";
import { restoreKeys } from "../auth/crypto"; import { restoreKeys } from "@/core/api/authApi";
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "@/core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "../utils/push-notifications"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "../electron/electron"; import { isElectron } from "@/core/electron/electron";
type Page = "login" | "register" | "chat"
export type ChatTabs = "chats" | "channels" | "contacts" | "dms" export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
interface ActiveDM { interface ActiveDM {
@@ -25,10 +24,12 @@ interface ChatState {
activeTab: ChatTabs; activeTab: ChatTabs;
dmUsers: User[]; dmUsers: User[];
activeDm: ActiveDM | null; activeDm: ActiveDM | null;
isChatSwitching: boolean; isSwitching: boolean;
setIsSwitching: (value: boolean) => void;
activePanel: MessagePanel | null; activePanel: MessagePanel | null;
publicChatPanel: PublicChatPanel | null; publicChatPanel: PublicChatPanel | null;
dmPanel: DMPanel | null; dmPanel: DMPanel | null;
pendingPanel?: MessagePanel | null;
} }
export interface UserState { export interface UserState {
@@ -37,9 +38,6 @@ export interface UserState {
} }
interface AppState { interface AppState {
currentPage: Page;
setCurrentPage: (page: Page) => void;
// Chat state // Chat state
chat: ChatState; chat: ChatState;
addMessage: (message: Message) => void; addMessage: (message: Message) => void;
@@ -50,23 +48,20 @@ interface AppState {
setDmUsers: (users: User[]) => void; setDmUsers: (users: User[]) => void;
setActiveDm: (dm: ChatState["activeDm"]) => void; setActiveDm: (dm: ChatState["activeDm"]) => void;
clearMessages: () => void; clearMessages: () => void;
setIsChatSwitching: (value: boolean) => void;
setActivePanel: (panel: MessagePanel | null) => void; setActivePanel: (panel: MessagePanel | null) => void;
setPendingPanel: (panel: MessagePanel | null) => void;
applyPendingPanel: () => void;
switchToPublicChat: (chatName: string) => Promise<void>; switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>; switchToDM: (dmData: DMPanelData) => Promise<void>;
switchToTab: (tab: ChatTabs) => Promise<void>;
// User state // User state
user: UserState; user: UserState;
setUser: (token: string, user: User) => void; setUser: (token: string, user: User) => void;
logout: () => void; logout: () => void;
restoreUserFromStorage: () => void; restoreUserFromStorage: () => Promise<void>;
} }
export const useAppState = create<AppState>((set, get) => ({ export const useAppState = create<AppState>((set, get) => ({
currentPage: "login", // default page
setCurrentPage: (page: Page) => set({ currentPage: page }),
// Chat state // Chat state
chat: { chat: {
messages: [], messages: [],
@@ -74,17 +69,18 @@ export const useAppState = create<AppState>((set, get) => ({
activeTab: "chats", activeTab: "chats",
dmUsers: [], dmUsers: [],
activeDm: null, activeDm: null,
isChatSwitching: false, isSwitching: false,
setIsSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isSwitching: value
}
})),
activePanel: null, activePanel: null,
publicChatPanel: null, publicChatPanel: null,
dmPanel: null dmPanel: null,
pendingPanel: null
}, },
setIsChatSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isChatSwitching: value
}
})),
addMessage: (message: Message) => set((state) => { addMessage: (message: Message) => set((state) => {
// Check if message already exists to prevent duplicates // Check if message already exists to prevent duplicates
const messageExists = state.chat.messages.some(msg => msg.id === message.id); 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 // Store credentials in localStorage
try { try {
localStorage.setItem('authToken', token); localStorage.setItem("authToken", token);
localStorage.setItem('currentUser', JSON.stringify(user)); localStorage.setItem("currentUser", JSON.stringify(user));
} catch (error) { } catch (error) {
console.error('Failed to store credentials in localStorage:', error); console.error("Failed to store credentials in localStorage:", error);
} }
try { try {
@@ -181,23 +177,22 @@ export const useAppState = create<AppState>((set, get) => ({
logout: () => { logout: () => {
// Clear localStorage // Clear localStorage
try { try {
localStorage.removeItem('authToken'); localStorage.removeItem("authToken");
localStorage.removeItem('currentUser'); localStorage.removeItem("currentUser");
} catch (error) { } catch (error) {
console.error('Failed to clear localStorage:', error); console.error("Failed to clear localStorage:", error);
} }
set(() => ({ set(() => ({
user: { user: {
currentUser: null, currentUser: null,
authToken: null authToken: null
}, }
currentPage: "login"
})); }));
}, },
restoreUserFromStorage: async () => { restoreUserFromStorage: async () => {
try { try {
const token = localStorage.getItem('authToken'); const token = localStorage.getItem("authToken");
if (token) { if (token) {
const response = await fetch(`${API_BASE_URL}/user/profile`, { const response = await fetch(`${API_BASE_URL}/user/profile`, {
@@ -212,8 +207,7 @@ export const useAppState = create<AppState>((set, get) => ({
user: { user: {
currentUser: user, currentUser: user,
authToken: token authToken: token
}, }
currentPage: "chat"
})); }));
try { try {
@@ -250,10 +244,10 @@ export const useAppState = create<AppState>((set, get) => ({
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to restore user from localStorage:', error); console.error("Failed to restore user from localStorage:", error);
// Clear invalid data // Clear invalid data
localStorage.removeItem('authToken'); localStorage.removeItem("authToken");
localStorage.removeItem('currentUser'); localStorage.removeItem("currentUser");
} }
}, },
@@ -264,15 +258,38 @@ export const useAppState = create<AppState>((set, get) => ({
activePanel: panel 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) => { switchToPublicChat: async (chatName: string) => {
const state = get(); const { user, chat } = get();
const { user, chat } = state;
if (!user.authToken) return; if (!user.authToken) return;
// Start chat switching animation // Start chat switching animation
state.setIsChatSwitching(true); chat.setIsSwitching(true);
// Create or get public chat panel // Create or get public chat panel
let publicChatPanel = chat.publicChatPanel; let publicChatPanel = chat.publicChatPanel;
@@ -281,37 +298,33 @@ export const useAppState = create<AppState>((set, get) => ({
} else { } else {
publicChatPanel.setChatName(chatName); publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken); publicChatPanel.setAuthToken(user.authToken);
// Reset messages for the new chat
publicChatPanel.clearMessages();
} }
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
// Activate panel // Activate panel
await publicChatPanel.activate(); await publicChatPanel.activate();
// Update state // Defer panel swap until animation switch-out completes
set((state) => ({ set((state) => ({
chat: { chat: {
...state.chat, ...state.chat,
activePanel: publicChatPanel, pendingPanel: publicChatPanel,
publicChatPanel: publicChatPanel,
currentChat: chatName,
activeTab: "chats" activeTab: "chats"
} }
})); }));
// End animation // Let MessagePanelRenderer handle the animation timing completely
state.setIsChatSwitching(false); // It will set isChatSwitching to false when the fadeInDown animation completes
}, },
switchToDM: async (dmData: DMPanelData) => { switchToDM: async (dmData: DMPanelData) => {
const state = get(); const { user, chat } = get();
const { user, chat } = state;
if (!user.authToken) return; if (!user.authToken) return;
// Start chat switching animation // Start chat switching animation
state.setIsChatSwitching(true); chat.setIsSwitching(true);
// Create or get DM panel // Create or get DM panel
let dmPanel = chat.dmPanel; let dmPanel = chat.dmPanel;
@@ -319,23 +332,21 @@ export const useAppState = create<AppState>((set, get) => ({
dmPanel = new DMPanel(user); dmPanel = new DMPanel(user);
} else { } else {
dmPanel.setAuthToken(user.authToken); dmPanel.setAuthToken(user.authToken);
// Reset messages for the new DM
dmPanel.clearMessages();
} }
// Set DM data // Set DM data
dmPanel.setDMData(dmData); dmPanel.setDMData(dmData);
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
// Activate panel // Activate panel
await dmPanel.activate(); await dmPanel.activate();
// Update state // Defer panel swap until animation switch-out completes
set((state) => ({ set((state) => ({
chat: { chat: {
...state.chat, ...state.chat,
activePanel: dmPanel, pendingPanel: dmPanel,
dmPanel: dmPanel,
activeDm: { activeDm: {
userId: dmData.userId, userId: dmData.userId,
username: dmData.username, username: dmData.username,
@@ -345,19 +356,7 @@ export const useAppState = create<AppState>((set, get) => ({
} }
})); }));
// End animation // Let MessagePanelRenderer handle the animation timing completely
state.setIsChatSwitching(false); // It will set isChatSwitching to false when the fadeInDown animation completes
},
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);
}
} }
})); }));
+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 { PRODUCT_NAME } from "@/core/config";
import { useProfile } from "../../hooks/useProfile"; import useProfile from "@/pages/chat/hooks/useProfile";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react"; import { useState } from "react";
import { ProfileDialog } from "../profile/ProfileDialog"; import { ProfileDialog } from "./profile/ProfileDialog";
export function ChatHeader() { export function ChatHeader() {
const { profileData } = useProfile(); const { profileData } = useProfile();
@@ -1,8 +1,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useDM } from "../../hooks/useDM"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { useAppState } from "../../state"; import { useAppState } from "@/pages/chat/state";
import { fetchUserPublicKey } from "../../../api/dmApi"; import { fetchUserPublicKey } from "@/core/api/dmApi";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
export function DMUsersList() { export function DMUsersList() {
const { dmUsers, isLoadingUsers, loadUsers } = useDM(); 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) { if (!user.publicKey) {
// Get public key if not already loaded // Get public key if not already loaded
const authToken = useAppState.getState().user.authToken; const authToken = useAppState.getState().user.authToken;
@@ -60,7 +60,7 @@ export function DMUsersList() {
return ( return (
<mdui-list> <mdui-list>
{dmUsers.map((user) => ( {dmUsers.map((user: DMUser) => (
<mdui-list-item <mdui-list-item
key={user.id} key={user.id}
headline={user.username} headline={user.username}
@@ -1,12 +1,12 @@
import { PRODUCT_NAME } from "../../../core/config"; import { PRODUCT_NAME } from "@/core/config";
import { useAppState } from "../../state"; import { useAppState } from "@/pages/chat/state";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { ProfileDialog } from "../profile/ProfileDialog"; import { ProfileDialog } from "./profile/ProfileDialog";
import { SettingsDialog } from "../settings/SettingsDialog"; import { SettingsDialog } from "./settings/SettingsDialog";
import { DMUsersList } from "./DMUsersList"; import { DMUsersList } from "./DMUsersList";
import type { Tabs } from "mdui"; import type { Tabs } from "mdui";
import type { ChatTabs } from "../../state"; import type { ChatTabs as ChatTabsType } from "@/pages/chat/state";
function BottomAppBar() { function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false); const [settingsOpen, onSettingsOpenChange] = useState(false);
@@ -19,16 +19,15 @@ function BottomAppBar() {
return ( return (
<> <>
<mdui-bottom-app-bar> <mdui-bottom-app-bar>
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon> <mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
<mdui-button-icon icon="group_add--filled"></mdui-button-icon> <mdui-button-icon icon="group_add--filled" />
<div style={{ flexGrow: 1 }}></div> <div style={{ flexGrow: 1 }} />
<mdui-button-icon <mdui-button-icon
icon="logout--filled" icon="logout--filled"
id="logout-btn" id="logout-btn"
onClick={handleLogout} onClick={handleLogout}
title="Выйти" title="Выйти" />
></mdui-button-icon> <mdui-fab icon="edit--filled" />
<mdui-fab icon="edit--filled"></mdui-fab>
</mdui-bottom-app-bar> </mdui-bottom-app-bar>
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} /> <SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
</> </>
@@ -37,17 +36,16 @@ function BottomAppBar() {
function ChatTabs() { function ChatTabs() {
const { chat, switchToTab, switchToPublicChat } = useAppState(); const { chat, setActiveTab, switchToPublicChat } = useAppState();
const { activeTab } = chat; const { activeTab } = chat;
const handleChatClick = async (chatName: string) => { async function handleChatClick(chatName: string) {
await switchToPublicChat(chatName); await switchToPublicChat(chatName);
}; }
const handleTabChange = async (e: FormEvent<Tabs>) => { function handleTabChange(e: FormEvent<Tabs>) {
const tab = (e.target as Tabs).value as ChatTabs; setActiveTab((e.target as Tabs).value as ChatTabsType);
await switchToTab(tab); }
};
return ( return (
<div className="chat-tabs"> <div className="chat-tabs">
@@ -4,10 +4,10 @@ export function CropperDialog() {
<div className="cropper-dialog-content"> <div className="cropper-dialog-content">
<div className="cropper-header"> <div className="cropper-header">
<h3>Обрезать фото профиля</h3> <h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon> <mdui-button-icon icon="close" id="cropper-close" />
</div> </div>
<div className="cropper-container"> <div className="cropper-container">
<div id="cropper-area"></div> <div id="cropper-area" />
</div> </div>
<div className="cropper-actions"> <div className="cropper-actions">
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button> <mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "../../../core/types"; import type { Size2D, Rect } from "@/core/types";
interface ImageCropperProps { interface ImageCropperProps {
onCrop: (croppedImageData: string) => void; onCrop: (croppedImageData: string) => void;
@@ -17,13 +17,13 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
useEffect(() => { useEffect(() => {
const img = imageRef.current;
if (imageFile) { if (imageFile) {
const reader = new FileReader(); const reader = new FileReader();
function handleImageLoad() { function handleImageLoad() {
setIsLoaded(true); setIsLoaded(true);
// Initialize crop area to center of image
const img = imageRef.current;
if (img) { if (img) {
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8; const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
setCropArea({ setCropArea({
@@ -36,9 +36,9 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
} }
function handleReaderLoad() { function handleReaderLoad() {
if (imageRef.current) { if (img) {
setSrc(reader.result as string); 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 () => { return () => {
reader.abort(); reader.abort();
reader.removeEventListener("load", handleReaderLoad); reader.removeEventListener("load", handleReaderLoad);
imageRef.current?.removeEventListener("load", handleImageLoad); img?.removeEventListener("load", handleImageLoad);
} }
} }
}, [imageFile]); }, [imageFile, imageRef]);
function handleMouseDown(e: React.MouseEvent) { function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return; if (!isLoaded) return;
@@ -100,10 +100,11 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
}; };
function handleCrop() { function handleCrop() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return; const img = imageRef.current;
if (!canvasRef.current || !img || !isLoaded) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
// Set canvas size to crop area // Set canvas size to crop area
@@ -112,47 +113,48 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
// Draw cropped portion // Draw cropped portion
ctx.drawImage( ctx.drawImage(
imageRef.current, img,
cropArea.x, cropArea.y, cropArea.width, cropArea.height, cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, cropArea.width, cropArea.height 0, 0, cropArea.width, cropArea.height
); );
// Convert to data URL // Convert to data URL
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9); const croppedImageData = canvas.toDataURL("image/jpeg", 0.9);
onCrop(croppedImageData); onCrop(croppedImageData);
}; };
function drawCropArea() { const drawCropArea = useCallback(() => {
if (!canvasRef.current || !imageRef.current || !isLoaded) return; const img = imageRef.current;
if (!canvasRef.current || !img || !isLoaded) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
// Clear canvas // Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw image // Draw image
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Draw crop overlay // 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); ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area // Clear crop area
ctx.globalCompositeOperation = 'destination-out'; ctx.globalCompositeOperation = "destination-out";
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height); ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
// Draw crop border // Draw crop border
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = '#fff'; ctx.strokeStyle = "#fff";
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height); ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
}; }, [cropArea, isLoaded, imageRef]);
useEffect(() => { useEffect(() => {
drawCropArea(); drawCropArea();
}, [cropArea, isLoaded]); }, [cropArea, isLoaded, drawCropArea]);
if (!imageFile) return null; if (!imageFile) return null;
@@ -163,10 +165,10 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
width={400} width={400}
height={400} height={400}
style={{ style={{
cursor: isDragging ? 'grabbing' : 'grab', cursor: isDragging ? "grabbing" : "grab",
border: '1px solid #ccc', border: "1px solid #ccc",
maxWidth: '100%', maxWidth: "100%",
height: 'auto' height: "auto"
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
@@ -176,7 +178,7 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
<img <img
ref={imageRef} ref={imageRef}
src={src} src={src}
style={{ display: 'none' }} style={{ display: "none" }}
alt="Crop source" alt="Crop source"
/> />
<div className="cropper-actions"> <div className="cropper-actions">
@@ -1,11 +1,11 @@
import { useState, useEffect, useRef, type FormEvent } from "react"; 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 { TextField } from "mdui/components/text-field";
import type { DialogProps } from "../../../core/types"; import type { DialogProps } from "@/core/types";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { useProfile } from "../../hooks/useProfile"; import useProfile from "@/pages/chat/hooks/useProfile";
import { ImageCropper } from "./ImageCropper"; import { ImageCropper } from "./ImageCropper";
import { MaterialTextField } from "../core/TextField"; import { MaterialTextField } from "@/core/components/TextField";
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) { export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile(); const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
@@ -20,6 +20,7 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
// Update form fields when profile data changes // Update form fields when profile data changes
useEffect(() => { useEffect(() => {
if (profileData) { if (profileData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setUsername(profileData.nickname || ""); setUsername(profileData.nickname || "");
setDescription(profileData.description || ""); setDescription(profileData.description || "");
} }
@@ -40,7 +41,7 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith("image/")) {
setSelectedImage(file); setSelectedImage(file);
setShowCropper(true); setShowCropper(true);
} }
@@ -57,25 +58,25 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
setShowCropper(false); setShowCropper(false);
setSelectedImage(null); setSelectedImage(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = "";
} }
} }
} catch (error) { } catch (error) {
console.error('Error processing cropped image:', error); console.error("Error processing cropped image:", error);
} }
}; };
const handleCropCancel = () => { function handleCropCancel() {
setShowCropper(false); setShowCropper(false);
setSelectedImage(null); setSelectedImage(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = "";
} }
}; }
const handleUploadClick = () => { function handleUploadClick() {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; }
const profilePictureUrl = profileData?.profile_picture || defaultAvatar; const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
@@ -1,31 +1,24 @@
import { useState, useEffect } from "react"; import { useState, useMemo } from "react";
import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config"; import { PRODUCT_NAME, API_BASE_URL } from "@/core/config";
import type { DialogProps } from "../../../core/types"; import type { DialogProps } from "@/core/types";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../utils/push-notifications"; import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "../../../electron/electron"; import { isElectron } from "@/core/electron/electron";
import { useAppState } from "../../state"; import { useAppState } from "@/pages/chat/state";
import type { Switch } from "mdui/components/switch"; import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "../../../auth/api"; import { getAuthHeaders } from "@/core/api/authApi";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings"); const [activePanel, setActivePanel] = useState("notifications-settings");
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false); const pushSupported = useMemo(() => isSupported(), []);
const [pushSupported, setPushSupported] = useState(false); const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(pushSupported);
const user = useAppState(state => state.user); const user = useAppState(state => state.user);
useEffect(() => { function handlePanelChange(panelId: string) {
setPushSupported(isSupported());
// For Electron, we assume notifications are enabled if supported
// For web browsers, we check if there's a subscription
setPushNotificationsEnabled(isSupported());
}, []);
const handlePanelChange = (panelId: string) => {
setActivePanel(panelId); setActivePanel(panelId);
}; }
const handlePushNotificationToggle = async (enabled: boolean) => { async function handlePushNotificationToggle(enabled: boolean) {
if (!user.authToken) return; if (!user.authToken) return;
try { try {
@@ -66,7 +59,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<div className="fullscreen-wrapper"> <div className="fullscreen-wrapper">
<div id="settings-dialog-inner"> <div id="settings-dialog-inner">
<div className="header"> <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> <mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
</div> </div>
<div id="settings-menu"> <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" : ""}`}> <div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
<h3>Хранилище</h3> <h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p> <p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value={25}></mdui-linear-progress> <mdui-linear-progress value={25} />
<mdui-button variant="outlined">Очистить кэш</mdui-button> <mdui-button variant="outlined">Очистить кэш</mdui-button>
</div> </div>
@@ -1,10 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { RichTextArea } from "../core/RichTextArea"; import { RichTextArea } from "@/core/components/RichTextArea";
import type { Message } from "../../../core/types"; import type { Message } from "@/core/types";
import Quote from "../core/Quote"; import Quote from "@/core/components/Quote";
import AnimatedHeight from "../core/animations/AnimatedHeight"; import AnimatedHeight from "@/core/components/animations/AnimatedHeight";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu";
interface ChatInputWrapperProps { interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void; onSendMessage: (message: string, files: File[]) => void;
@@ -18,6 +19,7 @@ interface ChatInputWrapperProps {
onClearEdit?: () => void; onClearEdit?: () => void;
onCloseEdit?: () => void; onCloseEdit?: () => void;
onProvideFileAdder?: (adder: (files: File[]) => void) => void; onProvideFileAdder?: (adder: (files: File[]) => void) => void;
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
} }
export function ChatInputWrapper( export function ChatInputWrapper(
@@ -32,24 +34,30 @@ export function ChatInputWrapper(
editVisible = false, editVisible = false,
onClearEdit, onClearEdit,
onCloseEdit, onCloseEdit,
onProvideFileAdder onProvideFileAdder,
messagePanelRef
}: ChatInputWrapperProps }: ChatInputWrapperProps
) { ) {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]); const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false); const [attachmentsVisible, setAttachmentsVisible] = useState(false);
const [errorOpen, setErrorOpen] = 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 // Expose a way for parent to programmatically add files
useEffect(() => { useEffect(() => {
if (onProvideFileAdder) { if (onProvideFileAdder) {
const addFiles = (files: File[]) => { const addFiles = (files: File[]) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
setSelectedFiles(draft => { draft.push(...files) }); setSelectedFiles(draft => {
draft.push(...files)
});
}; };
onProvideFileAdder(addFiles); onProvideFileAdder(addFiles);
} }
}, [onProvideFileAdder]); }, [onProvideFileAdder, setSelectedFiles]);
// When entering edit mode, preload the message content // When entering edit mode, preload the message content
useEffect(() => { useEffect(() => {
@@ -60,7 +68,32 @@ export function ChatInputWrapper(
setAttachmentsVisible(selectedFiles.length > 0); setAttachmentsVisible(selectedFiles.length > 0);
}, [selectedFiles]); }, [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(); e.preventDefault();
const hasText = Boolean(message.trim()); const hasText = Boolean(message.trim());
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
@@ -82,20 +115,22 @@ export function ChatInputWrapper(
if (onClearReply) onClearReply(); if (onClearReply) onClearReply();
} }
} }
}; }
function handleAttachClick() { function handleAttachClick() {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.multiple = true; input.multiple = true;
input.addEventListener("change", () => { input.addEventListener("change", () => {
setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) }); setSelectedFiles(draft => {
draft.push(...Array.from(input.files || []))
});
}); });
input.click(); input.click();
} }
return ( return (
<div className="chat-input-wrapper"> <div className="chat-input-wrapper" ref={chatInputWrapperRef}>
<form className="input-group" id="message-form" onSubmit={handleSubmit}> <form className="input-group" id="message-form" onSubmit={handleSubmit}>
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}> <AnimatedHeight visible={editVisible} onFinish={onCloseEdit}>
{editingMessage && ( {editingMessage && (
@@ -105,7 +140,7 @@ export function ChatInputWrapper(
<span className="reply-username">{editingMessage!.username}</span> <span className="reply-username">{editingMessage!.username}</span>
<span className="reply-text">{editingMessage!.content}</span> <span className="reply-text">{editingMessage!.content}</span>
</Quote> </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> </div>
)} )}
</AnimatedHeight> </AnimatedHeight>
@@ -117,7 +152,7 @@ export function ChatInputWrapper(
<span className="reply-username">{replyTo!.username}</span> <span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span> <span className="reply-text">{replyTo!.content}</span>
</Quote> </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> </div>
)} )}
</AnimatedHeight> </AnimatedHeight>
@@ -131,25 +166,35 @@ export function ChatInputWrapper(
key={i} key={i}
variant="input" variant="input"
end-icon="close" end-icon="close"
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`} title={`${file.name} (${Math.round(file.size / 1024 / 1024)} MB)`}
onClick={() => { onClick={() => {
if (selectedFiles.length == 1) { if (selectedFiles.length === 1) {
setAttachmentsVisible(false); setAttachmentsVisible(false);
} else { } 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> <span className="name">{file.name}</span>
</mdui-chip> </mdui-chip>
))} ))}
</div> </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> </div>
)} )}
</AnimatedHeight> </AnimatedHeight>
<div className="chat-input"> <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 <RichTextArea
className="message-input" className="message-input"
id="message-input" id="message-input"
@@ -160,7 +205,7 @@ export function ChatInputWrapper(
onTextChange={(value) => setMessage(value)} onTextChange={(value) => setMessage(value)}
onEnter={handleSubmit} /> onEnter={handleSubmit} />
<div className="buttons"> <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"> <button type="submit" className="send-btn">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span> <span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button> </button>
@@ -172,6 +217,14 @@ export function ChatInputWrapper(
<div>Общий размер вложений превышает 4 ГБ.</div> <div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button> <mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
</MaterialDialog> </MaterialDialog>
<EmojiMenu
isOpen={emojiMenuOpen}
onClose={() => setEmojiMenuOpen(false)}
onEmojiSelect={handleEmojiSelect}
position={emojiMenuPosition}
mode="standalone"
/>
</div> </div>
); );
} }
@@ -1,14 +1,15 @@
import { useChat } from "../../hooks/useChat";
import { Message } from "./Message"; import { Message } from "./Message";
import { useAppState } from "../../state"; import { useAppState } from "@/pages/chat/state";
import type { Message as MessageType } from "../../../core/types"; import type { Message as MessageType } from "@/core/types";
import type { UserProfile } from "../../../core/types"; import type { UserProfile } from "@/core/types";
import { UserProfileDialog } from "./UserProfileDialog"; import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; 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 { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils"; import { delay } from "@/utils/utils";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
interface ChatMessagesProps { interface ChatMessagesProps {
messages?: MessageType[]; messages?: MessageType[];
@@ -17,15 +18,23 @@ interface ChatMessagesProps {
onReplySelect?: (message: MessageType) => void; onReplySelect?: (message: MessageType) => void;
onEditSelect?: (message: MessageType) => void; onEditSelect?: (message: MessageType) => void;
onDelete?: (id: number) => void; onDelete?: (id: number) => void;
onRetryMessage?: (messageId: number) => void;
dmRecipientPublicKey?: string; dmRecipientPublicKey?: string;
} }
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) { export function ChatMessages({
const { messages: hookMessages } = useChat(); messages = [],
children,
isDm = false,
onReplySelect,
onEditSelect,
onDelete,
onRetryMessage,
dmRecipientPublicKey }: ChatMessagesProps
) {
const { user } = useAppState(); const { user } = useAppState();
// Use prop messages if provided, otherwise use hook messages // Use prop messages (panels provide their own messages)
const messages = propMessages || hookMessages;
const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null); const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(false); const [isLoadingProfile, setIsLoadingProfile] = useState(false);
@@ -41,6 +50,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null); const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
useEffect(() => { useEffect(() => {
if (!deleteDialogOpen) { if (!deleteDialogOpen) {
setToBeDeleted(null); setToBeDeleted(null);
@@ -92,20 +102,6 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
if (!toBeDeleted || !user.authToken) return; if (!toBeDeleted || !user.authToken) return;
try { try {
onDelete?.(toBeDeleted.id); 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) { } catch (error) {
console.error("Failed to delete message:", error); console.error("Failed to delete message:", error);
} }
@@ -117,16 +113,60 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
setDeleteDialogOpen(true); 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 ( return (
<> <>
<div className="chat-messages" id="chat-messages"> <div className="chat-messages" id="chat-messages">
{messages.map((message) => ( {messages.map((message: MessageType) => (
<Message <Message
key={message.id} key={message.id}
message={message} message={message}
isAuthor={message.username === user.currentUser?.username} isAuthor={message.username === user.currentUser?.username}
onProfileClick={handleProfileClick} onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick}
isLoadingProfile={isLoadingProfile} isLoadingProfile={isLoadingProfile}
isDm={isDm} isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} /> dmRecipientPublicKey={dmRecipientPublicKey} />
@@ -162,11 +202,15 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
onEdit={handleEdit} onEdit={handleEdit}
onReply={handleReply} onReply={handleReply}
onDelete={handleDelete} onDelete={handleDelete}
onRetry={handleRetry}
onReactionClick={handleReactionClick}
position={contextMenu.position} position={contextMenu.position}
isOpen={contextMenu.isOpen} isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange} 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 { formatTime, id } from "@/utils/utils";
import type { Attachment, Message as MessageType } from "../../../core/types"; import type { Attachment, Message as MessageType, Reaction } from "@/core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import Quote from "../core/Quote"; import Quote from "@/core/components/Quote";
import { parse } from "marked"; import { parse } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { getCurrentKeys } from "../../../auth/crypto"; import { getCurrentKeys } from "@/core/api/authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { getAuthHeaders } from "../../../auth/api"; import { getAuthHeaders } from "@/core/api/authApi";
import { useAppState } from "../../state"; import { useAppState } from "@/pages/chat/state";
import { ub64 } from "../../../utils/utils"; import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer"; 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 { interface MessageProps {
message: MessageType; message: MessageType;
isAuthor: boolean; isAuthor: boolean;
onProfileClick: (username: string) => void; onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void; onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
onReactionClick?: (messageId: number, emoji: string) => void;
isLoadingProfile?: boolean; isLoadingProfile?: boolean;
isDm?: boolean; isDm?: boolean;
dmRecipientPublicKey?: string; dmRecipientPublicKey?: string;
@@ -30,7 +148,16 @@ interface Rect {
height: number 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 [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map()); const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set()); 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 imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope; const dmEnvelope = message.runtimeData?.dmEnvelope;
useEffect(() => { const decryptFile = useCallback(async (file: Attachment): Promise<string | null> => {
(async () => {
setFormattedMessage({
__html: DOMPurify.sanitize(
await parse(message.content)
).trim()
});
})();
}, [message]);
// Auto-decrypt images in DMs
useEffect(() => {
if (isDm && message.files) {
message.files.forEach(async (file) => {
console.log(file);
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
console.log("Decrypting...");
const decryptedUrl = await decryptFile(file);
console.log(decryptedUrl);
if (decryptedUrl) {
updateDecryptedFiles(draft => {
draft.set(file.path, decryptedUrl);
});
}
}
});
}
}, [message.files, isDm, decryptedFiles]);
const decryptFile = async (file: Attachment): Promise<string | null> => {
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) { if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
debugger;
console.warn("Conditions not met") console.warn("Conditions not met")
return null; return null;
} }
@@ -133,9 +229,39 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
} finally { } finally {
// no-op decrypt indicator removed from UI // 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 // Use decrypted URL if available, otherwise decrypt first
const decryptedUrl = decryptedFiles.get(file.path); const decryptedUrl = decryptedFiles.get(file.path);
if (decryptedUrl) { 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 viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const maxWidth = Math.floor(viewportWidth * 0.9); const maxWidth = Math.floor(viewportWidth * 0.9);
@@ -165,7 +291,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
return { left, top, width, height }; 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 rect = imgEl.getBoundingClientRect();
const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
const tempImg = new Image(); const tempImg = new Image();
@@ -186,7 +312,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}; };
}; };
const closeFullscreen = () => { function closeFullscreen() {
// Reverse animation // Reverse animation
setIsAnimatingOpen(false); setIsAnimatingOpen(false);
// Wait for transition to finish // Wait for transition to finish
@@ -198,7 +324,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}, 300); }, 300);
}; };
const downloadImage = async () => { async function downloadImage() {
if (!fullscreenImage) return; if (!fullscreenImage) return;
const { src, name } = fullscreenImage; const { src, name } = fullscreenImage;
try { try {
@@ -232,7 +358,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
} }
}; };
const downloadFile = async (file: Attachment) => { async function downloadFile(file: Attachment) {
try { try {
updateDownloadingPaths(draft => { updateDownloadingPaths(draft => {
draft.add(file.path); draft.add(file.path);
@@ -286,7 +412,6 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<div className="message-inner"> <div className="message-inner">
{/* Add profile picture for received messages */}
{!isAuthor && !isDm && ( {!isAuthor && !isDm && (
<div className="message-profile-pic"> <div className="message-profile-pic">
<img <img
@@ -307,12 +432,13 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
<div <div
className={`message-username ${isLoadingProfile ? "loading" : ""}`} className={`message-username ${isLoadingProfile ? "loading" : ""}`}
onClick={() => !isLoadingProfile && onProfileClick(message.username)} onClick={() => !isLoadingProfile && onProfileClick(message.username)}
role="link"
tabIndex={0}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}> style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
{message.username} {message.username}
</div> </div>
)} )}
{/* Add reply preview if this is a reply */}
{message.reply_to && ( {message.reply_to && (
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}> <Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className="reply-username">{message.reply_to.username}</span> <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 decryptedUrl = decryptedFiles.get(file.path);
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined; const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
const isDownloading = downloadingPaths.has(file.path); const isDownloading = downloadingPaths.has(file.path);
const isSending = message.runtimeData?.sendingState?.status === "sending";
return ( return (
<div className="attachment" key={idx}> <div className="attachment" key={idx}>
@@ -342,10 +469,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
src={imageSrc} src={imageSrc}
alt={file.name || "image"} alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)} 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"}`} className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
/> />
{!loadedImages.has(file.path) && ( {(!loadedImages.has(file.path) || isSending) && (
<div className="loading-overlay"> <div className="loading-overlay">
<mdui-circular-progress /> <mdui-circular-progress />
</div> </div>
@@ -373,22 +502,43 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
</mdui-list> </mdui-list>
)} )}
<Reactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
/>
<div className="message-time"> <div className="message-time">
{formatTime(message.timestamp)} {formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined} {message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && ( {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> </div>
</div> </div>
{/* Fullscreen Image Viewer with shared-element like transition */} {/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && ( {fullscreenImage && createPortal(
<div <div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`} className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
onClick={closeFullscreen}> onClick={closeFullscreen}
role="dialog">
<img <img
src={fullscreenImage.src} src={fullscreenImage.src}
alt={fullscreenImage.name} alt={fullscreenImage.name}
@@ -401,7 +551,10 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}} }}
onClick={e => e.stopPropagation()} 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} /> <mdui-button-icon icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? ( {isDownloadingFullscreen ? (
<div className="progress-wrapper"> <div className="progress-wrapper">
@@ -411,7 +564,8 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
<mdui-button-icon icon="download" onClick={downloadImage} /> <mdui-button-icon icon="download" onClick={downloadImage} />
)} )}
</div> </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 { DialogProps } from "@/core/types";
import type { UserProfile } from "../../../core/types"; import type { UserProfile } from "@/core/types";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { formatTime } from "../../../utils/utils"; import { formatTime } from "@/utils/utils";
import defaultAvatar from "../../../resources/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
interface UserProfileDialogProps extends DialogProps { interface UserProfileDialogProps extends DialogProps {
userProfile: UserProfile | null; userProfile: UserProfile | null;
@@ -13,8 +13,7 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
<div className="content"> <div className="content">
<div className="profile-picture-section"> <div className="profile-picture-section">
<img <img
className="profile-picture" className="profile-picture"
alt="Profile Picture"
src={userProfile.profile_picture || defaultAvatar} src={userProfile.profile_picture || defaultAvatar}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; 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"}`}> <div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
{userProfile.online ? ( {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> </div>
<div className="bio-section"> <div className="bio-section">
<label>О себе:</label> <span>О себе:</span>
<div className="bio-display"> <div className="bio-display">
{userProfile.bio || "No bio available."} {userProfile.bio || "No bio available."}
</div> </div>
@@ -55,7 +54,7 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
</div> </div>
<div className="profile-actions"> <div className="profile-actions">
<mdui-button id="dm-button" variant="filled"> <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 Send Message
</mdui-button> </mdui-button>
</div> </div>

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