mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
56 Commits
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Analyze my codebase and think how it could be better organized, like a better folder or code structure.
|
||||
@@ -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.
|
||||
@@ -4,17 +4,49 @@ alwaysApply: true
|
||||
|
||||
When working with this project, follow these rules:
|
||||
|
||||
## Core Behavior
|
||||
- NEVER do anything i didn't ask you for!
|
||||
- Use double quotes ("") for strings.
|
||||
- Don't talk like a robot. Behave more like a human.
|
||||
- Be concise and direct in responses.
|
||||
- If you're unsure about something, ask for clarification instead of guessing.
|
||||
|
||||
## Code Quality & Principles
|
||||
- Follow DRY, SOLID, YAGNI and KISS principles.
|
||||
- Do NOT use old, outdated or deprecated APIs and functions.
|
||||
- Use double quotes ("") for strings consistently.
|
||||
- Prefer functional components over class components in React.
|
||||
- Use TypeScript strictly - avoid `any` types unless absolutely necessary.
|
||||
|
||||
## File Operations
|
||||
- If possible, try to update files in a single edit when making multiple changes.
|
||||
- Do NOT "cd" to the project directory.
|
||||
|
||||
## Testing & Validation
|
||||
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||
need to typecheck or build the app, in that case:
|
||||
|
||||
- To typecheck, run `npm run frontend:typecheck`.
|
||||
- To build, run `npm run frontend:build`.
|
||||
- Do NOT execute other commands like "cd".
|
||||
- If the typecheck passed, there's no need for checking the linter errors.
|
||||
|
||||
Do NOT execute other commands like "cd".
|
||||
- Do NOT "cd" to the project directory.
|
||||
- If possible, try to update files in a single edit.
|
||||
## Async Operations
|
||||
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
|
||||
make it async. The import is `<project>/frontend/src/utils/utils`.
|
||||
- When you complete your task, remove unused imports if there are any.
|
||||
make it async.
|
||||
|
||||
## Database
|
||||
- NEVER create database migrations, they are auto-generated.
|
||||
|
||||
## Project Structure Awareness
|
||||
- This is a React/TypeScript frontend with Python FastAPI backend
|
||||
- Uses MDUI components for UI
|
||||
- Has Electron support for desktop app
|
||||
- Uses Zustand for state management
|
||||
- Uses use-immer for immutable state updates
|
||||
- Uses React Router for navigation
|
||||
- Has WebSocket support for real-time features
|
||||
- Uses encryption (tweetnacl) for security
|
||||
|
||||
## Performance & Efficiency
|
||||
- Batch tool calls when possible to reduce latency
|
||||
- Use semantic search before grep when looking for concepts
|
||||
- Use TODOs for complex multi-step tasks to track progress
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When you work with UI:
|
||||
|
||||
1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML.
|
||||
2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML.
|
||||
3. The supporting text slot for MDUI lists is "description".
|
||||
4. When working with lists/sets in states, use the "useImmer" hook.
|
||||
+198
-8
@@ -1,6 +1,6 @@
|
||||
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,macos,node,osx
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,macos,node,osx
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,osx,node,macos,react,reactnative
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,osx,node,macos,react,reactnative
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
@@ -203,6 +203,8 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -353,6 +355,194 @@ poetry.toml
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### react ###
|
||||
.DS_*
|
||||
**/*.backup.*
|
||||
**/*.back.*
|
||||
|
||||
node_modules
|
||||
|
||||
*.sublime*
|
||||
|
||||
psd
|
||||
thumb
|
||||
sketch
|
||||
|
||||
### ReactNative ###
|
||||
# React Native Stack Base
|
||||
|
||||
.expo
|
||||
__generated__
|
||||
|
||||
### ReactNative.macOS Stack ###
|
||||
# General
|
||||
|
||||
# Icon must end with two \r
|
||||
|
||||
# Thumbnails
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
|
||||
### ReactNative.Android Stack ###
|
||||
# Gradle files
|
||||
.gradle/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
### ReactNative.Gradle Stack ###
|
||||
.gradle
|
||||
**/build/
|
||||
!src/**/build/
|
||||
|
||||
# Ignore Gradle GUI config
|
||||
gradle-app.setting
|
||||
|
||||
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
||||
!gradle-wrapper.jar
|
||||
|
||||
# Avoid ignore Gradle wrappper properties
|
||||
!gradle-wrapper.properties
|
||||
|
||||
# Cache of project
|
||||
.gradletasknamecache
|
||||
|
||||
# Eclipse Gradle plugin generated files
|
||||
# Eclipse Core
|
||||
.project
|
||||
# JDT-specific (Eclipse Java Development Tools)
|
||||
.classpath
|
||||
|
||||
### ReactNative.Xcode Stack ###
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Xcode 8 and earlier
|
||||
*.xcscmblueprint
|
||||
*.xccheckout
|
||||
|
||||
### ReactNative.Linux Stack ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### ReactNative.Node Stack ###
|
||||
# Logs
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
# Runtime data
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
# Dependency directories
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
# Next.js build output
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
# Gatsby files
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
# Serverless directories
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
# TernJS port file
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
# yarn v2
|
||||
|
||||
### ReactNative.Buck Stack ###
|
||||
buck-out/
|
||||
.buckconfig.local
|
||||
.buckd/
|
||||
.buckversion
|
||||
.fakebuckversion
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
@@ -372,15 +562,15 @@ pyrightconfig.json
|
||||
.history
|
||||
.ionide
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,macos,node,osx
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,osx,node,macos,react,reactnative
|
||||
|
||||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||
|
||||
data
|
||||
backend/data
|
||||
.vite
|
||||
*.db
|
||||
package-lock.json
|
||||
dist-electron
|
||||
backend/migrations/**
|
||||
!backend/migrations/env.py
|
||||
!backend/migrations/script.py.mako
|
||||
backend/alembic/**
|
||||
!backend/alembic/env.py
|
||||
!backend/alembic/script.py.mako
|
||||
!frontend/src/css/lib
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
+30
-12
@@ -1,12 +1,39 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from migration import run_auto_migration
|
||||
from db import engine
|
||||
from contextlib import asynccontextmanager
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
from routes import account, messaging, profile, push
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup - run migration in separate process to avoid logging interference
|
||||
try:
|
||||
print("Starting database migration check...")
|
||||
# Run migration in a separate process
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||
],
|
||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||
# No capture_output - let it stream to terminal in real-time
|
||||
# No text=True - let it use the terminal's encoding
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to run database migrations: {e}")
|
||||
raise
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown (if needed in the future)
|
||||
# logger.info("Application shutdown")
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(title="PixelChat")
|
||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
@@ -22,12 +49,3 @@ app.include_router(account.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _auto_migrate_on_startup():
|
||||
try:
|
||||
run_auto_migration(engine)
|
||||
except Exception:
|
||||
# Keep startup resilient; errors should be visible in server logs
|
||||
pass
|
||||
+511
-80
@@ -1,92 +1,523 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Database migration utility using Alembic.
|
||||
This module handles running database migrations on startup.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from traceback import format_exc
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
import logging
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
from models import Base
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
|
||||
LOCK_FILE = MIGRATIONS_DIR / ".autogen.lock"
|
||||
SCHEMA_HASH_FILE = MIGRATIONS_DIR / ".schema.hash"
|
||||
|
||||
|
||||
def _ensure_alembic_layout() -> None:
|
||||
"""Create a minimal Alembic environment if missing."""
|
||||
versions = MIGRATIONS_DIR / "versions"
|
||||
versions.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _alembic_config() -> Config:
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
|
||||
cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
# Provide a minimal ini section so env.py can read config_ini_section
|
||||
cfg.config_file_name = "alembic.ini"
|
||||
cfg.set_section_option("alembic", "sqlalchemy.url", DATABASE_URL)
|
||||
return cfg
|
||||
|
||||
|
||||
def _model_schema_fingerprint() -> str:
|
||||
"""Compute a deterministic fingerprint of the current SQLAlchemy model schema."""
|
||||
parts: list[str] = []
|
||||
md = Base.metadata
|
||||
for table in sorted(md.tables.values(), key=lambda t: t.name):
|
||||
parts.append(f"T:{table.name}")
|
||||
for col in sorted(table.columns, key=lambda c: c.name):
|
||||
col_type = str(col.type)
|
||||
parts.append(f"C:{col.name}:{col_type}:N{int(bool(col.nullable))}")
|
||||
digest = hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
||||
return digest
|
||||
|
||||
|
||||
def run_auto_migration(engine: Engine) -> None:
|
||||
"""Use Alembic to autogenerate and apply migrations automatically on startup."""
|
||||
# Ensure env present
|
||||
_ensure_alembic_layout()
|
||||
cfg = _alembic_config()
|
||||
|
||||
def run_migrations():
|
||||
"""
|
||||
Run database migrations using Alembic.
|
||||
This function will upgrade the database to the latest migration.
|
||||
Fully automated - handles all scenarios automatically.
|
||||
"""
|
||||
try:
|
||||
# Upgrade existing migrations (if any) first
|
||||
command.upgrade(cfg, "head")
|
||||
except Exception:
|
||||
print("[alembic] upgrade to head failed:\n" + format_exc())
|
||||
# Get the directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Always attempt autogenerate only when model schema fingerprint changed
|
||||
try:
|
||||
# Avoid concurrent autogenerate on dev server reloads
|
||||
try:
|
||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
os.close(fd)
|
||||
have_lock = True
|
||||
except FileExistsError:
|
||||
have_lock = False
|
||||
# Create Alembic configuration
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
|
||||
if have_lock:
|
||||
# Disable Alembic's logging configuration to avoid interfering with FastAPI
|
||||
alembic_cfg.set_main_option("configure_logging", "false")
|
||||
|
||||
# Set the database URL in the config
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Check if any migration files exist
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
|
||||
if not os.path.exists(versions_dir):
|
||||
os.makedirs(versions_dir)
|
||||
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if not migration_files:
|
||||
logger.info("No migration files found. Creating initial migration...")
|
||||
# Check if database exists and has tables
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
|
||||
existing_tables = result.fetchall()
|
||||
|
||||
if existing_tables:
|
||||
logger.info("Found existing database with tables. Creating migration to match current schema...")
|
||||
# Create migration with autogenerate to detect differences
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration from existing database")
|
||||
|
||||
# Check if the generated migration is empty (common with existing databases)
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
# Check if migration is empty
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content:
|
||||
logger.info("Generated migration is empty. Creating complete schema migration...")
|
||||
# Remove the empty migration
|
||||
os.remove(migration_path)
|
||||
# Create a complete migration
|
||||
_create_complete_migration(alembic_cfg)
|
||||
else:
|
||||
logger.info("No existing tables found. Creating fresh migration...")
|
||||
# Create fresh migration
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
|
||||
logger.info("Initial migration created successfully.")
|
||||
else:
|
||||
# Migration files exist, check if we need to create a new migration for schema changes
|
||||
logger.info("Migration files exist. Checking for pending schema changes...")
|
||||
try:
|
||||
new_hash = _model_schema_fingerprint()
|
||||
old_hash = SCHEMA_HASH_FILE.read_text(encoding="utf-8").strip() if SCHEMA_HASH_FILE.exists() else ""
|
||||
if new_hash != old_hash:
|
||||
command.revision(cfg, message="auto", autogenerate=True)
|
||||
command.upgrade(cfg, "head")
|
||||
# Update stored fingerprint
|
||||
SCHEMA_HASH_FILE.write_text(new_hash, encoding="utf-8")
|
||||
finally:
|
||||
try:
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
print("[alembic] autogenerate failed:\n" + format_exc())
|
||||
# Create a new migration to detect any schema changes
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration for schema changes")
|
||||
|
||||
# Check if the new migration is empty (no changes detected)
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
|
||||
# Check if migration is empty
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'pass' in content and 'op.create_table' not in content and 'op.add_column' not in content and 'op.drop_table' not in content and 'op.drop_column' not in content:
|
||||
logger.info("No schema changes detected. Removing empty migration...")
|
||||
# Remove the empty migration
|
||||
os.remove(migration_path)
|
||||
else:
|
||||
logger.info("Schema changes detected. New migration created.")
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"No new migrations needed or error creating migration: {e}")
|
||||
pass
|
||||
|
||||
# Run the upgrade command
|
||||
logger.info("Running database migrations...")
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully.")
|
||||
except Exception as upgrade_error:
|
||||
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
connection.commit()
|
||||
# Try upgrade again
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully after reset.")
|
||||
else:
|
||||
raise upgrade_error
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running database migrations: {e}")
|
||||
# Fully automated recovery - handle ALL error scenarios
|
||||
logger.info("Attempting automated recovery...")
|
||||
try:
|
||||
# Clear the alembic_version table to reset state
|
||||
engine = create_engine(DATABASE_URL)
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
# Remove any existing migration files to start fresh
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
for file in os.listdir(versions_dir):
|
||||
if file.endswith('.py') and not file.startswith('__'):
|
||||
os.remove(os.path.join(versions_dir, file))
|
||||
|
||||
# Create a completely fresh migration with full schema
|
||||
logger.info("Creating fresh migration with complete schema...")
|
||||
_create_complete_migration(alembic_cfg)
|
||||
|
||||
# Run the migration
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Automated recovery completed successfully.")
|
||||
|
||||
except Exception as recovery_error:
|
||||
logger.error(f"Automated recovery failed: {recovery_error}")
|
||||
# Last resort: create database using SQLAlchemy directly
|
||||
logger.info("Using fallback: creating database directly...")
|
||||
_create_database_directly()
|
||||
logger.info("Database created successfully using fallback method.")
|
||||
|
||||
|
||||
def _create_complete_migration(alembic_cfg):
|
||||
"""Create a complete migration file with all database schema."""
|
||||
# Create a new migration file
|
||||
command.revision(alembic_cfg, message="Complete schema migration")
|
||||
|
||||
# Get the latest migration file
|
||||
versions_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
latest_migration = max(migration_files) if migration_files else None
|
||||
|
||||
if latest_migration:
|
||||
migration_path = os.path.join(versions_dir, latest_migration)
|
||||
_populate_migration_file(migration_path)
|
||||
|
||||
|
||||
def _populate_migration_file(migration_path):
|
||||
"""Populate a migration file with the complete database schema from models."""
|
||||
# Generate the migration content dynamically from models
|
||||
migration_content = _generate_migration_from_models()
|
||||
|
||||
# Read the current migration file
|
||||
with open(migration_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Add datetime import if needed
|
||||
if "datetime.now" in migration_content and "from datetime import datetime" not in content:
|
||||
# Insert the import after the existing imports
|
||||
import re
|
||||
content = re.sub(
|
||||
r'(from alembic import op\nimport sqlalchemy as sa\n)',
|
||||
r'\1from datetime import datetime\n',
|
||||
content
|
||||
)
|
||||
|
||||
# Replace the empty upgrade/downgrade functions
|
||||
import re
|
||||
# More flexible regex to match the actual content
|
||||
content = re.sub(
|
||||
r'def upgrade\(\) -> None:.*?pass.*?(?=\n\ndef downgrade|\n\nif __name__|\Z)',
|
||||
migration_content,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
# Write the updated content back
|
||||
with open(migration_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _generate_migration_from_models():
|
||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
||||
from models import Base
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime
|
||||
|
||||
# Generate migration content using Alembic's op functions
|
||||
upgrade_statements = []
|
||||
downgrade_statements = []
|
||||
|
||||
# Get all tables from Base metadata
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
if table_name != 'alembic_version': # Skip alembic_version table
|
||||
# Check if table exists and compare schema
|
||||
schema_diff = _detect_schema_differences(table_name, table)
|
||||
|
||||
if schema_diff['table_exists']:
|
||||
if schema_diff['needs_update']:
|
||||
# Generate ALTER TABLE statements for existing table
|
||||
upgrade_statements.append(f" # Update {table_name} table schema")
|
||||
for statement in schema_diff['alter_statements']:
|
||||
upgrade_statements.append(f" {statement}")
|
||||
else:
|
||||
# Table exists and is up to date - skip creating it
|
||||
upgrade_statements.append(f" # Table {table_name} already exists and is up to date")
|
||||
else:
|
||||
# Generate CREATE TABLE for new table
|
||||
table_code = _generate_table_creation_code(table_name, table)
|
||||
upgrade_statements.append(f" # Create {table_name} table")
|
||||
upgrade_statements.append(table_code)
|
||||
|
||||
# Only add to downgrade if table actually exists
|
||||
if schema_diff['table_exists']:
|
||||
downgrade_statements.append(f" # op.drop_table('{table_name}') # Skipped - table exists")
|
||||
else:
|
||||
downgrade_statements.append(f" op.drop_table('{table_name}')")
|
||||
|
||||
# Combine all statements
|
||||
upgrade_content = "def upgrade() -> None:\n \"\"\"Upgrade schema.\"\"\"\n" + "\n".join(upgrade_statements)
|
||||
downgrade_content = "def downgrade() -> None:\n \"\"\"Downgrade schema.\"\"\"\n" + "\n".join(downgrade_statements)
|
||||
|
||||
return upgrade_content + "\n\n" + downgrade_content
|
||||
|
||||
|
||||
def _detect_schema_differences(table_name, expected_table):
|
||||
"""Detect differences between existing table and expected schema."""
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
# Check if table exists
|
||||
inspector = inspect(connection)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return {
|
||||
'table_exists': False,
|
||||
'needs_update': False,
|
||||
'alter_statements': []
|
||||
}
|
||||
|
||||
# Get existing columns
|
||||
existing_columns = inspector.get_columns(table_name)
|
||||
existing_column_names = {col['name'] for col in existing_columns}
|
||||
|
||||
# Get expected columns
|
||||
expected_column_names = {col.name for col in expected_table.columns}
|
||||
|
||||
# Check for missing columns
|
||||
missing_columns = expected_column_names - existing_column_names
|
||||
extra_columns = existing_column_names - expected_column_names
|
||||
|
||||
alter_statements = []
|
||||
|
||||
# Add missing columns
|
||||
for column in expected_table.columns:
|
||||
if column.name in missing_columns:
|
||||
column_def = _generate_column_definition(column)
|
||||
alter_statements.append(f"op.add_column('{table_name}', {column_def})")
|
||||
|
||||
# Add missing indexes
|
||||
for index in expected_table.indexes:
|
||||
if not index.unique:
|
||||
cols = "', '".join([col.name for col in index.columns])
|
||||
alter_statements.append(f"op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
||||
|
||||
return {
|
||||
'table_exists': True,
|
||||
'needs_update': len(alter_statements) > 0,
|
||||
'alter_statements': alter_statements
|
||||
}
|
||||
|
||||
|
||||
def _generate_column_definition(column):
|
||||
"""Generate column definition for ALTER TABLE."""
|
||||
type_def = _get_column_type(column)
|
||||
nullable = "nullable=True" if column.nullable else "nullable=False"
|
||||
|
||||
definition = f"sa.Column('{column.name}', {type_def}, {nullable}"
|
||||
|
||||
# Handle default values properly
|
||||
if column.default is not None:
|
||||
if hasattr(column.default, 'arg'):
|
||||
# Handle callable defaults
|
||||
if callable(column.default.arg):
|
||||
definition += f", default=datetime.now"
|
||||
else:
|
||||
definition += f", default={repr(column.default.arg)}"
|
||||
else:
|
||||
definition += f", default={repr(column.default)}"
|
||||
|
||||
definition += ")"
|
||||
return definition
|
||||
|
||||
|
||||
def _generate_table_creation_code(table_name, table):
|
||||
"""Generate op.create_table code for a SQLAlchemy table."""
|
||||
lines = [f" op.create_table('{table_name}',"]
|
||||
|
||||
# Collect all table items (columns + constraints)
|
||||
all_items = []
|
||||
|
||||
# Add columns
|
||||
for column in table.columns:
|
||||
column_def = f" sa.Column('{column.name}', {_get_column_type(column)}, nullable={column.nullable}"
|
||||
if column.default is not None:
|
||||
# Handle callable defaults properly
|
||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
||||
column_def += f", default=datetime.now"
|
||||
else:
|
||||
column_def += f", default={repr(column.default)}"
|
||||
column_def += ")"
|
||||
all_items.append(column_def)
|
||||
|
||||
# Add constraints
|
||||
for constraint in table.constraints:
|
||||
if hasattr(constraint, 'columns'):
|
||||
if constraint.__class__.__name__ == 'PrimaryKeyConstraint':
|
||||
all_items.append(f" sa.PrimaryKeyConstraint('{constraint.columns.keys()[0]}')")
|
||||
elif constraint.__class__.__name__ == 'UniqueConstraint':
|
||||
cols = "', '".join(constraint.columns.keys())
|
||||
all_items.append(f" sa.UniqueConstraint('{cols}')")
|
||||
|
||||
# Add foreign key constraints
|
||||
for fk in table.foreign_keys:
|
||||
all_items.append(f" sa.ForeignKeyConstraint(['{fk.parent.name}'], ['{fk.column.table.name}.{fk.column.name}'], )")
|
||||
|
||||
# Add all items with commas (except the last one)
|
||||
for i, item in enumerate(all_items):
|
||||
if i < len(all_items) - 1:
|
||||
item += ","
|
||||
lines.append(item)
|
||||
|
||||
lines.append(" )")
|
||||
|
||||
# Add indexes with IF NOT EXISTS equivalent using try/except
|
||||
for index in table.indexes:
|
||||
if not index.unique:
|
||||
cols = "', '".join([col.name for col in index.columns])
|
||||
lines.append(f" # Create index for {table_name}")
|
||||
lines.append(f" try:")
|
||||
lines.append(f" op.create_index(op.f('ix_{table_name}_{index.name}'), '{table_name}', ['{cols}'], unique=False)")
|
||||
lines.append(f" except Exception:")
|
||||
lines.append(f" pass # Index may already exist")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_column_type(column):
|
||||
"""Get SQLAlchemy column type string."""
|
||||
type_name = column.type.__class__.__name__
|
||||
|
||||
if type_name == 'String':
|
||||
return f"sa.String(length={column.type.length})"
|
||||
elif type_name == 'Integer':
|
||||
return "sa.Integer()"
|
||||
elif type_name == 'Text':
|
||||
return "sa.Text()"
|
||||
elif type_name == 'Boolean':
|
||||
return "sa.Boolean()"
|
||||
elif type_name == 'DateTime':
|
||||
return "sa.DateTime()"
|
||||
else:
|
||||
return f"sa.{type_name}()"
|
||||
|
||||
|
||||
def _create_database_directly():
|
||||
"""Fallback method: create database directly using SQLAlchemy."""
|
||||
from models import Base
|
||||
from db import engine
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
# Check existing tables and update schema
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# For each model table, check if it needs updates
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
if table_name != 'alembic_version':
|
||||
if table_name in existing_tables:
|
||||
# Table exists, check for missing columns
|
||||
existing_columns = {col['name'] for col in inspector.get_columns(table_name)}
|
||||
expected_columns = {col.name for col in table.columns}
|
||||
missing_columns = expected_columns - existing_columns
|
||||
|
||||
# Add missing columns
|
||||
for column in table.columns:
|
||||
if column.name in missing_columns:
|
||||
# Convert to raw SQL for direct execution
|
||||
sql_type = _get_sql_type(column)
|
||||
nullable = "NULL" if column.nullable else "NOT NULL"
|
||||
|
||||
# Handle datetime columns without default (SQLite limitation)
|
||||
if column.type.__class__.__name__ == 'DateTime':
|
||||
# Add column without default, then update existing rows
|
||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}"
|
||||
try:
|
||||
connection.execute(text(alter_sql))
|
||||
logger.info(f"Added column {column.name} to {table_name}")
|
||||
|
||||
# Update existing rows with current timestamp
|
||||
update_sql = f"UPDATE {table_name} SET {column.name} = CURRENT_TIMESTAMP WHERE {column.name} IS NULL"
|
||||
connection.execute(text(update_sql))
|
||||
logger.info(f"Updated {column.name} with current timestamp")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not add column {column.name}: {e}")
|
||||
else:
|
||||
# Handle other column types with defaults
|
||||
default_clause = ""
|
||||
if column.default is not None:
|
||||
if hasattr(column.default, 'arg') and callable(column.default.arg):
|
||||
# Skip callable defaults for SQLite compatibility
|
||||
pass
|
||||
elif hasattr(column.default, 'arg'):
|
||||
default_clause = f" DEFAULT {repr(column.default.arg)}"
|
||||
|
||||
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {sql_type} {nullable}{default_clause}"
|
||||
try:
|
||||
connection.execute(text(alter_sql))
|
||||
logger.info(f"Added column {column.name} to {table_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not add column {column.name}: {e}")
|
||||
else:
|
||||
# Table doesn't exist, create it
|
||||
logger.info(f"Creating table {table_name}")
|
||||
|
||||
# Create alembic_version table manually
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL,
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)
|
||||
"""))
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _get_sql_type(column):
|
||||
"""Get SQL type for direct SQL execution."""
|
||||
type_name = column.type.__class__.__name__
|
||||
|
||||
if type_name == 'String':
|
||||
return f"VARCHAR({column.type.length})"
|
||||
elif type_name == 'Integer':
|
||||
return "INTEGER"
|
||||
elif type_name == 'Text':
|
||||
return "TEXT"
|
||||
elif type_name == 'Boolean':
|
||||
return "BOOLEAN"
|
||||
elif type_name == 'DateTime':
|
||||
return "DATETIME"
|
||||
else:
|
||||
return "TEXT" # fallback
|
||||
|
||||
|
||||
def check_migration_status():
|
||||
"""
|
||||
Check if the database needs migrations.
|
||||
Returns True if migrations are needed, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Create engine
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
# Check if alembic_version table exists
|
||||
with engine.connect() as connection:
|
||||
# Check if alembic_version table exists
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'")
|
||||
)
|
||||
alembic_table_exists = result.fetchone() is not None
|
||||
|
||||
if not alembic_table_exists:
|
||||
return True
|
||||
|
||||
# Get current migration context
|
||||
context = MigrationContext.configure(connection)
|
||||
current_rev = context.get_current_revision()
|
||||
|
||||
# Get the latest revision from alembic
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
script_dir = command.ScriptDirectory.from_config(alembic_cfg)
|
||||
head_rev = script_dir.get_current_head()
|
||||
|
||||
return current_rev != head_rev
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking migration status: {e}")
|
||||
return True # Assume migrations are needed if we can't check
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# This allows running migrations directly
|
||||
run_migrations()
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
from models import Base
|
||||
|
||||
config = context.config
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _skip_empty_autogenerate(ctx, rev, directives):
|
||||
# Avoid creating empty migrations when there are no schema changes
|
||||
if getattr(config, "cmd_opts", None) and getattr(config.cmd_opts, "autogenerate", False):
|
||||
if directives:
|
||||
script = directives[0]
|
||||
if hasattr(script, "upgrade_ops") and script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True,
|
||||
process_revision_directives=_skip_empty_autogenerate
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(config.get_section(config.config_ini_section) or {}, prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
process_revision_directives=_skip_empty_autogenerate
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '${up_revision}'
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
+73
-5
@@ -1,8 +1,7 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, null, text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from db import engine
|
||||
from pydantic import BaseModel
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -37,6 +36,7 @@ class Message(Base):
|
||||
author = relationship("User", back_populates="messages")
|
||||
reply_to = relationship("Message", remote_side=[id])
|
||||
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
reactions = relationship("Reaction", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class MessageFile(Base):
|
||||
@@ -80,6 +80,7 @@ class DMEnvelope(Base):
|
||||
reply_to_id = Column(Integer, nullable=True)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
|
||||
class DMFile(Base):
|
||||
@@ -107,6 +108,39 @@ class PushSubscription(Base):
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
# Ensure unique combination of message, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
|
||||
|
||||
|
||||
class DMReaction(Base):
|
||||
__tablename__ = "dm_reaction"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
|
||||
|
||||
# Ensure unique combination of dm_envelope, user, and emoji
|
||||
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
||||
|
||||
|
||||
# Pydantic модели
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
@@ -121,7 +155,7 @@ class RegisterRequest(BaseModel):
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
content: str
|
||||
reply_to_id: int | None
|
||||
reply_to_id: int | None = None
|
||||
|
||||
|
||||
class EditMessageRequest(BaseModel):
|
||||
@@ -167,5 +201,39 @@ class MessageResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Создание таблиц
|
||||
Base.metadata.create_all(bind=engine)
|
||||
class ReactionRequest(BaseModel):
|
||||
message_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class ReactionResponse(BaseModel):
|
||||
id: int
|
||||
message_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DMReactionRequest(BaseModel):
|
||||
dm_envelope_id: int
|
||||
emoji: str
|
||||
|
||||
|
||||
class DMReactionResponse(BaseModel):
|
||||
id: int
|
||||
dm_envelope_id: int
|
||||
user_id: int
|
||||
emoji: str
|
||||
timestamp: datetime
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Tables are now created through Alembic migrations
|
||||
# Base.metadata.create_all(bind=engine)
|
||||
@@ -116,9 +116,13 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
token = create_token(new_user.id, new_user.username)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Регистрация прошла успешно Теперь вы можете войти."
|
||||
"message": "Регистрация прошла успешно",
|
||||
"token": token,
|
||||
"user": convert_user(new_user)
|
||||
}
|
||||
|
||||
@router.get("/crypto/public-key")
|
||||
|
||||
+239
-1
@@ -10,7 +10,7 @@ from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from constants import OWNER_USERNAME
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse
|
||||
from push_service import push_service
|
||||
from PIL import Image
|
||||
import io
|
||||
@@ -31,6 +31,23 @@ os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def convert_message(msg: Message) -> dict:
|
||||
# Group reactions by emoji
|
||||
reactions_dict = {}
|
||||
if msg.reactions:
|
||||
for reaction in msg.reactions:
|
||||
emoji = reaction.emoji
|
||||
if emoji not in reactions_dict:
|
||||
reactions_dict[emoji] = {
|
||||
"emoji": emoji,
|
||||
"count": 0,
|
||||
"users": []
|
||||
}
|
||||
reactions_dict[emoji]["count"] += 1
|
||||
reactions_dict[emoji]["users"].append({
|
||||
"id": reaction.user_id,
|
||||
"username": reaction.user.username
|
||||
})
|
||||
|
||||
return {
|
||||
"id": msg.id,
|
||||
"content": msg.content,
|
||||
@@ -40,6 +57,7 @@ def convert_message(msg: Message) -> dict:
|
||||
"username": msg.author.username,
|
||||
"profile_picture": msg.author.profile_picture,
|
||||
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
|
||||
"reactions": list(reactions_dict.values()),
|
||||
"files": [
|
||||
{
|
||||
"path": f"/api/uploads/files/normal/{Path(f.path).name}",
|
||||
@@ -51,6 +69,47 @@ def convert_message(msg: Message) -> dict:
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
||||
# Group reactions by emoji
|
||||
reactions_dict = {}
|
||||
if envelope.reactions:
|
||||
for reaction in envelope.reactions:
|
||||
emoji = reaction.emoji
|
||||
if emoji not in reactions_dict:
|
||||
reactions_dict[emoji] = {
|
||||
"emoji": emoji,
|
||||
"count": 0,
|
||||
"users": []
|
||||
}
|
||||
reactions_dict[emoji]["count"] += 1
|
||||
reactions_dict[emoji]["users"].append({
|
||||
"id": reaction.user_id,
|
||||
"username": reaction.user.username
|
||||
})
|
||||
|
||||
return {
|
||||
"id": envelope.id,
|
||||
"senderId": envelope.sender_id,
|
||||
"recipientId": envelope.recipient_id,
|
||||
"iv": envelope.iv_b64,
|
||||
"ciphertext": envelope.ciphertext_b64,
|
||||
"salt": envelope.salt_b64,
|
||||
"iv2": envelope.iv2_b64,
|
||||
"wrappedMk": envelope.wrapped_mk_b64,
|
||||
"timestamp": envelope.timestamp.isoformat(),
|
||||
"reactions": list(reactions_dict.values()),
|
||||
"files": [
|
||||
{
|
||||
"path": f"/api/uploads/files/encrypted/{Path(f.path).name}",
|
||||
"id": f.id,
|
||||
"name": f.name,
|
||||
"dm_envelope_id": f.dm_envelope_id
|
||||
}
|
||||
for f in (envelope.files or [])
|
||||
]
|
||||
}
|
||||
|
||||
# для тех кто читает этот код я эти маты не писал
|
||||
# мат писал ии а я сам не матерюсь))
|
||||
# - denis0001-dev
|
||||
@@ -436,6 +495,125 @@ async def delete_message(
|
||||
|
||||
return {"status": "success", "message_id": message_id}
|
||||
|
||||
|
||||
@router.post("/add_reaction")
|
||||
async def add_reaction(
|
||||
request: ReactionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check if message exists
|
||||
message = db.query(Message).filter(Message.id == request.message_id).first()
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# Check if reaction already exists
|
||||
existing_reaction = db.query(Reaction).filter(
|
||||
Reaction.message_id == request.message_id,
|
||||
Reaction.user_id == current_user.id,
|
||||
Reaction.emoji == request.emoji
|
||||
).first()
|
||||
|
||||
if existing_reaction:
|
||||
# Remove existing reaction (toggle off)
|
||||
db.delete(existing_reaction)
|
||||
action = "removed"
|
||||
else:
|
||||
# Add new reaction
|
||||
new_reaction = Reaction(
|
||||
message_id=request.message_id,
|
||||
user_id=current_user.id,
|
||||
emoji=request.emoji
|
||||
)
|
||||
db.add(new_reaction)
|
||||
action = "added"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh message to get updated reactions
|
||||
db.refresh(message)
|
||||
|
||||
# Broadcast reaction update
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast({
|
||||
"type": "reactionUpdate",
|
||||
"data": {
|
||||
"message_id": request.message_id,
|
||||
"emoji": request.emoji,
|
||||
"action": action,
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": convert_message(message)["reactions"]
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
|
||||
|
||||
|
||||
@router.post("/dm/add_reaction")
|
||||
async def add_dm_reaction(
|
||||
request: DMReactionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Check if DM envelope exists
|
||||
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first()
|
||||
if not envelope:
|
||||
raise HTTPException(status_code=404, detail="DM envelope not found")
|
||||
|
||||
# Check if user is part of this DM conversation
|
||||
if current_user.id not in [envelope.sender_id, envelope.recipient_id]:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to react to this message")
|
||||
|
||||
# Check if reaction already exists
|
||||
existing_reaction = db.query(DMReaction).filter(
|
||||
DMReaction.dm_envelope_id == request.dm_envelope_id,
|
||||
DMReaction.user_id == current_user.id,
|
||||
DMReaction.emoji == request.emoji
|
||||
).first()
|
||||
|
||||
if existing_reaction:
|
||||
# Remove existing reaction (toggle off)
|
||||
db.delete(existing_reaction)
|
||||
action = "removed"
|
||||
else:
|
||||
# Add new reaction
|
||||
new_reaction = DMReaction(
|
||||
dm_envelope_id=request.dm_envelope_id,
|
||||
user_id=current_user.id,
|
||||
emoji=request.emoji
|
||||
)
|
||||
db.add(new_reaction)
|
||||
action = "added"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh envelope to get updated reactions
|
||||
db.refresh(envelope)
|
||||
|
||||
# Broadcast reaction update to both participants
|
||||
try:
|
||||
from .messaging import messagingManager
|
||||
await messagingManager.broadcast({
|
||||
"type": "dmReactionUpdate",
|
||||
"data": {
|
||||
"dm_envelope_id": request.dm_envelope_id,
|
||||
"emoji": request.emoji,
|
||||
"action": action,
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": convert_dm_envelope(envelope)["reactions"]
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
|
||||
|
||||
|
||||
class MessaggingSocketManager:
|
||||
def __init__(self) -> None:
|
||||
self.connections: list[WebSocket] = []
|
||||
@@ -670,6 +848,66 @@ class MessaggingSocketManager:
|
||||
"data": {"message_id": message_id}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "addReaction":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
request_data = data["data"]
|
||||
reaction_request = ReactionRequest(
|
||||
message_id=request_data["message_id"],
|
||||
emoji=request_data["emoji"]
|
||||
)
|
||||
|
||||
response = await add_reaction(reaction_request, current_user, db)
|
||||
|
||||
# Broadcast reaction update
|
||||
await self.broadcast({
|
||||
"type": "reactionUpdate",
|
||||
"data": {
|
||||
"message_id": request_data["message_id"],
|
||||
"emoji": request_data["emoji"],
|
||||
"action": response["action"],
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": response["reactions"]
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "addDmReaction":
|
||||
try:
|
||||
current_user = get_current_user_inner()
|
||||
if not current_user:
|
||||
raise HTTPException(401)
|
||||
|
||||
request_data = data["data"]
|
||||
reaction_request = DMReactionRequest(
|
||||
dm_envelope_id=request_data["dm_envelope_id"],
|
||||
emoji=request_data["emoji"]
|
||||
)
|
||||
|
||||
response = await add_dm_reaction(reaction_request, current_user, db)
|
||||
|
||||
# Broadcast reaction update
|
||||
await self.broadcast({
|
||||
"type": "dmReactionUpdate",
|
||||
"data": {
|
||||
"dm_envelope_id": request_data["dm_envelope_id"],
|
||||
"emoji": request_data["emoji"],
|
||||
"action": response["action"],
|
||||
"user_id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"reactions": response["reactions"]
|
||||
}
|
||||
})
|
||||
|
||||
await websocket.send_json({"type": type, "data": response})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
|
||||
@@ -17,8 +17,6 @@ services:
|
||||
target: /app
|
||||
- action: rebuild
|
||||
path: ../backend/requirements.txt
|
||||
networks:
|
||||
- main
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -40,15 +38,7 @@ services:
|
||||
target: /server/server.js
|
||||
- action: rebuild
|
||||
path: package.json
|
||||
networks:
|
||||
- main
|
||||
- default
|
||||
|
||||
volumes:
|
||||
data:
|
||||
name: fromchat-data
|
||||
|
||||
networks:
|
||||
main:
|
||||
driver: bridge
|
||||
internal: true # isolate from the outside world
|
||||
@@ -11,6 +11,7 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
COPY frontend frontend
|
||||
RUN npm run frontend:build
|
||||
|
||||
|
||||
# 2. Build the static file server
|
||||
FROM node:24 AS server
|
||||
|
||||
@@ -23,6 +24,10 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
# 2.2. Copy the code
|
||||
COPY deployment/frontend/ .
|
||||
|
||||
# 2.3. Build
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# 3. Put it all together
|
||||
FROM node:24-slim
|
||||
|
||||
@@ -44,4 +49,4 @@ COPY --from=server --chown=app /server .
|
||||
|
||||
# 4. Final command
|
||||
ENV STATIC_FILE_PATH=/app
|
||||
ENTRYPOINT ["npm", "run", "start"]
|
||||
ENTRYPOINT ["npm", "run", "start:prod"]
|
||||
@@ -3,10 +3,18 @@
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
"start": "ts-node server.ts",
|
||||
"build": "tsc -b",
|
||||
"start:prod": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0",
|
||||
"ts-node": "^10.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,6 +1,6 @@
|
||||
import { app, BrowserWindow, Notification, ipcMain } from 'electron';
|
||||
import path from "node:path";
|
||||
import { NotificationShowOptions } from '../electron';
|
||||
import type { NotificationShowOptions } from '../electron.d.ts';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
@@ -13,7 +13,6 @@ app.whenReady().then(() => {
|
||||
preload: path.join(import.meta.dirname, "preload.mjs")
|
||||
},
|
||||
titleBarStyle: "hidden",
|
||||
...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
|
||||
trafficLightPosition: {
|
||||
x: 16 - 4,
|
||||
y: 16 - 4
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Loading...</title>
|
||||
<link rel="icon" href="./src/resources/images/logo.png" />
|
||||
<link rel="icon" href="./src/images/logo.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Headers } from "../core/types";
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {boolean} json - Whether to include JSON content type header
|
||||
* @returns {Headers} Headers object with authentication and content type
|
||||
*/
|
||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||
const headers: Headers = {};
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -1,9 +1,26 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { getAuthHeaders } from "./api";
|
||||
import { generateX25519KeyPair } from "../utils/crypto/asymmetric";
|
||||
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../utils/crypto/backup";
|
||||
import { b64, ub64 } from "../utils/utils";
|
||||
import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
|
||||
import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {boolean} json - Whether to include JSON content type header
|
||||
* @returns {Headers} Headers object with authentication and content type
|
||||
*/
|
||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||
const headers: Headers = {};
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
@@ -18,15 +35,12 @@ async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||
}
|
||||
|
||||
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({
|
||||
publicKey: b64(publicKey)
|
||||
} satisfies UploadPublicKeyRequest)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { getAuthHeaders } from "../auth/api";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/symmetric";
|
||||
import { randomBytes } from "../utils/crypto/kdf";
|
||||
import { getCurrentKeys } from "../auth/crypto";
|
||||
import { request } from "../core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "../core/types";
|
||||
import { b64, ub64 } from "../utils/utils";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { getCurrentKeys } from "./authApi";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
@@ -46,7 +46,13 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
export async function sendDMViaWebSocket(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintext: string,
|
||||
authToken: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
@@ -81,7 +87,13 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
export async function sendDmWithFiles(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintextJson: string,
|
||||
files: File[],
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAuthHeaders } from "../auth/api";
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import type { UserProfile } from "../core/types";
|
||||
import { getAuthHeaders } from "./authApi";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
@@ -33,7 +33,7 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
console.error("Error loading profile:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,10 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||
formData.append("profile_picture", file, "profile_picture.jpg");
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
@@ -57,7 +57,7 @@ export async function uploadProfilePicture(token: string, file: Blob): Promise<U
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
console.error("Upload error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -74,17 +74,17 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...getAuthHeaders(token),
|
||||
'Content-Type': 'application/json'
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(backendData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
console.error("Error updating profile:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -95,14 +95,14 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
|
||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
console.error("Error updating bio:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export async function fetchUserProfile(token: string, username: string): Promise
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
console.error("Error fetching user profile:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -1,8 +1,8 @@
|
||||
import type { Dialog as MduiDialog } from "mdui/components/dialog";
|
||||
import { useEffect, type Ref } from "react"
|
||||
import { createPortal } from "react-dom";
|
||||
import { id } from "../../../utils/utils";
|
||||
import useCombinedRefs from "../../hooks/useCombinedRefs";
|
||||
import { id } from "@/utils/utils";
|
||||
import useCombinedRefs from "@/core/hooks/useCombinedRefs";
|
||||
|
||||
export interface BaseDialogProps {
|
||||
onOpenChange: (value: boolean) => void;
|
||||
@@ -12,7 +12,9 @@ export interface BaseDialogProps {
|
||||
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
|
||||
|
||||
export function MaterialDialog(props: FullDialogProps) {
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
|
||||
const { open, onOpenChange } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
@@ -22,8 +24,8 @@ export function MaterialDialog(props: FullDialogProps) {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === "attributes" && mutation.attributeName === "open") {
|
||||
const isOpen = dialog.hasAttribute("open");
|
||||
if (isOpen !== props.open) {
|
||||
props.onOpenChange(isOpen);
|
||||
if (isOpen !== open) {
|
||||
onOpenChange(isOpen);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -39,7 +41,8 @@ export function MaterialDialog(props: FullDialogProps) {
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [dialogRef.current, props.open, props.onOpenChange]);
|
||||
}, [open, onOpenChange, dialogRef]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
|
||||
}
|
||||
+11
-13
@@ -20,18 +20,18 @@ export function RichTextArea({
|
||||
placeholder,
|
||||
className,
|
||||
rows = 1,
|
||||
autoComplete = "off",
|
||||
autoComplete = "off"
|
||||
}: RichTextAreaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const heightRef = useRef<number | null>(null);
|
||||
|
||||
const getStyleValue = (computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number => {
|
||||
const raw = (computedStyle as any)[prop] as string | number | undefined;
|
||||
if (raw == null) return 0;
|
||||
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
|
||||
const raw = computedStyle[prop] as string | number | undefined;
|
||||
if (raw === null) return 0;
|
||||
const str = String(raw);
|
||||
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
|
||||
};
|
||||
}
|
||||
|
||||
const calculateTextareaStyles = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
@@ -116,9 +116,7 @@ export function RichTextArea({
|
||||
textarea.style.overflowY = overflowing ? "hidden" : "";
|
||||
}, [calculateTextareaStyles]);
|
||||
|
||||
const useEnhancedEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||
|
||||
useEnhancedEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
syncHeight();
|
||||
}, [syncHeight, text]);
|
||||
|
||||
@@ -142,13 +140,13 @@ export function RichTextArea({
|
||||
};
|
||||
}, [syncHeight]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
// Keep height responsive during rapid uncontrolled input bursts
|
||||
syncHeight();
|
||||
onTextChange(e.target.value);
|
||||
};
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
const isCtrlEnter = e.key === "Enter" && (e.ctrlKey || e.metaKey);
|
||||
const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
|
||||
|
||||
@@ -175,7 +173,7 @@ export function RichTextArea({
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -204,7 +202,7 @@ export function RichTextArea({
|
||||
height: "auto",
|
||||
minHeight: 0,
|
||||
maxHeight: "none",
|
||||
overflow: "hidden",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
+5
-3
@@ -3,7 +3,9 @@ import type { TextField } from "mdui/components/text-field";
|
||||
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
|
||||
|
||||
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
|
||||
return <mdui-text-field
|
||||
autocomplete="off"
|
||||
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
|
||||
return (
|
||||
<mdui-text-field
|
||||
autocomplete="off"
|
||||
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -10,6 +10,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setShouldRender(true);
|
||||
setIsAnimating(true);
|
||||
// Wait for content to render, then measure
|
||||
@@ -33,6 +34,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
|
||||
requestAnimationFrame(() => {
|
||||
// Read layout to ensure the previous height assignment is flushed
|
||||
if (containerRef.current) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
containerRef.current.offsetHeight;
|
||||
}
|
||||
// Use a second frame to ensure the measured pixel height is applied before collapsing
|
||||
@@ -50,7 +52,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
|
||||
}
|
||||
}, duration * 1000);
|
||||
}
|
||||
}, [visible, shouldRender]);
|
||||
}, [visible, shouldRender, duration, onFinish]);
|
||||
|
||||
return (visible || shouldRender || isAnimating) && (
|
||||
<div
|
||||
+3
-1
@@ -7,6 +7,7 @@ export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, chi
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setShouldRender(true);
|
||||
setOpacity(0);
|
||||
|
||||
@@ -36,6 +37,7 @@ export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, chi
|
||||
transition: `opacity ${duration}s ease`,
|
||||
...props.style
|
||||
}}
|
||||
>{children}</div>
|
||||
>{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface BaseAnimatedPropertyProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
visible: any;
|
||||
duration?: number;
|
||||
onFinish?: () => void
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@use "common/material" as *;
|
||||
@use "../../css/material" as *;
|
||||
|
||||
#electron-title-bar {
|
||||
display: none;
|
||||
@@ -5,7 +5,9 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
|
||||
import "./electron.scss";
|
||||
|
||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface !== undefined;
|
||||
|
||||
if (isElectron) {
|
||||
console.log("Running in Electron");
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
|
||||
import { useRef, useCallback, type RefCallback, type Ref } from "react";
|
||||
|
||||
// Определяем тип для ref, который может быть либо функцией, либо объектом
|
||||
type PossibleRef<T> = Ref<T> | undefined;
|
||||
@@ -14,7 +14,7 @@ export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallb
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
if (typeof ref === "function") {
|
||||
// Если ref - это функция, вызываем её
|
||||
ref(node);
|
||||
} else {
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { MINIMUM_WIDTH } from "../config";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
export default function useDownloadAppScreen() {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < MINIMUM_WIDTH;
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
navigate: isMobile ? <Navigate to="/download-app" replace /> : null
|
||||
};
|
||||
}
|
||||
+14
-25
@@ -1,7 +1,8 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { isElectron } from "../electron/electron";
|
||||
import { websocket } from "../core/websocket";
|
||||
import type { NewMessageWebSocketMessage, WebSocketMessage } from "../core/types";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import type { Message, NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
|
||||
import serviceWorker from "./service-worker?worker&url";
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
@@ -17,7 +18,6 @@ export interface NotificationPayload {
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Global state
|
||||
@@ -103,7 +103,7 @@ async function sendSubscriptionToServer(token: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function showMessageNotification(message: any): Promise<void> {
|
||||
async function showMessageNotification(message: Message): Promise<void> {
|
||||
try {
|
||||
await showNotification({
|
||||
title: `New message from ${message.username}`,
|
||||
@@ -111,20 +111,14 @@ async function showMessageNotification(message: any): Promise<void> {
|
||||
? message.content.substring(0, 100) + "..."
|
||||
: message.content,
|
||||
icon: message.profile_picture || "/logo.png",
|
||||
tag: `message_${message.id}`,
|
||||
data: {
|
||||
type: "public_message",
|
||||
message_id: message.id,
|
||||
sender_id: message.user_id,
|
||||
sender_username: message.username
|
||||
}
|
||||
tag: `message_${message.id}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show message notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
|
||||
async function handleWebSocketMessage(response: WebSocketMessage<object>): Promise<void> {
|
||||
// Handle notifications for new messages
|
||||
if (response.type === "newMessage" && response.data) {
|
||||
const newResponse = response as NewMessageWebSocketMessage;
|
||||
@@ -152,7 +146,7 @@ export async function initialize(): Promise<boolean> {
|
||||
}
|
||||
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register("/assets/serviceWorker.js");
|
||||
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
|
||||
console.log("Service Worker registered successfully");
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
@@ -188,12 +182,7 @@ export async function subscribe(token: string): Promise<boolean> {
|
||||
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
try {
|
||||
return await window.electronInterface.notifications.show({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
icon: payload.icon,
|
||||
tag: payload.tag
|
||||
});
|
||||
return await window.electronInterface.notifications.show(payload);
|
||||
} catch (error) {
|
||||
console.error("Failed to show Electron notification:", error);
|
||||
return false;
|
||||
@@ -243,14 +232,14 @@ export async function startElectronReceiver(): Promise<void> {
|
||||
// Add our own message listener to the existing WebSocket
|
||||
messageListener = (event: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(event.data);
|
||||
const response: WebSocketMessage<object> = JSON.parse(event.data);
|
||||
handleWebSocketMessage(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener('message', messageListener);
|
||||
websocket.addEventListener("message", messageListener);
|
||||
}
|
||||
|
||||
export function stopElectronReceiver(): void {
|
||||
@@ -262,7 +251,7 @@ export function stopElectronReceiver(): void {
|
||||
|
||||
// Remove our message listener
|
||||
if (messageListener) {
|
||||
websocket.removeEventListener('message', messageListener);
|
||||
websocket.removeEventListener("message", messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -8,7 +8,7 @@ interface NotificationPayload {
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
data?: object;
|
||||
}
|
||||
|
||||
interface NotificationAction {
|
||||
@@ -22,7 +22,7 @@ interface NotificationOptions {
|
||||
badge: string;
|
||||
image?: string;
|
||||
tag: string;
|
||||
data?: any;
|
||||
data?: object;
|
||||
actions: NotificationAction[];
|
||||
requireInteraction: boolean;
|
||||
silent: boolean;
|
||||
Vendored
+81
-11
@@ -51,6 +51,15 @@ export interface Rect extends Size2D {
|
||||
* @property {string} [profile_picture] - URL to sender's profile picture
|
||||
* @property {Message} [reply_to] - The message this is replying to
|
||||
*/
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
users: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -61,9 +70,19 @@ export interface Message {
|
||||
profile_picture?: string;
|
||||
reply_to?: Message;
|
||||
files?: Attachment[];
|
||||
reactions?: Reaction[];
|
||||
|
||||
runtimeData?: {
|
||||
dmEnvelope?: DmEnvelope;
|
||||
sendingState?: {
|
||||
status: "sending" | "sent" | "failed";
|
||||
tempId?: string; // Temporary ID for tracking until server confirms
|
||||
retryData?: {
|
||||
content: string;
|
||||
replyToId?: number;
|
||||
files?: File[];
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +212,7 @@ export interface DmEnvelope extends BaseDmEnvelope {
|
||||
senderId: number;
|
||||
files?: DmFile[];
|
||||
timestamp: string;
|
||||
reactions?: Reaction[];
|
||||
}
|
||||
|
||||
export interface DmFile {
|
||||
@@ -219,7 +239,7 @@ export interface FetchDMResponse {
|
||||
}
|
||||
|
||||
export interface DmEncryptedJSON {
|
||||
type: "text",
|
||||
type: "text";
|
||||
data: {
|
||||
content: string;
|
||||
reply_to_id?: number;
|
||||
@@ -290,13 +310,13 @@ export interface DMEditPayload {
|
||||
|
||||
// Requests
|
||||
export interface DMEditRequest extends WebSocketMessage {
|
||||
type: "dmEdit",
|
||||
type: "dmEdit";
|
||||
credentials: WebSocketCredentials;
|
||||
data: DMEditPayload
|
||||
}
|
||||
|
||||
export interface SendMessageRequest extends WebSocketMessage {
|
||||
type: "sendMessage",
|
||||
type: "sendMessage";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
content: string;
|
||||
@@ -304,44 +324,94 @@ export interface SendMessageRequest extends WebSocketMessage {
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddReactionRequest extends WebSocketMessage {
|
||||
type: "addReaction";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
message_id: number;
|
||||
emoji: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddDmReactionRequest extends WebSocketMessage {
|
||||
type: "addDmReaction";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
dm_envelope_id: number;
|
||||
emoji: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
export interface DMNewWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmNew",
|
||||
type: "dmNew";
|
||||
data: DmEnvelope
|
||||
}
|
||||
|
||||
export interface DMEditedWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmEdited",
|
||||
type: "dmEdited";
|
||||
data: DMEditPayload
|
||||
}
|
||||
|
||||
export interface DMDeletedWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmDeleted",
|
||||
type: "dmDeleted";
|
||||
data: {
|
||||
id: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageEditedWebSocketMessage extends WebSocketMessage {
|
||||
type: "messageEdited",
|
||||
type: "messageEdited";
|
||||
data: Partial<Message> & { id: number }
|
||||
}
|
||||
|
||||
export interface MessageDeletedWebSocketMessage extends WebSocketMessage {
|
||||
type: "messageDeleted",
|
||||
type: "messageDeleted";
|
||||
data: {
|
||||
message_id: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewMessageWebSocketMessage extends WebSocketMessage {
|
||||
type: "newMessage",
|
||||
type: "newMessage";
|
||||
data: Message
|
||||
}
|
||||
|
||||
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "reactionUpdate";
|
||||
data: {
|
||||
message_id: number;
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
user_id: number;
|
||||
username: string;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmReactionUpdate",
|
||||
data: {
|
||||
dm_envelope_id: number;
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
user_id: number;
|
||||
username: string;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
}
|
||||
|
||||
// Shared types
|
||||
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage
|
||||
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage
|
||||
export type DMWebSocketMessage =
|
||||
DMNewWebSocketMessage |
|
||||
DMEditedWebSocketMessage |
|
||||
DMDeletedWebSocketMessage |
|
||||
DMReactionUpdateWebSocketMessage;
|
||||
export type ChatWebSocketMessage =
|
||||
MessageEditedWebSocketMessage |
|
||||
MessageDeletedWebSocketMessage |
|
||||
NewMessageWebSocketMessage |
|
||||
ReactionUpdateWebSocketMessage;
|
||||
|
||||
// -----------
|
||||
// Encrypted message JSON (plaintext structure before encryption)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { API_WS_BASE_URL } from "./config";
|
||||
import type { WebSocketMessage } from "./types";
|
||||
import { delay } from "../utils/utils";
|
||||
import { delay } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Creates a new WebSocket connection to the chat server
|
||||
@@ -33,17 +33,17 @@ export let websocket: WebSocket = create();
|
||||
* Global WebSocket message handler reference
|
||||
* This will be set by the active panel to handle incoming messages
|
||||
*/
|
||||
let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null;
|
||||
let globalMessageHandler: ((response: WebSocketMessage<object>) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Set the global WebSocket message handler
|
||||
* @param handler - Function to handle WebSocket messages
|
||||
*/
|
||||
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
|
||||
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<object>) => void) | null): void {
|
||||
globalMessageHandler = handler;
|
||||
}
|
||||
|
||||
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
export function request<Request, Response = object>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
console.log("WebSocket request:", payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
function requestInner() {
|
||||
@@ -58,7 +58,7 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
}
|
||||
|
||||
if (websocket.readyState == 0) {
|
||||
if (websocket.readyState === 0) {
|
||||
websocket.addEventListener("open", requestInner);
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
} else {
|
||||
@@ -79,10 +79,10 @@ async function onError() {
|
||||
await delay(3000);
|
||||
websocket = create();
|
||||
|
||||
let listener: () => void | null;
|
||||
let listener: (() => void) | null = null;
|
||||
listener = () => {
|
||||
console.log("WebSocket successfully reconnected!");
|
||||
websocket.removeEventListener("open", listener);
|
||||
websocket.removeEventListener("open", listener!);
|
||||
}
|
||||
|
||||
websocket.addEventListener("open", listener);
|
||||
@@ -95,7 +95,7 @@ async function onError() {
|
||||
|
||||
websocket.addEventListener("message", (e) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(e.data);
|
||||
const response: WebSocketMessage<object> = JSON.parse(e.data);
|
||||
|
||||
// Route message to global handler if set
|
||||
if (globalMessageHandler) {
|
||||
|
||||
-66
@@ -30,72 +30,6 @@ button, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
min-width: 150px;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: $color-dark-on-surface;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.pos-top-left {
|
||||
transform-origin: top right;
|
||||
}
|
||||
&.pos-top-right {
|
||||
transform-origin: top left;
|
||||
}
|
||||
&.pos-bottom-left {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
&.pos-bottom-right {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
&.open {
|
||||
animation: context-menu-open 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes context-menu-open {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog content styles
|
||||
.dialog-content {
|
||||
h3 {
|
||||
@@ -1,18 +1,10 @@
|
||||
@use "auth";
|
||||
@use "chat";
|
||||
@use "profile";
|
||||
@use "settings";
|
||||
@use "panelchat";
|
||||
@use "common/animations";
|
||||
@use "common/components";
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
@use "electron";
|
||||
@use "dialogs/reply";
|
||||
@use "download-app";
|
||||
@use "animations";
|
||||
@use "components";
|
||||
@use "colors" as *;
|
||||
@use "material" as *;
|
||||
|
||||
@use "lib/fonts/montserrat";
|
||||
@use "lib/fonts/material-symbols";
|
||||
@use "fonts/montserrat";
|
||||
@use "fonts/material-symbols";
|
||||
|
||||
|
||||
* {
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -5,17 +5,15 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import './resources/css/style.scss';
|
||||
import "mdui/mdui.css";
|
||||
import "./css/style.scss";
|
||||
|
||||
import "./utils/material";
|
||||
import "./core/init";
|
||||
import "./electron/electron";
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './ui/App';
|
||||
import { StrictMode } from 'react';
|
||||
import "./core/electron/electron";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { StrictMode } from "react";
|
||||
|
||||
// Initialize React app
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppState } from "./chat/state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { user } = useAppState();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.authToken) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
}, [user.authToken, user.currentUser, navigate]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export interface AuthHeaderProps {
|
||||
}
|
||||
|
||||
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
|
||||
const iconType = typeof icon == "string" ? "filled" : icon.type;
|
||||
const iconName = typeof icon == "string" ? icon : icon.name;
|
||||
const iconType = typeof icon === "string" ? "filled" : icon.type;
|
||||
const iconName = typeof icon === "string" ? icon : icon.name;
|
||||
|
||||
return (
|
||||
<div className="auth-header">
|
||||
@@ -37,3 +37,20 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type AlertType = "success" | "danger"
|
||||
|
||||
export interface Alert {
|
||||
type: AlertType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
||||
return (
|
||||
<div>
|
||||
{alerts.slice(-3).map((alert, i) => {
|
||||
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,35 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
|
||||
import { AuthContainer, AuthHeader } from "../components/Auth";
|
||||
import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types";
|
||||
import { ensureKeysOnLogin } from "../../auth/crypto";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "./Auth";
|
||||
import { AuthContainer, AuthHeader } from "./Auth";
|
||||
import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types";
|
||||
import { ensureKeysOnLogin } from "@/core/api/authApi";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { useRef } from "react";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import { useAppState } from "../state";
|
||||
import { MaterialTextField } from "../components/core/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/push-notifications";
|
||||
import { isElectron } from "../../electron/electron";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialTextField } from "@/core/components/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./auth.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
|
||||
export default function LoginScreen() {
|
||||
export default function LoginPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setCurrentPage = useAppState(state => state.setCurrentPage);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => {
|
||||
alerts.push({ type: type, message: message });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
|
||||
@@ -45,12 +52,12 @@ export default function LoginScreen() {
|
||||
const request: LoginRequest = {
|
||||
username: username,
|
||||
password: password
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
@@ -67,7 +74,7 @@ export default function LoginScreen() {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
setCurrentPage("chat");
|
||||
navigate("/chat");
|
||||
|
||||
// Initialize notifications
|
||||
try {
|
||||
@@ -95,7 +102,7 @@ export default function LoginScreen() {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
@@ -130,7 +137,7 @@ export default function LoginScreen() {
|
||||
<a
|
||||
href="#"
|
||||
className="link"
|
||||
onClick={() => setCurrentPage("register")}>
|
||||
onClick={() => navigate("/register")}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
+38
-23
@@ -1,27 +1,34 @@
|
||||
import { useImmer } from "use-immer";
|
||||
// import { showLogin } from "../../navigation";
|
||||
import { AuthContainer, AuthHeader } from "../components/Auth";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
|
||||
import { AuthContainer, AuthHeader } from "./Auth";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "./Auth";
|
||||
import { useRef } from "react";
|
||||
import { TextField } from "mdui/components/text-field";
|
||||
import type { ErrorResponse, RegisterRequest } from "../../core/types";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { delay } from "../../utils/utils";
|
||||
import { useAppState } from "../state";
|
||||
import { MaterialTextField } from "../components/core/TextField";
|
||||
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MaterialTextField } from "@/core/components/TextField";
|
||||
import { ensureKeysOnLogin } from "@/core/api/authApi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./auth.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
export default function RegisterPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setCurrentPage = useAppState(state => state.setCurrentPage);
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
const confirmPasswordElement = useRef<TextField>(null);
|
||||
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => {
|
||||
alerts.push({ type: type, message: message });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
|
||||
@@ -63,23 +70,31 @@ export default function RegisterScreen() {
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Registration successful
|
||||
showAlert("success", "Регистрация прошла успешно! Теперь вы можете войти.");
|
||||
await delay(2000);
|
||||
setCurrentPage("login");
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Ошибка при регистрации");
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
@@ -127,7 +142,7 @@ export default function RegisterScreen() {
|
||||
href="#"
|
||||
id="login-link"
|
||||
className="link"
|
||||
onClick={() => setCurrentPage("login")}>
|
||||
onClick={() => navigate("/login")}>
|
||||
Войдите
|
||||
</a>
|
||||
</p>
|
||||
@@ -1,5 +1,5 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
@use "../../css/colors" as *;
|
||||
@use "../../css/material" as *;
|
||||
|
||||
.auth-container {
|
||||
display: flex;
|
||||
@@ -0,0 +1,107 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Animation for reactions appearing/disappearing
|
||||
@keyframes messageReactionsFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reactionFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reactionFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu wrapper animations
|
||||
@keyframes contextMenuEnter {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterLeft {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuEnterUpLeft {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuClose {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseLeft {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseUp {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contextMenuCloseUpLeft {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) translateY(20px) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiMenuEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
margin: 0 10px 10px 10px;
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
background: $color-dark-surface-container;
|
||||
border-radius: 30px;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.contextual-preview {
|
||||
padding: 12px 16px 0 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
mdui-icon {
|
||||
align-self: center;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.reply-cancel {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments-preview {
|
||||
align-items: center;
|
||||
|
||||
.attachments-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.buttons, .left-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left-buttons {
|
||||
.emoji-btn {
|
||||
margin: 10px;
|
||||
color: $color-dark-on-surface-variant;
|
||||
transition: color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
|
||||
&:hover {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
caret-color: $color-dark-primary;
|
||||
color: $color-dark-on-surface;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font: inherit;
|
||||
font-size: 13pt;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: $color-dark-on-surface-variant;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
.send-btn {
|
||||
margin: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%));
|
||||
color: $color-dark-on-primary;
|
||||
border: 1px solid rgba($color-dark-primary, 0.5);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.25s ease;
|
||||
align-self: flex-end;
|
||||
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 30px rgba($color-dark-primary, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji Menu Styles
|
||||
.emoji-menu {
|
||||
$transition: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
background: $color-dark-surface-container;
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform-origin: bottom left;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: transform 0.25s $transition, opacity 0.25s $transition;
|
||||
user-select: none;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.emoji-menu-header {
|
||||
background: $color-dark-surface-container-high;
|
||||
border-bottom: 1px solid rgba($color-dark-outline-variant, 0.2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
.emoji-category-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
padding: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
}
|
||||
|
||||
.emoji-category-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 1.2rem;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background: $color-dark-surface-container;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $color-dark-primary-container;
|
||||
color: $color-dark-on-primary-container;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: $color-dark-primary-container;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.emoji-category-section {
|
||||
.emoji-category-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $color-dark-on-surface-variant;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.emoji-category-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
$size: 30px;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: $size;
|
||||
width: $size;
|
||||
height: $size;
|
||||
box-sizing: content-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: $color-dark-surface-container-high;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-empty-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
// Integrated mode styles (inside reaction bar)
|
||||
&.integrated {
|
||||
position: relative !important;
|
||||
width: 320px !important;
|
||||
height: 400px !important;
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: $color-dark-surface-container;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Reaction bar styles (standalone)
|
||||
.reaction-bar {
|
||||
background: $color-dark-surface-container;
|
||||
border: 1px solid $color-dark-outline;
|
||||
border-radius: 24px;
|
||||
padding: 8px;
|
||||
opacity: 1;
|
||||
transition: all 0.15s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
transform: translateY(0);
|
||||
|
||||
&.closing {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji menu wrapper inside reaction bar
|
||||
.emoji-menu-wrapper {
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// Context menu wrapper with animations
|
||||
.context-menu-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
// Animation states
|
||||
&.entering {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
animation: contextMenuEnter 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-left {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) scale(0.8);
|
||||
animation: contextMenuEnterLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
animation: contextMenuEnterUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up-left {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) translateY(20px) scale(0.8);
|
||||
animation: contextMenuEnterUpLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
animation: contextMenuClose 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-left {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
animation: contextMenuCloseLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
animation: contextMenuCloseUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up-left {
|
||||
opacity: 1;
|
||||
transform: translateX(0) translateY(0) scale(1);
|
||||
animation: contextMenuCloseUpLeft 0.2s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
background: $color-dark-surface;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem 0;
|
||||
min-width: 160px;
|
||||
z-index: 1000;
|
||||
|
||||
&.entering {
|
||||
animation: fadeInDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-left {
|
||||
animation: fadeInLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up {
|
||||
animation: fadeInUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up-left {
|
||||
animation: fadeInUpLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
animation: fadeOutUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-left {
|
||||
animation: fadeOutRight 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up {
|
||||
animation: fadeOutDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up-left {
|
||||
animation: fadeOutDownRight 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-dark-surface-container;
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 1.1rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reaction bar inside context menu wrapper
|
||||
.context-menu-reaction-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: $color-dark-surface-container;
|
||||
border: 1px solid $color-dark-outline;
|
||||
border-radius: 16px;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
transition: width 0.3s ease-out, height 0.3s ease-out;
|
||||
|
||||
&.left {
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&.right {
|
||||
right: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
border-radius: 16px;
|
||||
|
||||
// Default: expand downward from the reaction bar's bottom edge
|
||||
position: absolute;
|
||||
bottom: auto;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateY(0);
|
||||
|
||||
&.expand-upward {
|
||||
// Expand upward from the reaction bar's top edge
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.emoji-menu-wrapper {
|
||||
animation: emojiMenuEnter 0.5s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-bar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.faded {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-emoji-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
font-size: 18px;
|
||||
|
||||
&:hover {
|
||||
background: var(--mdui-color-surface-container-high);
|
||||
transform: scale(1.3);
|
||||
box-shadow: var(--mdui-elevation-1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-expand-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--mdui-color-outline);
|
||||
border-radius: 16px;
|
||||
background: var(--mdui-color-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
&:hover {
|
||||
background: var(--mdui-color-surface-container-high);
|
||||
border-color: var(--mdui-color-primary);
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--mdui-elevation-1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 18px;
|
||||
color: var(--mdui-color-on-surface);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .material-symbols {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
+92
-8
@@ -1,13 +1,56 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
|
||||
// контейнер чата и панели с чатами
|
||||
.all-container {
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $color-dark-surface-container;
|
||||
color: white;
|
||||
padding: 16px 16px;
|
||||
justify-content: end;
|
||||
width: fit-content;
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#logouts {
|
||||
display: none;
|
||||
list-style: none;
|
||||
gap: 10px;
|
||||
|
||||
li {
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#profile {
|
||||
@@ -148,3 +191,44 @@
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Profile styles
|
||||
#profile-dialog .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/material" as *;
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Settings styles
|
||||
#settings-dialog {
|
||||
.fullscreen-wrapper {
|
||||
display: flex;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Import all component-specific styles
|
||||
@use "layout";
|
||||
@use "left-panel";
|
||||
@use "right-panel";
|
||||
@use "message";
|
||||
@use "chat-input";
|
||||
@use "message-reactions";
|
||||
@use "context-menu";
|
||||
@use "profile-dialog";
|
||||
@use "settings-dialog";
|
||||
@use "animations";
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppState } from "../state";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUserPublicKey,
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
} from "../../api/dmApi";
|
||||
import type { User, Message, DmEncryptedJSON } from "../../core/types";
|
||||
import { websocket } from "../../core/websocket";
|
||||
} from "../../../core/api/dmApi";
|
||||
import type { User, Message, DmEncryptedJSON } from "@/core/types";
|
||||
import { websocket } from "@/core/websocket";
|
||||
|
||||
interface DMUser extends User {
|
||||
export interface DMUser extends User {
|
||||
lastMessage?: string;
|
||||
unreadCount: number;
|
||||
publicKey?: string | null;
|
||||
@@ -101,7 +101,7 @@ export function useDM() {
|
||||
} finally {
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
}, [user.authToken, isLoadingUsers]);
|
||||
}, [user.authToken, isLoadingUsers, loadUserLastMessage, setDmUsers]);
|
||||
|
||||
// Reset users loaded flag when user changes
|
||||
useEffect(() => {
|
||||
@@ -198,7 +198,7 @@ export function useDM() {
|
||||
|
||||
// WebSocket message handler
|
||||
useEffect(() => {
|
||||
const handleWebSocketMessage = async (e: MessageEvent) => {
|
||||
async function handleWebSocketMessage(e: MessageEvent) {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "dmNew") {
|
||||
@@ -258,11 +258,12 @@ export function useDM() {
|
||||
} catch (error) {
|
||||
console.error("Failed to handle WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
websocket.addEventListener("message", handleWebSocketMessage);
|
||||
|
||||
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
||||
}, [chat.activeDm, user.currentUser, addMessage]);
|
||||
}, [chat.activeDm, user.currentUser, addMessage, user.authToken]);
|
||||
|
||||
// Force reload users (useful for refreshing the list)
|
||||
const reloadUsers = useCallback(() => {
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useAppState } from "../state";
|
||||
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../api/profileApi";
|
||||
import { showSuccess, showError } from "../../utils/notification";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/profileApi";
|
||||
import { showSuccess, showError } from "@/utils/notification";
|
||||
|
||||
export function useProfile() {
|
||||
export default function useProfile() {
|
||||
const { user } = useAppState();
|
||||
const [profileData, setProfileData] = useState<ProfileData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -20,8 +20,8 @@ export function useProfile() {
|
||||
setProfileData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
showError('Ошибка при загрузке профиля');
|
||||
console.error("Error loading profile:", error);
|
||||
showError("Ошибка при загрузке профиля");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -37,15 +37,15 @@ export function useProfile() {
|
||||
if (success) {
|
||||
// Reload profile data to get updated information
|
||||
await loadProfileData();
|
||||
showSuccess('Профиль обновлен!');
|
||||
showSuccess("Профиль обновлен!");
|
||||
return true;
|
||||
} else {
|
||||
showError('Ошибка при обновлении профиля');
|
||||
showError("Ошибка при обновлении профиля");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
showError('Ошибка при обновлении профиля');
|
||||
console.error("Error updating profile:", error);
|
||||
showError("Ошибка при обновлении профиля");
|
||||
return false;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
@@ -65,15 +65,15 @@ export function useProfile() {
|
||||
...prev,
|
||||
profile_picture: result.profile_picture_url
|
||||
} : null);
|
||||
showSuccess('Фото профиля обновлено!');
|
||||
showSuccess("Фото профиля обновлено!");
|
||||
return true;
|
||||
} else {
|
||||
showError('Ошибка при загрузке фото');
|
||||
showError("Ошибка при загрузке фото");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading profile picture:', error);
|
||||
showError('Ошибка при загрузке фото');
|
||||
console.error("Error uploading profile picture:", error);
|
||||
showError("Ошибка при загрузке фото");
|
||||
return false;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
@@ -1,16 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
import type { Message, User } from "../core/types";
|
||||
import { request } from "../core/websocket";
|
||||
import { MessagePanel } from "./panels/MessagePanel";
|
||||
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
||||
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
|
||||
import { getAuthHeaders } from "../auth/api";
|
||||
import { restoreKeys } from "../auth/crypto";
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { initialize, subscribe, startElectronReceiver, isSupported } from "../utils/push-notifications";
|
||||
import { isElectron } from "../electron/electron";
|
||||
import type { Message, User } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
import { MessagePanel } from "./ui/right/panels/MessagePanel";
|
||||
import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel";
|
||||
import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { restoreKeys } from "@/core/api/authApi";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
|
||||
type Page = "login" | "register" | "chat"
|
||||
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||
|
||||
interface ActiveDM {
|
||||
@@ -25,10 +24,12 @@ interface ChatState {
|
||||
activeTab: ChatTabs;
|
||||
dmUsers: User[];
|
||||
activeDm: ActiveDM | null;
|
||||
isChatSwitching: boolean;
|
||||
isSwitching: boolean;
|
||||
setIsSwitching: (value: boolean) => void;
|
||||
activePanel: MessagePanel | null;
|
||||
publicChatPanel: PublicChatPanel | null;
|
||||
dmPanel: DMPanel | null;
|
||||
pendingPanel?: MessagePanel | null;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
@@ -37,9 +38,6 @@ export interface UserState {
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
currentPage: Page;
|
||||
setCurrentPage: (page: Page) => void;
|
||||
|
||||
// Chat state
|
||||
chat: ChatState;
|
||||
addMessage: (message: Message) => void;
|
||||
@@ -50,23 +48,20 @@ interface AppState {
|
||||
setDmUsers: (users: User[]) => void;
|
||||
setActiveDm: (dm: ChatState["activeDm"]) => void;
|
||||
clearMessages: () => void;
|
||||
setIsChatSwitching: (value: boolean) => void;
|
||||
setActivePanel: (panel: MessagePanel | null) => void;
|
||||
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||
applyPendingPanel: () => void;
|
||||
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||
switchToTab: (tab: ChatTabs) => Promise<void>;
|
||||
|
||||
// User state
|
||||
user: UserState;
|
||||
setUser: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
restoreUserFromStorage: () => void;
|
||||
restoreUserFromStorage: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppState = create<AppState>((set, get) => ({
|
||||
currentPage: "login", // default page
|
||||
setCurrentPage: (page: Page) => set({ currentPage: page }),
|
||||
|
||||
// Chat state
|
||||
chat: {
|
||||
messages: [],
|
||||
@@ -74,17 +69,18 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
activeTab: "chats",
|
||||
dmUsers: [],
|
||||
activeDm: null,
|
||||
isChatSwitching: false,
|
||||
isSwitching: false,
|
||||
setIsSwitching: (value: boolean) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
isSwitching: value
|
||||
}
|
||||
})),
|
||||
activePanel: null,
|
||||
publicChatPanel: null,
|
||||
dmPanel: null
|
||||
dmPanel: null,
|
||||
pendingPanel: null
|
||||
},
|
||||
setIsChatSwitching: (value: boolean) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
isChatSwitching: value
|
||||
}
|
||||
})),
|
||||
addMessage: (message: Message) => set((state) => {
|
||||
// Check if message already exists to prevent duplicates
|
||||
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||
@@ -159,10 +155,10 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
|
||||
// Store credentials in localStorage
|
||||
try {
|
||||
localStorage.setItem('authToken', token);
|
||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||
localStorage.setItem("authToken", token);
|
||||
localStorage.setItem("currentUser", JSON.stringify(user));
|
||||
} catch (error) {
|
||||
console.error('Failed to store credentials in localStorage:', error);
|
||||
console.error("Failed to store credentials in localStorage:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -181,23 +177,22 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
logout: () => {
|
||||
// Clear localStorage
|
||||
try {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
localStorage.removeItem("authToken");
|
||||
localStorage.removeItem("currentUser");
|
||||
} catch (error) {
|
||||
console.error('Failed to clear localStorage:', error);
|
||||
console.error("Failed to clear localStorage:", error);
|
||||
}
|
||||
|
||||
set(() => ({
|
||||
user: {
|
||||
currentUser: null,
|
||||
authToken: null
|
||||
},
|
||||
currentPage: "login"
|
||||
}
|
||||
}));
|
||||
},
|
||||
restoreUserFromStorage: async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
if (token) {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
@@ -212,8 +207,7 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
user: {
|
||||
currentUser: user,
|
||||
authToken: token
|
||||
},
|
||||
currentPage: "chat"
|
||||
}
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -250,10 +244,10 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore user from localStorage:', error);
|
||||
console.error("Failed to restore user from localStorage:", error);
|
||||
// Clear invalid data
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
localStorage.removeItem("authToken");
|
||||
localStorage.removeItem("currentUser");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -264,15 +258,38 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
activePanel: panel
|
||||
}
|
||||
})),
|
||||
// Stash a panel to be applied after switch-out animation ends
|
||||
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
pendingPanel: panel
|
||||
}
|
||||
})),
|
||||
// Apply pending panel atomically and update related fields
|
||||
applyPendingPanel: () => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||
// when switching to public chat, keep reference if type matches
|
||||
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||
? (state.chat.pendingPanel as PublicChatPanel)
|
||||
: state.chat.publicChatPanel,
|
||||
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||
? (state.chat.pendingPanel as DMPanel)
|
||||
: state.chat.dmPanel,
|
||||
// update currentChat from panel title if available
|
||||
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||
pendingPanel: null
|
||||
}
|
||||
})),
|
||||
|
||||
switchToPublicChat: async (chatName: string) => {
|
||||
const state = get();
|
||||
const { user, chat } = state;
|
||||
const { user, chat } = get();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
// Start chat switching animation
|
||||
state.setIsChatSwitching(true);
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
// Create or get public chat panel
|
||||
let publicChatPanel = chat.publicChatPanel;
|
||||
@@ -281,37 +298,33 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
} else {
|
||||
publicChatPanel.setChatName(chatName);
|
||||
publicChatPanel.setAuthToken(user.authToken);
|
||||
// Reset messages for the new chat
|
||||
publicChatPanel.clearMessages();
|
||||
}
|
||||
|
||||
// Wait for animation
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
|
||||
// Activate panel
|
||||
await publicChatPanel.activate();
|
||||
|
||||
// Update state
|
||||
// Defer panel swap until animation switch-out completes
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: publicChatPanel,
|
||||
publicChatPanel: publicChatPanel,
|
||||
currentChat: chatName,
|
||||
pendingPanel: publicChatPanel,
|
||||
activeTab: "chats"
|
||||
}
|
||||
}));
|
||||
|
||||
// End animation
|
||||
state.setIsChatSwitching(false);
|
||||
// Let MessagePanelRenderer handle the animation timing completely
|
||||
// It will set isChatSwitching to false when the fadeInDown animation completes
|
||||
},
|
||||
|
||||
switchToDM: async (dmData: DMPanelData) => {
|
||||
const state = get();
|
||||
const { user, chat } = state;
|
||||
const { user, chat } = get();
|
||||
|
||||
if (!user.authToken) return;
|
||||
|
||||
// Start chat switching animation
|
||||
state.setIsChatSwitching(true);
|
||||
chat.setIsSwitching(true);
|
||||
|
||||
// Create or get DM panel
|
||||
let dmPanel = chat.dmPanel;
|
||||
@@ -319,23 +332,21 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
dmPanel = new DMPanel(user);
|
||||
} else {
|
||||
dmPanel.setAuthToken(user.authToken);
|
||||
// Reset messages for the new DM
|
||||
dmPanel.clearMessages();
|
||||
}
|
||||
|
||||
// Set DM data
|
||||
dmPanel.setDMData(dmData);
|
||||
|
||||
// Wait for animation
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
|
||||
// Activate panel
|
||||
await dmPanel.activate();
|
||||
|
||||
// Update state
|
||||
// Defer panel swap until animation switch-out completes
|
||||
set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
activePanel: dmPanel,
|
||||
dmPanel: dmPanel,
|
||||
pendingPanel: dmPanel,
|
||||
activeDm: {
|
||||
userId: dmData.userId,
|
||||
username: dmData.username,
|
||||
@@ -345,19 +356,7 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// End animation
|
||||
state.setIsChatSwitching(false);
|
||||
},
|
||||
|
||||
switchToTab: async (tab: ChatTabs) => {
|
||||
const state = get();
|
||||
state.setActiveTab(tab);
|
||||
|
||||
if (tab === "chats") {
|
||||
await state.switchToPublicChat("Общий чат");
|
||||
} else if (tab === "dms") {
|
||||
// DM tab - no specific panel until user is selected
|
||||
state.setActivePanel(null);
|
||||
}
|
||||
// Let MessagePanelRenderer handle the animation timing completely
|
||||
// It will set isChatSwitching to false when the fadeInDown animation completes
|
||||
}
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { PRODUCT_NAME } from "../../../core/config";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import { PRODUCT_NAME } from "@/core/config";
|
||||
import useProfile from "@/pages/chat/hooks/useProfile";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { useState } from "react";
|
||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||
import { ProfileDialog } from "./profile/ProfileDialog";
|
||||
|
||||
export function ChatHeader() {
|
||||
const { profileData } = useProfile();
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDM } from "../../hooks/useDM";
|
||||
import { useAppState } from "../../state";
|
||||
import { fetchUserPublicKey } from "../../../api/dmApi";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
|
||||
export function DMUsersList() {
|
||||
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||
@@ -34,7 +34,7 @@ export function DMUsersList() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleUserClick = async (user: any) => {
|
||||
async function handleUserClick(user: DMUser) {
|
||||
if (!user.publicKey) {
|
||||
// Get public key if not already loaded
|
||||
const authToken = useAppState.getState().user.authToken;
|
||||
@@ -60,7 +60,7 @@ export function DMUsersList() {
|
||||
|
||||
return (
|
||||
<mdui-list>
|
||||
{dmUsers.map((user) => (
|
||||
{dmUsers.map((user: DMUser) => (
|
||||
<mdui-list-item
|
||||
key={user.id}
|
||||
headline={user.username}
|
||||
+17
-19
@@ -1,12 +1,12 @@
|
||||
import { PRODUCT_NAME } from "../../../core/config";
|
||||
import { useAppState } from "../../state";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import { PRODUCT_NAME } from "@/core/config";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||
import { SettingsDialog } from "../settings/SettingsDialog";
|
||||
import { ProfileDialog } from "./profile/ProfileDialog";
|
||||
import { SettingsDialog } from "./settings/SettingsDialog";
|
||||
import { DMUsersList } from "./DMUsersList";
|
||||
import type { Tabs } from "mdui";
|
||||
import type { ChatTabs } from "../../state";
|
||||
import type { ChatTabs as ChatTabsType } from "@/pages/chat/state";
|
||||
|
||||
function BottomAppBar() {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
@@ -19,16 +19,15 @@ function BottomAppBar() {
|
||||
return (
|
||||
<>
|
||||
<mdui-bottom-app-bar>
|
||||
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
|
||||
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
|
||||
<mdui-button-icon icon="group_add--filled" />
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
<mdui-button-icon
|
||||
icon="logout--filled"
|
||||
id="logout-btn"
|
||||
onClick={handleLogout}
|
||||
title="Выйти"
|
||||
></mdui-button-icon>
|
||||
<mdui-fab icon="edit--filled"></mdui-fab>
|
||||
title="Выйти" />
|
||||
<mdui-fab icon="edit--filled" />
|
||||
</mdui-bottom-app-bar>
|
||||
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
|
||||
</>
|
||||
@@ -37,17 +36,16 @@ function BottomAppBar() {
|
||||
|
||||
|
||||
function ChatTabs() {
|
||||
const { chat, switchToTab, switchToPublicChat } = useAppState();
|
||||
const { chat, setActiveTab, switchToPublicChat } = useAppState();
|
||||
const { activeTab } = chat;
|
||||
|
||||
const handleChatClick = async (chatName: string) => {
|
||||
async function handleChatClick(chatName: string) {
|
||||
await switchToPublicChat(chatName);
|
||||
};
|
||||
}
|
||||
|
||||
const handleTabChange = async (e: FormEvent<Tabs>) => {
|
||||
const tab = (e.target as Tabs).value as ChatTabs;
|
||||
await switchToTab(tab);
|
||||
};
|
||||
function handleTabChange(e: FormEvent<Tabs>) {
|
||||
setActiveTab((e.target as Tabs).value as ChatTabsType);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
+2
-2
@@ -4,10 +4,10 @@ export function CropperDialog() {
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
|
||||
<mdui-button-icon icon="close" id="cropper-close" />
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<div id="cropper-area"></div>
|
||||
<div id="cropper-area" />
|
||||
</div>
|
||||
<div className="cropper-actions">
|
||||
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
|
||||
+29
-27
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Size2D, Rect } from "../../../core/types";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Size2D, Rect } from "@/core/types";
|
||||
|
||||
interface ImageCropperProps {
|
||||
onCrop: (croppedImageData: string) => void;
|
||||
@@ -17,13 +17,13 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
|
||||
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const img = imageRef.current;
|
||||
|
||||
if (imageFile) {
|
||||
const reader = new FileReader();
|
||||
|
||||
function handleImageLoad() {
|
||||
setIsLoaded(true);
|
||||
// Initialize crop area to center of image
|
||||
const img = imageRef.current;
|
||||
if (img) {
|
||||
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
|
||||
setCropArea({
|
||||
@@ -36,9 +36,9 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
|
||||
}
|
||||
|
||||
function handleReaderLoad() {
|
||||
if (imageRef.current) {
|
||||
if (img) {
|
||||
setSrc(reader.result as string);
|
||||
imageRef.current.addEventListener("load", handleImageLoad);
|
||||
img.addEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
|
||||
return () => {
|
||||
reader.abort();
|
||||
reader.removeEventListener("load", handleReaderLoad);
|
||||
imageRef.current?.removeEventListener("load", handleImageLoad);
|
||||
img?.removeEventListener("load", handleImageLoad);
|
||||
}
|
||||
}
|
||||
}, [imageFile]);
|
||||
}, [imageFile, imageRef]);
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent) {
|
||||
if (!isLoaded) return;
|
||||
@@ -100,10 +100,11 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
|
||||
};
|
||||
|
||||
function handleCrop() {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
const img = imageRef.current;
|
||||
if (!canvasRef.current || !img || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to crop area
|
||||
@@ -112,47 +113,48 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
|
||||
|
||||
// Draw cropped portion
|
||||
ctx.drawImage(
|
||||
imageRef.current,
|
||||
img,
|
||||
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
|
||||
0, 0, cropArea.width, cropArea.height
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
const croppedImageData = canvas.toDataURL("image/jpeg", 0.9);
|
||||
onCrop(croppedImageData);
|
||||
};
|
||||
|
||||
function drawCropArea() {
|
||||
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
|
||||
const drawCropArea = useCallback(() => {
|
||||
const img = imageRef.current;
|
||||
if (!canvasRef.current || !img || !isLoaded) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw crop overlay
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Clear crop area
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
|
||||
// Draw crop border
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = "#fff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
|
||||
};
|
||||
}, [cropArea, isLoaded, imageRef]);
|
||||
|
||||
useEffect(() => {
|
||||
drawCropArea();
|
||||
}, [cropArea, isLoaded]);
|
||||
}, [cropArea, isLoaded, drawCropArea]);
|
||||
|
||||
if (!imageFile) return null;
|
||||
|
||||
@@ -163,10 +165,10 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
|
||||
width={400}
|
||||
height={400}
|
||||
style={{
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
border: '1px solid #ccc',
|
||||
maxWidth: '100%',
|
||||
height: 'auto'
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
border: "1px solid #ccc",
|
||||
maxWidth: "100%",
|
||||
height: "auto"
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -176,7 +178,7 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
style={{ display: 'none' }}
|
||||
style={{ display: "none" }}
|
||||
alt="Crop source"
|
||||
/>
|
||||
<div className="cropper-actions">
|
||||
+14
-13
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import useProfile from "@/pages/chat/hooks/useProfile";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
import { MaterialTextField } from "../core/TextField";
|
||||
import { MaterialTextField } from "@/core/components/TextField";
|
||||
|
||||
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
|
||||
@@ -20,6 +20,7 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
// Update form fields when profile data changes
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setUsername(profileData.nickname || "");
|
||||
setDescription(profileData.description || "");
|
||||
}
|
||||
@@ -40,7 +41,7 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
setSelectedImage(file);
|
||||
setShowCropper(true);
|
||||
}
|
||||
@@ -57,25 +58,25 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing cropped image:', error);
|
||||
console.error("Error processing cropped image:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
function handleCropCancel() {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleUploadClick = () => {
|
||||
function handleUploadClick() {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
}
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
+15
-22
@@ -1,31 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config";
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../utils/push-notifications";
|
||||
import { isElectron } from "../../../electron/electron";
|
||||
import { useAppState } from "../../state";
|
||||
import { useState, useMemo } from "react";
|
||||
import { PRODUCT_NAME, API_BASE_URL } from "@/core/config";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import type { Switch } from "mdui/components/switch";
|
||||
import { getAuthHeaders } from "../../../auth/api";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
|
||||
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const [activePanel, setActivePanel] = useState("notifications-settings");
|
||||
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
|
||||
const [pushSupported, setPushSupported] = useState(false);
|
||||
const pushSupported = useMemo(() => isSupported(), []);
|
||||
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(pushSupported);
|
||||
const user = useAppState(state => state.user);
|
||||
|
||||
useEffect(() => {
|
||||
setPushSupported(isSupported());
|
||||
// For Electron, we assume notifications are enabled if supported
|
||||
// For web browsers, we check if there's a subscription
|
||||
setPushNotificationsEnabled(isSupported());
|
||||
}, []);
|
||||
|
||||
const handlePanelChange = (panelId: string) => {
|
||||
function handlePanelChange(panelId: string) {
|
||||
setActivePanel(panelId);
|
||||
};
|
||||
}
|
||||
|
||||
const handlePushNotificationToggle = async (enabled: boolean) => {
|
||||
async function handlePushNotificationToggle(enabled: boolean) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
@@ -66,7 +59,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
<div className="fullscreen-wrapper">
|
||||
<div id="settings-dialog-inner">
|
||||
<div className="header">
|
||||
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></mdui-button-icon>
|
||||
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)} />
|
||||
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
|
||||
</div>
|
||||
<div id="settings-menu">
|
||||
@@ -185,7 +178,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
|
||||
<h3>Хранилище</h3>
|
||||
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
|
||||
<mdui-linear-progress value={25}></mdui-linear-progress>
|
||||
<mdui-linear-progress value={25} />
|
||||
<mdui-button variant="outlined">Очистить кэш</mdui-button>
|
||||
</div>
|
||||
|
||||
+74
-21
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { RichTextArea } from "../core/RichTextArea";
|
||||
import type { Message } from "../../../core/types";
|
||||
import Quote from "../core/Quote";
|
||||
import AnimatedHeight from "../core/animations/AnimatedHeight";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||
import type { Message } from "@/core/types";
|
||||
import Quote from "@/core/components/Quote";
|
||||
import AnimatedHeight from "@/core/components/animations/AnimatedHeight";
|
||||
import { useImmer } from "use-immer";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage: (message: string, files: File[]) => void;
|
||||
@@ -18,6 +19,7 @@ interface ChatInputWrapperProps {
|
||||
onClearEdit?: () => void;
|
||||
onCloseEdit?: () => void;
|
||||
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
|
||||
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper(
|
||||
@@ -32,24 +34,30 @@ export function ChatInputWrapper(
|
||||
editVisible = false,
|
||||
onClearEdit,
|
||||
onCloseEdit,
|
||||
onProvideFileAdder
|
||||
onProvideFileAdder,
|
||||
messagePanelRef
|
||||
}: ChatInputWrapperProps
|
||||
) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
|
||||
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
|
||||
const [errorOpen, setErrorOpen] = useState(false);
|
||||
const [emojiMenuOpen, setEmojiMenuOpen] = useState(false);
|
||||
const [emojiMenuPosition, setEmojiMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const chatInputWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Expose a way for parent to programmatically add files
|
||||
useEffect(() => {
|
||||
if (onProvideFileAdder) {
|
||||
const addFiles = (files: File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setSelectedFiles(draft => { draft.push(...files) });
|
||||
setSelectedFiles(draft => {
|
||||
draft.push(...files)
|
||||
});
|
||||
};
|
||||
onProvideFileAdder(addFiles);
|
||||
}
|
||||
}, [onProvideFileAdder]);
|
||||
}, [onProvideFileAdder, setSelectedFiles]);
|
||||
|
||||
// When entering edit mode, preload the message content
|
||||
useEffect(() => {
|
||||
@@ -60,7 +68,32 @@ export function ChatInputWrapper(
|
||||
setAttachmentsVisible(selectedFiles.length > 0);
|
||||
}, [selectedFiles]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent | Event) => {
|
||||
function handleEmojiButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!emojiMenuOpen) {
|
||||
if (chatInputWrapperRef.current && messagePanelRef?.current) {
|
||||
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
|
||||
const panelRect = messagePanelRef.current.getBoundingClientRect();
|
||||
|
||||
// Position menu 10px from message panel edge and 10px above the chat input
|
||||
// The animation will start 30px below this position
|
||||
setEmojiMenuPosition({
|
||||
x: panelRect.left + 10, // 10px from message panel edge
|
||||
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
|
||||
});
|
||||
setEmojiMenuOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmojiMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
setMessage(prev => prev + emoji);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent | Event) {
|
||||
e.preventDefault();
|
||||
const hasText = Boolean(message.trim());
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
@@ -82,20 +115,22 @@ export function ChatInputWrapper(
|
||||
if (onClearReply) onClearReply();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleAttachClick() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.addEventListener("change", () => {
|
||||
setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) });
|
||||
setSelectedFiles(draft => {
|
||||
draft.push(...Array.from(input.files || []))
|
||||
});
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-wrapper" ref={chatInputWrapperRef}>
|
||||
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
|
||||
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}>
|
||||
{editingMessage && (
|
||||
@@ -105,7 +140,7 @@ export function ChatInputWrapper(
|
||||
<span className="reply-username">{editingMessage!.username}</span>
|
||||
<span className="reply-text">{editingMessage!.content}</span>
|
||||
</Quote>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit} />
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
@@ -117,7 +152,7 @@ export function ChatInputWrapper(
|
||||
<span className="reply-username">{replyTo!.username}</span>
|
||||
<span className="reply-text">{replyTo!.content}</span>
|
||||
</Quote>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply} />
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
@@ -131,25 +166,35 @@ export function ChatInputWrapper(
|
||||
key={i}
|
||||
variant="input"
|
||||
end-icon="close"
|
||||
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
|
||||
title={`${file.name} (${Math.round(file.size / 1024 / 1024)} MB)`}
|
||||
onClick={() => {
|
||||
if (selectedFiles.length == 1) {
|
||||
if (selectedFiles.length === 1) {
|
||||
setAttachmentsVisible(false);
|
||||
} else {
|
||||
setSelectedFiles(draft => { draft.splice(i) })
|
||||
setSelectedFiles(draft => {
|
||||
draft.splice(i);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
|
||||
<mdui-icon slot="icon" name="attach_file" />
|
||||
<span className="name">{file.name}</span>
|
||||
</mdui-chip>
|
||||
))}
|
||||
</div>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
|
||||
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)} />
|
||||
</div>
|
||||
)}
|
||||
</AnimatedHeight>
|
||||
<div className="chat-input">
|
||||
<div className="left-buttons">
|
||||
<mdui-button-icon
|
||||
icon="mood"
|
||||
onClick={handleEmojiButtonClick}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onMouseUp={e => e.stopPropagation()}
|
||||
className="emoji-btn" />
|
||||
</div>
|
||||
<RichTextArea
|
||||
className="message-input"
|
||||
id="message-input"
|
||||
@@ -160,7 +205,7 @@ export function ChatInputWrapper(
|
||||
onTextChange={(value) => setMessage(value)}
|
||||
onEnter={handleSubmit} />
|
||||
<div className="buttons">
|
||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn" />
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
|
||||
</button>
|
||||
@@ -172,6 +217,14 @@ export function ChatInputWrapper(
|
||||
<div>Общий размер вложений превышает 4 ГБ.</div>
|
||||
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
|
||||
</MaterialDialog>
|
||||
|
||||
<EmojiMenu
|
||||
isOpen={emojiMenuOpen}
|
||||
onClose={() => setEmojiMenuOpen(false)}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
position={emojiMenuPosition}
|
||||
mode="standalone"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+70
-26
@@ -1,14 +1,15 @@
|
||||
import { useChat } from "../../hooks/useChat";
|
||||
import { Message } from "./Message";
|
||||
import { useAppState } from "../../state";
|
||||
import type { Message as MessageType } from "../../../core/types";
|
||||
import type { UserProfile } from "../../../core/types";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import type { Message as MessageType } from "@/core/types";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
import { UserProfileDialog } from "./UserProfileDialog";
|
||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||
import { fetchUserProfile } from "../../../api/profileApi";
|
||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "../../../utils/utils";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { delay } from "@/utils/utils";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
@@ -17,15 +18,23 @@ interface ChatMessagesProps {
|
||||
onReplySelect?: (message: MessageType) => void;
|
||||
onEditSelect?: (message: MessageType) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onRetryMessage?: (messageId: number) => void;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) {
|
||||
const { messages: hookMessages } = useChat();
|
||||
export function ChatMessages({
|
||||
messages = [],
|
||||
children,
|
||||
isDm = false,
|
||||
onReplySelect,
|
||||
onEditSelect,
|
||||
onDelete,
|
||||
onRetryMessage,
|
||||
dmRecipientPublicKey }: ChatMessagesProps
|
||||
) {
|
||||
const { user } = useAppState();
|
||||
|
||||
// Use prop messages if provided, otherwise use hook messages
|
||||
const messages = propMessages || hookMessages;
|
||||
// Use prop messages (panels provide their own messages)
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
||||
@@ -41,6 +50,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteDialogOpen) {
|
||||
setToBeDeleted(null);
|
||||
@@ -92,20 +102,6 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
||||
if (!toBeDeleted || !user.authToken) return;
|
||||
try {
|
||||
onDelete?.(toBeDeleted.id);
|
||||
// if (toBeDeleted.isDm) {
|
||||
// // For DM, send dmDelete
|
||||
// await request({
|
||||
// type: "dmDelete",
|
||||
// data: { id: toBeDeleted.id },
|
||||
// credentials: { scheme: "Bearer", credentials: user.authToken }
|
||||
// });
|
||||
// } else {
|
||||
// await request({
|
||||
// type: "deleteMessage",
|
||||
// data: { message_id: toBeDeleted.id },
|
||||
// credentials: { scheme: "Bearer", credentials: user.authToken }
|
||||
// });
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error("Failed to delete message:", error);
|
||||
}
|
||||
@@ -117,16 +113,60 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
|
||||
function handleRetry(message: MessageType) {
|
||||
if (onRetryMessage) {
|
||||
onRetryMessage(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReactionClick(messageId: number, emoji: string) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
if (isDm) {
|
||||
// For DM messages, we need to find the dm_envelope_id from the message
|
||||
const message = messages.find(m => m.id === messageId);
|
||||
const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id;
|
||||
|
||||
if (dmEnvelopeId) {
|
||||
await request<AddDmReactionRequest["data"]>({
|
||||
type: "addDmReaction",
|
||||
credentials: { scheme: "Bearer", credentials: user.authToken },
|
||||
data: {
|
||||
dm_envelope_id: dmEnvelopeId,
|
||||
emoji: emoji
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For regular chat messages
|
||||
await request<AddReactionRequest["data"]>({
|
||||
type: "addReaction",
|
||||
credentials: { scheme: "Bearer", credentials: user.authToken },
|
||||
data: {
|
||||
message_id: messageId,
|
||||
emoji: emoji
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add reaction:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
{messages.map((message) => (
|
||||
{messages.map((message: MessageType) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
isAuthor={message.username === user.currentUser?.username}
|
||||
onProfileClick={handleProfileClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReactionClick={handleReactionClick}
|
||||
isLoadingProfile={isLoadingProfile}
|
||||
isDm={isDm}
|
||||
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||
@@ -162,11 +202,15 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
||||
onEdit={handleEdit}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
onRetry={handleRetry}
|
||||
onReactionClick={handleReactionClick}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onOpenChange={handleContextMenuOpenChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
||||
import type { Size2D } from "@/core/types";
|
||||
|
||||
interface BaseEmojiMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
}
|
||||
|
||||
interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps {
|
||||
position: Size2D;
|
||||
mode: "standalone";
|
||||
}
|
||||
|
||||
interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps {
|
||||
mode: "integrated";
|
||||
}
|
||||
|
||||
type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps;
|
||||
|
||||
export function EmojiMenu(props: EmojiMenuProps) {
|
||||
const { isOpen, onClose, onEmojiSelect, mode } = props;
|
||||
const position = mode === "standalone" ? props.position : undefined;
|
||||
const [activeCategory, setActiveCategory] = useState("recent");
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
const scrollToCategory = useCallback((categoryName: string) => {
|
||||
const element = categoryRefs.current.get(categoryName);
|
||||
if (element && scrollRef.current) {
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start"
|
||||
});
|
||||
}
|
||||
}, [categoryRefs, scrollRef]);
|
||||
|
||||
const scrollTabIntoView = useCallback((categoryName: string) => {
|
||||
const tabElement = tabRefs.current.get(categoryName);
|
||||
if (tabElement && tabsRef.current) {
|
||||
const tabsRect = tabsRef.current.getBoundingClientRect();
|
||||
const tabRect = tabElement.getBoundingClientRect();
|
||||
|
||||
// Check if tab is outside the visible area
|
||||
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
|
||||
tabElement.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center"
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [tabRefs, tabsRef]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
// Find which category is currently visible
|
||||
for (const [categoryName, element] of categoryRefs.current) {
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const containerRect = scrollRef.current.getBoundingClientRect();
|
||||
|
||||
// Check if category header is in view
|
||||
if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) {
|
||||
if (activeCategory !== categoryName) {
|
||||
setActiveCategory(categoryName);
|
||||
scrollTabIntoView(categoryName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeCategory, scrollTabIntoView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRecentEmojis(getRecentEmojis());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
function handleEmojiClick(emoji: string) {
|
||||
addRecentEmoji(emoji);
|
||||
onEmojiSelect(emoji);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
|
||||
style={mode === "standalone" && position ? {
|
||||
position: "fixed",
|
||||
left: position.x,
|
||||
bottom: position.y,
|
||||
zIndex: 1000,
|
||||
pointerEvents: isOpen ? "auto" : "none"
|
||||
} : {
|
||||
pointerEvents: isOpen ? "auto" : "none"
|
||||
}}
|
||||
>
|
||||
<div className="emoji-menu-header">
|
||||
<div ref={tabsRef} className="emoji-category-tabs">
|
||||
{EMOJI_CATEGORIES.map((category) => (
|
||||
<button
|
||||
key={category.name}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(category.name, el);
|
||||
}}
|
||||
className={`emoji-category-tab ${activeCategory === category.name ? "active" : ""}`}
|
||||
onClick={() => scrollToCategory(category.name)}
|
||||
title={category.name}
|
||||
>
|
||||
<span>{category.icon}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="emoji-grid"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{EMOJI_CATEGORIES.map((category) => {
|
||||
const emojis = category.name === "recent" ? recentEmojis : category.emojis;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category.name}
|
||||
ref={(el) => {
|
||||
if (el) categoryRefs.current.set(category.name, el);
|
||||
}}
|
||||
className="emoji-category-section"
|
||||
>
|
||||
<h3 className="emoji-category-title">
|
||||
{category.name.charAt(0).toUpperCase() + category.name.slice(1)}
|
||||
</h3>
|
||||
{emojis.length > 0 ? (
|
||||
<div className="emoji-category-grid">
|
||||
{emojis.map((emoji, index) => (
|
||||
<button
|
||||
key={`${category.name}-${index}`}
|
||||
className="emoji-item"
|
||||
onClick={() => handleEmojiClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="emoji-empty-state">
|
||||
<span>No {category.name} emojis</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+214
-60
@@ -1,23 +1,141 @@
|
||||
import { formatTime } from "../../../utils/utils";
|
||||
import type { Attachment, Message as MessageType } from "../../../core/types";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import Quote from "../core/Quote";
|
||||
import { formatTime, id } from "@/utils/utils";
|
||||
import type { Attachment, Message as MessageType, Reaction } from "@/core/types";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import Quote from "@/core/components/Quote";
|
||||
import { parse } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { getCurrentKeys } from "../../../auth/crypto";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "../../../auth/api";
|
||||
import { useAppState } from "../../state";
|
||||
import { ub64 } from "../../../utils/utils";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { getCurrentKeys } from "@/core/api/authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions?: Reaction[];
|
||||
onReactionClick: (emoji: string) => void;
|
||||
messageId?: number; // Add messageId to ensure unique keys
|
||||
}
|
||||
|
||||
function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
|
||||
const { user } = useAppState();
|
||||
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
|
||||
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Handle reactions with animation
|
||||
useEffect(() => {
|
||||
if (!reactions || reactions.length === 0) {
|
||||
// If we have visible reactions, animate them out
|
||||
if (visibleReactions.length > 0) {
|
||||
visibleReactions.forEach(reaction => {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
});
|
||||
// After animation completes, hide the component
|
||||
setTimeout(() => {
|
||||
setVisibleReactions([]);
|
||||
setAnimatingReactions(new Set());
|
||||
setIsVisible(false);
|
||||
}, 200);
|
||||
} else {
|
||||
// No visible reactions, hide immediately
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsVisible(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the component when we have reactions
|
||||
setIsVisible(true);
|
||||
|
||||
// Deduplicate reactions by emoji (safety measure)
|
||||
const uniqueReactions = reactions.reduce((acc, reaction) => {
|
||||
const existing = acc.find(r => r.emoji === reaction.emoji);
|
||||
if (existing) {
|
||||
// Keep the one with the higher count
|
||||
if (reaction.count > existing.count) {
|
||||
acc[acc.indexOf(existing)] = reaction;
|
||||
}
|
||||
} else {
|
||||
acc.push(reaction);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Reaction[]);
|
||||
|
||||
|
||||
// Animate out removed reactions
|
||||
visibleReactions.forEach(reaction => {
|
||||
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
|
||||
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
|
||||
setTimeout(() => {
|
||||
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
|
||||
setAnimatingReactions(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(reaction.emoji);
|
||||
return newSet;
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Update existing reactions and add new ones
|
||||
setVisibleReactions(prev => {
|
||||
const updated = [...prev];
|
||||
|
||||
// Update existing reactions
|
||||
uniqueReactions.forEach(reaction => {
|
||||
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
|
||||
if (existingIndex !== -1) {
|
||||
updated[existingIndex] = reaction;
|
||||
} else {
|
||||
// Add new reaction only if it doesn't already exist
|
||||
if (!updated.some(r => r.emoji === reaction.emoji)) {
|
||||
updated.push(reaction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, [reactions, visibleReactions]);
|
||||
|
||||
// Don't render if not visible
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-reactions">
|
||||
{visibleReactions.map((reaction, index) => {
|
||||
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
|
||||
const isAnimating = animatingReactions.has(reaction.emoji);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${messageId || "unknown"}-${reaction.emoji}-${reaction.count}-${index}`}
|
||||
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
|
||||
onClick={() => onReactionClick(reaction.emoji)}
|
||||
title={reaction.users.map(u => u.username).join(", ")}
|
||||
>
|
||||
<span className="reaction-emoji">{reaction.emoji}</span>
|
||||
<span className="reaction-count">{reaction.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
isAuthor: boolean;
|
||||
onProfileClick: (username: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => void;
|
||||
isLoadingProfile?: boolean;
|
||||
isDm?: boolean;
|
||||
dmRecipientPublicKey?: string;
|
||||
@@ -30,7 +148,16 @@ interface Rect {
|
||||
height: number
|
||||
}
|
||||
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||
export function Message({
|
||||
message,
|
||||
isAuthor,
|
||||
onProfileClick,
|
||||
onContextMenu,
|
||||
onReactionClick,
|
||||
isLoadingProfile = false,
|
||||
isDm = false,
|
||||
dmRecipientPublicKey
|
||||
}: MessageProps) {
|
||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||
@@ -48,39 +175,8 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setFormattedMessage({
|
||||
__html: DOMPurify.sanitize(
|
||||
await parse(message.content)
|
||||
).trim()
|
||||
});
|
||||
})();
|
||||
}, [message]);
|
||||
|
||||
// Auto-decrypt images in DMs
|
||||
useEffect(() => {
|
||||
if (isDm && message.files) {
|
||||
message.files.forEach(async (file) => {
|
||||
console.log(file);
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
|
||||
console.log("Decrypting...");
|
||||
const decryptedUrl = await decryptFile(file);
|
||||
console.log(decryptedUrl);
|
||||
if (decryptedUrl) {
|
||||
updateDecryptedFiles(draft => {
|
||||
draft.set(file.path, decryptedUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [message.files, isDm, decryptedFiles]);
|
||||
|
||||
const decryptFile = async (file: Attachment): Promise<string | null> => {
|
||||
const decryptFile = useCallback(async (file: Attachment): Promise<string | null> => {
|
||||
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
|
||||
debugger;
|
||||
console.warn("Conditions not met")
|
||||
return null;
|
||||
}
|
||||
@@ -133,9 +229,39 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
} finally {
|
||||
// no-op decrypt indicator removed from UI
|
||||
}
|
||||
};
|
||||
}, [decryptedFiles, isDm, user.authToken, dmRecipientPublicKey, dmEnvelope, updateDecryptedFiles]);
|
||||
|
||||
const handleImageClick = async (file: Attachment, imageElement: HTMLImageElement) => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setFormattedMessage({
|
||||
__html: DOMPurify.sanitize(
|
||||
await parse(message.content)
|
||||
).trim()
|
||||
});
|
||||
})();
|
||||
}, [message]);
|
||||
|
||||
// Auto-decrypt images in DMs
|
||||
useEffect(() => {
|
||||
if (isDm && message.files) {
|
||||
message.files.forEach(async (file) => {
|
||||
console.log(file);
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
|
||||
console.log("Decrypting...");
|
||||
const decryptedUrl = await decryptFile(file);
|
||||
console.log(decryptedUrl);
|
||||
if (decryptedUrl) {
|
||||
updateDecryptedFiles(draft => {
|
||||
draft.set(file.path, decryptedUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [message.files, isDm, decryptedFiles, decryptFile, updateDecryptedFiles]);
|
||||
|
||||
async function handleImageClick(file: Attachment, imageElement: HTMLImageElement) {
|
||||
// Use decrypted URL if available, otherwise decrypt first
|
||||
const decryptedUrl = decryptedFiles.get(file.path);
|
||||
if (decryptedUrl) {
|
||||
@@ -150,7 +276,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
}
|
||||
};
|
||||
|
||||
const computeEndRect = (naturalWidth: number, naturalHeight: number): Rect => {
|
||||
function computeEndRect(naturalWidth: number, naturalHeight: number): Rect {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const maxWidth = Math.floor(viewportWidth * 0.9);
|
||||
@@ -165,7 +291,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
return { left, top, width, height };
|
||||
};
|
||||
|
||||
const openFullscreenFromThumb = (imgEl: HTMLImageElement, src: string, name: string) => {
|
||||
function openFullscreenFromThumb(imgEl: HTMLImageElement, src: string, name: string) {
|
||||
const rect = imgEl.getBoundingClientRect();
|
||||
const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
||||
const tempImg = new Image();
|
||||
@@ -186,7 +312,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
};
|
||||
};
|
||||
|
||||
const closeFullscreen = () => {
|
||||
function closeFullscreen() {
|
||||
// Reverse animation
|
||||
setIsAnimatingOpen(false);
|
||||
// Wait for transition to finish
|
||||
@@ -198,7 +324,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const downloadImage = async () => {
|
||||
async function downloadImage() {
|
||||
if (!fullscreenImage) return;
|
||||
const { src, name } = fullscreenImage;
|
||||
try {
|
||||
@@ -232,7 +358,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = async (file: Attachment) => {
|
||||
async function downloadFile(file: Attachment) {
|
||||
try {
|
||||
updateDownloadingPaths(draft => {
|
||||
draft.add(file.path);
|
||||
@@ -286,7 +412,6 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="message-inner">
|
||||
{/* Add profile picture for received messages */}
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-profile-pic">
|
||||
<img
|
||||
@@ -307,12 +432,13 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
<div
|
||||
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
|
||||
{message.username}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add reply preview if this is a reply */}
|
||||
{message.reply_to && (
|
||||
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
|
||||
<span className="reply-username">{message.reply_to.username}</span>
|
||||
@@ -330,6 +456,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
const decryptedUrl = decryptedFiles.get(file.path);
|
||||
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
|
||||
const isDownloading = downloadingPaths.has(file.path);
|
||||
const isSending = message.runtimeData?.sendingState?.status === "sending";
|
||||
|
||||
return (
|
||||
<div className="attachment" key={idx}>
|
||||
@@ -342,10 +469,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
src={imageSrc}
|
||||
alt={file.name || "image"}
|
||||
onClick={(e) => handleImageClick(file, e.currentTarget)}
|
||||
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
|
||||
onLoad={() => updateLoadedImages(draft => {
|
||||
draft.add(file.path);
|
||||
})}
|
||||
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
|
||||
/>
|
||||
{!loadedImages.has(file.path) && (
|
||||
{(!loadedImages.has(file.path) || isSending) && (
|
||||
<div className="loading-overlay">
|
||||
<mdui-circular-progress />
|
||||
</div>
|
||||
@@ -373,22 +502,43 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
</mdui-list>
|
||||
)}
|
||||
|
||||
<Reactions
|
||||
reactions={message.reactions}
|
||||
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
|
||||
messageId={message.id}
|
||||
/>
|
||||
|
||||
<div className="message-time">
|
||||
{formatTime(message.timestamp)}
|
||||
{message.is_edited ? " (edited)" : undefined}
|
||||
|
||||
{isAuthor && message.is_read && (
|
||||
<span className="material-symbols outlined"></span>
|
||||
<span className="material-symbols outlined" />
|
||||
)}
|
||||
|
||||
{isAuthor && message.runtimeData?.sendingState && (
|
||||
<span className="message-status-indicator">
|
||||
{message.runtimeData.sendingState.status === "sending" && (
|
||||
<mdui-circular-progress style={{ width: "16px", height: "16px" }} />
|
||||
)}
|
||||
{message.runtimeData.sendingState.status === "failed" && (
|
||||
<span className="material-symbols error-icon">error</span>
|
||||
)}
|
||||
{message.runtimeData.sendingState.status === "sent" && (
|
||||
<span className="material-symbols success-icon">check</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Image Viewer with shared-element like transition */}
|
||||
{fullscreenImage && (
|
||||
{fullscreenImage && createPortal(
|
||||
<div
|
||||
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
|
||||
onClick={closeFullscreen}>
|
||||
onClick={closeFullscreen}
|
||||
role="dialog">
|
||||
<img
|
||||
src={fullscreenImage.src}
|
||||
alt={fullscreenImage.name}
|
||||
@@ -401,7 +551,10 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="fullscreen-controls top-right"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<mdui-button-icon icon="close" onClick={closeFullscreen} />
|
||||
{isDownloadingFullscreen ? (
|
||||
<div className="progress-wrapper">
|
||||
@@ -411,7 +564,8 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
<mdui-button-icon icon="download" onClick={downloadImage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
id("root")
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { Message, Size2D } from "@/core/types";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
message: Message;
|
||||
isAuthor: boolean;
|
||||
onEdit: (message: Message) => void;
|
||||
onReply: (message: Message) => void;
|
||||
onDelete: (message: Message) => void;
|
||||
onRetry?: (message: Message) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
|
||||
position: Size2D;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
isOpen: boolean;
|
||||
message: Message | null;
|
||||
position: Size2D;
|
||||
}
|
||||
|
||||
export function MessageContextMenu({
|
||||
message,
|
||||
isAuthor,
|
||||
onEdit,
|
||||
onReply,
|
||||
onDelete,
|
||||
onRetry,
|
||||
onReactionClick,
|
||||
position,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: MessageContextMenuProps) {
|
||||
// Internal state for closing animation
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||
const [animationClass, setAnimationClass] = useState("entering");
|
||||
const [reactionBarPosition, setReactionBarPosition] = useState<"left" | "right">("left");
|
||||
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
|
||||
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [expandUpward, setExpandUpward] = useState(false);
|
||||
const [contextMenuHeight, setContextMenuHeight] = useState<number | null>(null);
|
||||
|
||||
// Refs for measuring actual dimensions
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const reactionBarRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const emojiMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
// Set appropriate closing animation based on opening animation
|
||||
const closingAnimation = animationClass.replace("entering", "closing");
|
||||
setAnimationClass(closingAnimation);
|
||||
|
||||
// Wait for animation to complete before calling onOpenChange
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
setIsClosing(false);
|
||||
setAnimationClass("entering"); // Reset for next opening
|
||||
// Reset emoji menu state after context menu animation completes
|
||||
setIsEmojiMenuExpanded(false);
|
||||
setInitialDimensions(null);
|
||||
setExpandUpward(false);
|
||||
setContextMenuHeight(null);
|
||||
}, 200); // Match the animation duration from _animations.scss
|
||||
}, [animationClass, onOpenChange]);
|
||||
|
||||
// Calculate smart positioning when component opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Use a small delay to ensure elements are rendered before measuring
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (wrapperRef.current && reactionBarRef.current && contextMenuRef.current) {
|
||||
// Get actual dimensions from DOM elements
|
||||
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
|
||||
const contextMenuRect = contextMenuRef.current.getBoundingClientRect();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate shared/combined rect dimensions
|
||||
const sharedRect = {
|
||||
width: Math.max(reactionBarRect.width, contextMenuRect.width),
|
||||
height: reactionBarRect.height + contextMenuRect.height
|
||||
};
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
let animation = "entering";
|
||||
let reactionPosition: "left" | "right" = "left";
|
||||
|
||||
// Check if shared rect would overflow and adjust position
|
||||
if (x + sharedRect.width > viewportWidth) {
|
||||
x = position.x - contextMenuRect.width - 25;
|
||||
animation = "entering-left";
|
||||
reactionPosition = "right";
|
||||
} else {
|
||||
reactionPosition = "left";
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off the left edge
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
// Check if shared rect would overflow bottom edge
|
||||
if (y + sharedRect.height > viewportHeight) {
|
||||
y = viewportHeight - sharedRect.height;
|
||||
animation = "entering-up";
|
||||
}
|
||||
|
||||
setCalculatedPosition({ x, y });
|
||||
setAnimationClass(animation);
|
||||
setReactionBarPosition(reactionPosition);
|
||||
}
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}
|
||||
}, [isOpen, position, isAuthor]);
|
||||
|
||||
// Effect to handle clicks outside the context menu
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (isOpen && !isClosing) {
|
||||
// Check if the click is on a context menu element or reaction bar
|
||||
const target = event.target as Element;
|
||||
if (!target.closest(".context-menu") && !target.closest(".context-menu-reaction-bar")) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
function handleWindowBlur() {
|
||||
// Close context menu when browser window loses focus
|
||||
if (isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
};
|
||||
}, [isOpen, isClosing, handleClose]);
|
||||
|
||||
interface Action {
|
||||
label: string;
|
||||
icon: string;
|
||||
onClick: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
// Check if message is sending or failed
|
||||
const isSending = message.runtimeData?.sendingState?.status === "sending";
|
||||
const isFailed = message.runtimeData?.sendingState?.status === "failed";
|
||||
const isSendingOrFailed = isSending || isFailed;
|
||||
|
||||
const actions: Action[] = [
|
||||
{
|
||||
label: "Reply",
|
||||
icon: "reply",
|
||||
onClick: () => {
|
||||
onReply(message);
|
||||
handleClose();
|
||||
},
|
||||
show: !isSendingOrFailed
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: "edit",
|
||||
onClick: () => {
|
||||
onEdit(message);
|
||||
handleClose();
|
||||
},
|
||||
show: isAuthor && !isSendingOrFailed
|
||||
},
|
||||
{
|
||||
label: "Retry",
|
||||
icon: "refresh",
|
||||
onClick: () => {
|
||||
if (onRetry) {
|
||||
onRetry(message);
|
||||
}
|
||||
handleClose();
|
||||
},
|
||||
show: isAuthor && isFailed && !!onRetry
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: "delete",
|
||||
onClick: () => {
|
||||
onDelete(message);
|
||||
handleClose();
|
||||
},
|
||||
show: isAuthor
|
||||
}
|
||||
];
|
||||
|
||||
// Quick reactions for the reaction bar
|
||||
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
|
||||
|
||||
async function handleReactionClick(emoji: string) {
|
||||
if (onReactionClick) {
|
||||
await onReactionClick(message.id, emoji);
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleExpandClick() {
|
||||
if (!reactionBarRef.current || !wrapperRef.current) return;
|
||||
|
||||
// Measure the actual dimensions of the reaction bar content
|
||||
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
||||
|
||||
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
|
||||
setContextMenuHeight(wrapperRect.height);
|
||||
|
||||
// Check if expanding downward would cause overflow
|
||||
// Calculate space from the reaction bar's bottom edge downward
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - reactionBarRect.bottom;
|
||||
const emojiMenuHeight = 400;
|
||||
|
||||
// Only expand upward if there's not enough space below for the emoji menu
|
||||
const shouldExpandUpward = spaceBelow < emojiMenuHeight;
|
||||
setExpandUpward(shouldExpandUpward);
|
||||
|
||||
// Use requestAnimationFrame to ensure the dimensions are applied before expansion
|
||||
requestAnimationFrame(() => {
|
||||
setIsEmojiMenuExpanded(true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
if (onReactionClick) {
|
||||
onReactionClick(message.id, emoji);
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
|
||||
return isOpen && (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`context-menu-wrapper ${animationClass}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: calculatedPosition.y,
|
||||
left: calculatedPosition.x,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="menu"
|
||||
tabIndex={0}>
|
||||
|
||||
{/* Reaction Bar */}
|
||||
<div
|
||||
ref={reactionBarRef}
|
||||
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
|
||||
style={isEmojiMenuExpanded && !expandUpward ? {
|
||||
position: "fixed",
|
||||
top: `${(-(contextMenuHeight || 0) + 95)}px`,
|
||||
width: "320px",
|
||||
height: "400px",
|
||||
zIndex: 1001
|
||||
} : initialDimensions && !isEmojiMenuExpanded ? {
|
||||
width: `${initialDimensions.width}px`,
|
||||
height: `${initialDimensions.height}px`
|
||||
} : {}}>
|
||||
{!isEmojiMenuExpanded ? (
|
||||
<div className="reaction-bar-content">
|
||||
{QUICK_REACTIONS.map((emoji, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="reaction-emoji-button"
|
||||
onClick={async () => await handleReactionClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="reaction-expand-button"
|
||||
onClick={handleExpandClick}
|
||||
title="More emojis"
|
||||
>
|
||||
<span className="material-symbols">add</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={emojiMenuRef}
|
||||
className="emoji-menu-wrapper">
|
||||
<EmojiMenu
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
mode="integrated"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
|
||||
{actions.map((action, i) => (
|
||||
action.show && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={action.onClick}
|
||||
key={i}
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="material-symbols">{action.icon}</span>
|
||||
{action.label}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { setGlobalMessageHandler } from "@/core/websocket";
|
||||
import type { Message, WebSocketMessage } from "@/core/types";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
|
||||
import type { DMPanel } from "./panels/DMPanel";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
}
|
||||
|
||||
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const { applyPendingPanel, chat } = useAppState();
|
||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||
const [switchIn, setSwitchIn] = useState(false);
|
||||
const [switchOut, setSwitchOut] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const previousMessageCountRef = useRef(0);
|
||||
const [replyTo, setReplyTo] = useState<Message | null>(null);
|
||||
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
|
||||
const [editMessage, setEditMessage] = useState<Message | null>(null);
|
||||
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
|
||||
|
||||
|
||||
// Drag & drop
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const addFilesRef = useRef<null | ((files: File[]) => void)>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panel || !panelState) return;
|
||||
|
||||
return () => {
|
||||
dragCounterRef.current = 0;
|
||||
setIsDragging(false);
|
||||
};
|
||||
}, [panel, panelState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replyTo) {
|
||||
setReplyToVisible(true);
|
||||
}
|
||||
}, [replyTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMessage) {
|
||||
setEditVisible(true);
|
||||
}
|
||||
}, [editMessage]);
|
||||
|
||||
// Handle panel state changes
|
||||
useEffect(() => {
|
||||
if (panel) {
|
||||
setPanelState(panel.getState());
|
||||
|
||||
// Store the handler for cleanup
|
||||
panel.onStateChange = (newState: MessagePanelState) => {
|
||||
setPanelState(newState);
|
||||
};
|
||||
|
||||
// Set up WebSocket message handler for this panel
|
||||
if (panel.handleWebSocketMessage) {
|
||||
setGlobalMessageHandler((message: WebSocketMessage<object>) => panel.handleWebSocketMessage(message));
|
||||
}
|
||||
} else {
|
||||
setPanelState(null);
|
||||
setGlobalMessageHandler(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (panel) {
|
||||
if (panel.onStateChange) {
|
||||
panel.onStateChange = null;
|
||||
}
|
||||
|
||||
if (typeof panel.destroy === "function") {
|
||||
panel.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [panel]);
|
||||
|
||||
// Handle chat switching animation with event listeners
|
||||
useEffect(() => {
|
||||
if (chat.isSwitching) {
|
||||
setSwitchOut(true);
|
||||
|
||||
// Use animation event listeners instead of hardcoded delays
|
||||
function handleAnimationEnd(event: Event) {
|
||||
const animationEvent = event as AnimationEvent;
|
||||
|
||||
if (animationEvent.animationName === "fadeOutUp") {
|
||||
// Apply pending panel exactly at the boundary between animations
|
||||
applyPendingPanel();
|
||||
setSwitchOut(false);
|
||||
setSwitchIn(true);
|
||||
} else if (animationEvent.animationName === "fadeInDown") {
|
||||
setSwitchIn(false);
|
||||
// End the chat switching state
|
||||
chat.setIsSwitching(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener to document to catch all animation events
|
||||
document.addEventListener("animationend", handleAnimationEnd);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
document.removeEventListener("animationend", handleAnimationEnd);
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat.isSwitching, chat.setIsSwitching, applyPendingPanel]);
|
||||
|
||||
// Load messages when panel changes and animation is not running
|
||||
useEffect(() => {
|
||||
if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return;
|
||||
|
||||
const panelState = chat.activePanel.getState();
|
||||
|
||||
if (panelState.messages.length === 0 && !panelState.isLoading) {
|
||||
chat.activePanel.loadMessages();
|
||||
}
|
||||
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]);
|
||||
|
||||
// Scroll to bottom only when new messages are added
|
||||
useEffect(() => {
|
||||
if (!panelState || chat.isSwitching || switchOut || switchIn) return;
|
||||
|
||||
const currentMessageCount = panelState.messages.length;
|
||||
const previousMessageCount = previousMessageCountRef.current;
|
||||
|
||||
const el = messagesEndRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) {
|
||||
el.scrollIntoView({ behavior: "instant", block: "end" });
|
||||
} else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
|
||||
const id = requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
// Update the previous message count
|
||||
previousMessageCountRef.current = currentMessageCount;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
|
||||
|
||||
return (
|
||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||
<div
|
||||
ref={messagePanelRef}
|
||||
className="chat-main"
|
||||
id="chat-inner"
|
||||
onDragEnter={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current += 1;
|
||||
// Only show overlay when actual files are dragged
|
||||
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
|
||||
if (hasFiles) setIsDragging(true);
|
||||
} : undefined}
|
||||
onDragOver={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
} : undefined}
|
||||
onDragLeave={panel ? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) setIsDragging(false);
|
||||
} : undefined}
|
||||
onDrop={panel ? (e) => {
|
||||
if (!e.dataTransfer) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length > 0 && addFilesRef.current) {
|
||||
addFilesRef.current(files);
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
} : undefined}>
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={panel?.handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }} />
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<p>
|
||||
<span className={`online-status ${panelState?.online ? "online" : ""}`} />
|
||||
{panelState ? (
|
||||
panelState.online ? "Online" : "Offline"
|
||||
) : (
|
||||
"Выберите чат, чтобы начать переписку"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{panelState?.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
</div>
|
||||
) : panelState && panel ? (
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: message });
|
||||
setEditVisible(false); // onCloseEdit will apply pending
|
||||
} else {
|
||||
setReplyTo(message);
|
||||
}
|
||||
}}
|
||||
onEditSelect={(message) => {
|
||||
if (replyTo || replyToVisible) {
|
||||
setPendingAction({ type: "edit", message: message });
|
||||
setReplyToVisible(false); // onCloseReply will apply pending
|
||||
} else {
|
||||
setEditMessage(message);
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => panel.handleDeleteMessage(id)}
|
||||
onRetryMessage={(id) => panel.retryMessage(id)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
) : (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Выберите чат на боковой панели, чтобы начать переписку
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panel && (
|
||||
<>
|
||||
<AnimatedOpacity
|
||||
visible={isDragging}
|
||||
className="file-overlay"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}>
|
||||
<div className="file-overlay-wrapper">
|
||||
<div className="file-overlay-inner">
|
||||
<mdui-icon name="upload_file" />
|
||||
<span>Отпустите файл(ы) для добавления</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedOpacity>
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
setReplyTo(null);
|
||||
}}
|
||||
onSaveEdit={(content) => {
|
||||
if (editMessage) {
|
||||
panel.handleEditMessage(editMessage.id, content);
|
||||
setEditMessage(null);
|
||||
}
|
||||
}}
|
||||
replyTo={replyTo}
|
||||
replyToVisible={replyToVisible}
|
||||
onClearReply={() => {
|
||||
setPendingAction(null);
|
||||
setReplyToVisible(false);
|
||||
}}
|
||||
onCloseReply={() => {
|
||||
setReplyTo(null);
|
||||
if (pendingAction && pendingAction.type === "edit") {
|
||||
setEditMessage(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
editingMessage={editMessage}
|
||||
editVisible={editVisible}
|
||||
onClearEdit={() => {
|
||||
setPendingAction(null);
|
||||
setEditVisible(false);
|
||||
}}
|
||||
onCloseEdit={() => {
|
||||
setEditMessage(null);
|
||||
if (pendingAction && pendingAction.type === "reply") {
|
||||
setReplyTo(pendingAction.message);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}}
|
||||
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
||||
messagePanelRef={messagePanelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MessagePanelRenderer } from "./MessagePanelRenderer";
|
||||
|
||||
export function RightPanel() {
|
||||
const { chat } = useAppState();
|
||||
|
||||
return <MessagePanelRenderer panel={chat.activePanel} />
|
||||
}
|
||||
+9
-10
@@ -1,8 +1,8 @@
|
||||
import type { DialogProps } from "../../../core/types";
|
||||
import type { UserProfile } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { formatTime } from "../../../utils/utils";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { formatTime } from "@/utils/utils";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
|
||||
interface UserProfileDialogProps extends DialogProps {
|
||||
userProfile: UserProfile | null;
|
||||
@@ -14,7 +14,6 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
|
||||
<div className="profile-picture-section">
|
||||
<img
|
||||
className="profile-picture"
|
||||
alt="Profile Picture"
|
||||
src={userProfile.profile_picture || defaultAvatar}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
@@ -28,17 +27,17 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
|
||||
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
|
||||
{userProfile.online ? (
|
||||
<>
|
||||
<span className="online-indicator"></span> Онлайн
|
||||
<span className="online-indicator" /> Онлайн
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
|
||||
<span className="offline-indicator" /> Последний заход {formatTime(userProfile.last_seen)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bio-section">
|
||||
<label>О себе:</label>
|
||||
<span>О себе:</span>
|
||||
<div className="bio-display">
|
||||
{userProfile.bio || "No bio available."}
|
||||
</div>
|
||||
@@ -55,7 +54,7 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
<mdui-button id="dm-button" variant="filled">
|
||||
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
|
||||
<mdui-icon slot="icon" name="chat--filled" />
|
||||
Send Message
|
||||
</mdui-button>
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
export interface EmojiCategory {
|
||||
name: string;
|
||||
icon: string;
|
||||
emojis: string[];
|
||||
}
|
||||
|
||||
export const EMOJI_CATEGORIES: EmojiCategory[] = [
|
||||
{
|
||||
name: "recent",
|
||||
icon: "🕒",
|
||||
emojis: []
|
||||
},
|
||||
{
|
||||
name: "smileys",
|
||||
icon: "😀",
|
||||
emojis: [
|
||||
"😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖", "😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "people",
|
||||
icon: "👋",
|
||||
emojis: [
|
||||
"👋", "🤚", "🖐", "✋", "🖖", "👌", "🤏", "✌️", "🤞", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "👍", "👎", "👊", "✊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦿", "🦵", "🦶", "👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁", "👅", "👄", "💋", "🩸", "👶", "🧒", "👦", "👧", "🧑", "👨", "👩", "🧓", "👴", "👵", "👱", "🧔", "👲", "🧕", "👳", "👮", "👷", "💂", "🕵️", "👩⚕️", "👨⚕️", "👩🌾", "👨🌾", "👩🍳", "👨🍳", "👩🎓", "👨🎓", "👩🎤", "👨🎤", "👩🏫", "👨🏫", "👩🏭", "👨🏭", "👩💻", "👨💻", "👩💼", "👨💼", "👩🔧", "👨🔧", "👩🔬", "👨🔬", "👩🎨", "👨🎨", "👩🚒", "👨🚒", "👩✈️", "👨✈️", "👩🚀", "👨🚀", "👩⚖️", "👨⚖️", "👰", "🤵", "👸", "🤴", "🦸", "🦹", "🤶", "🎅", "🧙", "🧚", "🧛", "🧜", "🧝", "🧞", "🧟", "💆", "💇", "🚶", "🏃", "💃", "🕺", "🕴", "👯", "🧘", "🛀", "🛌", "👭", "👫", "👬", "💏", "💑", "👪"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "animals",
|
||||
icon: "🐶",
|
||||
emojis: [
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐽", "🐸", "🐵", "🙈", "🙉", "🙊", "🐒", "🐔", "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐜", "🦟", "🦗", "🕷", "🕸", "🦂", "🐢", "🐍", "🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍", "🦧", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌", "🐕", "🐩", "🦮", "🐕🦺", "🐈", "🐓", "🦃", "🦚", "🦜", "🦢", "🦩", "🕊", "🐇", "🦝", "🦨", "🦡", "🦦", "🦥", "🐁", "🐀", "🐿", "🦔"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "food",
|
||||
icon: "🍎",
|
||||
emojis: [
|
||||
"🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔", "🍟", "🍕", "🫓", "🥙", "🌮", "🌯", "🫔", "🥗", "🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟", "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡", "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬", "🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "☕", "🫖", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹", "🧉", "🍾"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "travel",
|
||||
icon: "🚗",
|
||||
emojis: [
|
||||
"🚗", "🚕", "🚙", "🚌", "🚎", "🏎", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🏍", "🛵", "🚲", "🛴", "🛹", "🛼", "🚁", "✈️", "🛩", "🛫", "🛬", "🪂", "💺", "🚀", "🛸", "🚉", "🚊", "🚝", "🚞", "🚋", "🚃", "🚋", "🚋", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🎢", "🎡", "🎠", "⛵", "🛥", "🚤", "⛴", "🛳", "🚢", "⚓", "🚧", "⛽", "🚨", "🚥", "🚦", "🛑", "🚏", "🗺", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟", "🎡", "🎢", "🎠", "⛲", "⛱", "🏖", "🏝", "🏔", "⛰", "🌋", "🗻", "🏕", "⛺", "🏠", "🏡", "🏘", "🏚", "🏗", "🏭", "🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩", "💒", "🏛", "⛪", "🕌", "🛕", "🕍", "🕋", "⛩", "🛤", "🛣", "🗾", "🎑", "🏞", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆", "🏙", "🌃", "🌌", "🌉", "🌁"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "activities",
|
||||
icon: "⚽",
|
||||
emojis: [
|
||||
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛷", "⛸", "🥌", "🎿", "⛷", "🏂", "🪂", "🏋️♀️", "🏋️♂️", "🤼♀️", "🤼♂️", "🤸♀️", "🤸♂️", "⛹️♀️", "⛹️♂️", "🤺", "🤾♀️", "🤾♂️", "🏌️♀️", "🏌️♂️", "🏇", "🧘♀️", "🧘♂️", "🏄♀️", "🏄♂️", "🏊♀️", "🏊♂️", "🤽♀️", "🤽♂️", "🚣♀️", "🚣♂️", "🧗♀️", "🧗♂️", "🚵♀️", "🚵♂️", "🚴♀️", "🚴♂️", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖", "🏵", "🎗", "🎫", "🎟", "🎪", "🤹", "🤹♀️", "🤹♂️", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧", "🎼", "🎹", "🥁", "🎷", "🎺", "🎸", "🪕", "🎻", "🎲", "♠️", "♥️", "♦️", "♣️", "♟", "🃏", "🀄", "🎴", "🎯", "🎳", "🎮", "🎰", "🧩"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "objects",
|
||||
icon: "📱",
|
||||
emojis: [
|
||||
"📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌", "💻", "🖥", "🖨", "⌨️", "🖱", "🖲", "💽", "💾", "💿", "📀", "🧮", "🎥", "📽", "📸", "📹", "📷", "🔍", "🔎", "🕯", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰", "🗞", "📑", "🔖", "🏷", "💰", "💴", "💵", "💶", "💷", "💸", "💳", "🧾", "💹", "💱", "💲", "✉️", "📧", "📨", "📩", "📤", "📥", "📦", "📫", "📪", "📬", "📭", "📮", "🗳", "✏️", "✒️", "🖋", "🖊", "🖌", "🖍", "📝", "💼", "📁", "📂", "🗂", "📅", "📆", "🗒", "🗓", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇", "📏", "📐", "✂️", "🗃", "🗄", "🗑", "🔒", "🔓", "🔏", "🔐", "🔑", "🗝", "🔨", "⛏", "⚒", "🛠", "🗡", "⚔️", "🔫", "🪃", "🏹", "🛡", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜", "⚖️", "🦯", "🔗", "⛓", "🧰", "🧲", "⚗️", "🧪", "🧫", "🧬", "🔬", "🔭", "📡", "💉", "💊", "🩹", "🩺", "🚪", "🛏", "🛋", "🚽", "🚿", "🛁", "🛀", "🧴", "🧷", "🧹", "🧺", "🧻", "🚰", "🚰", "🪒", "🧽", "🧯", "🛒"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "symbols",
|
||||
icon: "❤️",
|
||||
emojis: [
|
||||
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "☮️", "✝️", "☪️", "🕉", "☸️", "✡️", "🔯", "🕎", "☯️", "☦️", "🛐", "⛎", "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", "🆔", "⚛️", "🉑", "☢️", "☣️", "📴", "📳", "🈶", "🈚", "🈸", "🈺", "🈷️", "✴️", "🆚", "💮", "🉐", "㊙️", "㊗️", "🈴", "🈵", "🈹", "🈲", "🅰️", "🅱️", "🆎", "🅾️", "🆘", "❌", "⭕", "🛑", "⛔", "📛", "🚫", "💯", "💢", "♨️", "🚷", "🚯", "🚳", "🚱", "🔞", "📵", "🚭", "❗", "❕", "❓", "❔", "‼️", "⁉️", "🔅", "🔆", "〽️", "⚠️", "🚸", "🔱", "⚜️", "🔰", "♻️", "✅", "🈯", "💹", "❇️", "✳️", "❎", "🌐", "💠", "Ⓜ️", "🌀", "💤", "🏧", "🚾", "♿", "🅿️", "🛗", "🈳", "🈂️", "🛂", "🛃", "🛄", "🛅", "🚹", "🚺", "🚼", "⚧", "🚻", "🚮", "🎦", "📶", "🈁", "🔣", "ℹ️", "🔤", "🔡", "🔠", "🆖", "🆗", "🆙", "🆒", "🆕", "🆓", "0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "flags",
|
||||
icon: "🏳️",
|
||||
emojis: [
|
||||
"🏳️", "🏴", "🏁", "🚩", "🏳️🌈", "🏳️⚧️", "🏴☠️", "🇦🇨", "🇦🇩", "🇦🇪", "🇦🇫", "🇦🇬", "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼", "🇧🇾", "🇧🇿", "🇨🇦", "🇨🇨", "🇨🇩", "🇨🇫", "🇨🇬", "🇨🇭", "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", "🇨🇿", "🇩🇪", "🇩🇬", "🇩🇯", "🇩🇰", "🇩🇲", "🇩🇴", "🇩🇿", "🇪🇦", "🇪🇨", "🇪🇪", "🇪🇬", "🇪🇭", "🇪🇷", "🇪🇸", "🇪🇹", "🇪🇺", "🇫🇮", "🇫🇯", "🇫🇰", "🇫🇲", "🇫🇴", "🇫🇷", "🇬🇦", "🇬🇧", "🇬🇩", "🇬🇪", "🇬🇫", "🇬🇬", "🇬🇭", "🇬🇮", "🇬🇱", "🇬🇲", "🇬🇳", "🇬🇵", "🇬🇶", "🇬🇷", "🇬🇸", "🇬🇹", "🇬🇺", "🇬🇼", "🇬🇾", "🇭🇰", "🇭🇲", "🇭🇳", "🇭🇷", "🇭🇹", "🇭🇺", "🇮🇨", "🇮🇩", "🇮🇪", "🇮🇱", "🇮🇲", "🇮🇳", "🇮🇴", "🇮🇶", "🇮🇷", "🇮🇸", "🇮🇹", "🇯🇪", "🇯🇲", "🇯🇴", "🇯🇵", "🇰🇪", "🇰🇬", "🇰🇭", "🇰🇮", "🇰🇲", "🇰🇳", "🇰🇵", "🇰🇷", "🇰🇼", "🇰🇾", "🇰🇿", "🇱🇦", "🇱🇧", "🇱🇨", "🇱🇮", "🇱🇰", "🇱🇷", "🇱🇸", "🇱🇹", "🇱🇺", "🇱🇻", "🇱🇾", "🇲🇦", "🇲🇨", "🇲🇩", "🇲🇪", "🇲🇫", "🇲🇬", "🇲🇭", "🇲🇰", "🇲🇱", "🇲🇲", "🇲🇳", "🇲🇴", "🇲🇵", "🇲🇶", "🇲🇷", "🇲🇸", "🇲🇹", "🇲🇺", "🇲🇻", "🇲🇼", "🇲🇽", "🇲🇾", "🇲🇿", "🇳🇦", "🇳🇨", "🇳🇪", "🇳🇫", "🇳🇬", "🇳🇮", "🇳🇱", "🇳🇴", "🇳🇵", "🇳🇷", "🇳🇺", "🇳🇿", "🇴🇲", "🇵🇦", "🇵🇪", "🇵🇫", "🇵🇬", "🇵🇭", "🇵🇰", "🇵🇱", "🇵🇲", "🇵🇳", "🇵🇷", "🇵🇸", "🇵🇹", "🇵🇼", "🇵🇾", "🇶🇦", "🇷🇪", "🇷🇴", "🇷🇸", "🇷🇺", "🇷🇼", "🇸🇦", "🇸🇧", "🇸🇨", "🇸🇩", "🇸🇪", "🇸🇬", "🇸🇭", "🇸🇮", "🇸🇯", "🇸🇰", "🇸🇱", "🇸🇲", "🇸🇳", "🇸🇴", "🇸🇷", "🇸🇸", "🇸🇹", "🇸🇻", "🇸🇽", "🇸🇾", "🇸🇿", "🇹🇦", "🇹🇨", "🇹🇩", "🇹🇫", "🇹🇬", "🇹🇭", "🇹🇯", "🇹🇰", "🇹🇱", "🇹🇲", "🇹🇳", "🇹🇴", "🇹🇷", "🇹🇹", "🇹🇻", "🇹🇼", "🇹🇿", "🇺🇦", "🇺🇬", "🇺🇲", "🇺🇸", "🇺🇾", "🇺🇿", "🇻🇦", "🇻🇨", "🇻🇪", "🇻🇬", "🇻🇮", "🇻🇳", "🇻🇺", "🇼🇫", "🇼🇸", "🇾🇪", "🇾🇹", "🇿🇦", "🇿🇲", "🇿🇼"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const RECENT_EMOJIS_KEY = "recentEmojis";
|
||||
|
||||
export function getRecentEmojis(): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_EMOJIS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function addRecentEmoji(emoji: string): void {
|
||||
try {
|
||||
let recentEmojis = getRecentEmojis();
|
||||
recentEmojis = recentEmojis.filter(e => e !== emoji);
|
||||
recentEmojis.unshift(emoji);
|
||||
recentEmojis = recentEmojis.slice(0, 50);
|
||||
localStorage.setItem(RECENT_EMOJIS_KEY, JSON.stringify(recentEmojis));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user