mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-24 12:05:05 +03:00
Merge branch 'refactor'
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Analyze my codebase and think how it could be better organized, like a better folder or code structure.
|
||||
+1
-1
@@ -573,4 +573,4 @@ package-lock.json
|
||||
backend/alembic/**
|
||||
!backend/alembic/env.py
|
||||
!backend/alembic/script.py.mako
|
||||
!frontend/src/pages/app/resources/css/lib
|
||||
!frontend/src/css/lib
|
||||
+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>
|
||||
|
||||
+35
-17
@@ -1,17 +1,19 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ElectronTitleBar } from "./pages/app/ui/components/Electron";
|
||||
import { useAppState } from "./pages/app/ui/state";
|
||||
import { useEffect, useState } from "react";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
import ChatPage from "./pages/ChatPage";
|
||||
import DownloadAppPage from "./pages/DownloadAppPage";
|
||||
import NotFoundPage from "./pages/NotFoundPage";
|
||||
import { ElectronTitleBar } from "./Electron";
|
||||
import { useAppState } from "./pages/chat/state";
|
||||
import { useEffect, useState, lazy, Suspense } from "react";
|
||||
import { isElectron } from "./core/electron/electron";
|
||||
import { MINIMUM_WIDTH } from "./core/config";
|
||||
import useWindowSize from "./core/hooks/useWindowSize";
|
||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||
import { isElectron } from "./pages/app/electron/electron";
|
||||
import { MINIMUM_WIDTH } from "./pages/app/core/config";
|
||||
import useWindowSize from "./pages/app/ui/hooks/useWindowSize";
|
||||
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();
|
||||
@@ -31,10 +33,24 @@ export default function App() {
|
||||
<ElectronTitleBar />
|
||||
<div id="main-wrapper">
|
||||
<Routes>
|
||||
z<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/download-app" element={<DownloadAppPage />} />
|
||||
<Route path="/" element={
|
||||
<Suspense>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/login" element={
|
||||
<Suspense>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/register" element={
|
||||
<Suspense>
|
||||
<RegisterPage />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/download-app" element={
|
||||
<DownloadAppPage />
|
||||
} />
|
||||
<Route path="/">
|
||||
<Route path="chat" element={
|
||||
<ProtectedRoute>
|
||||
@@ -42,7 +58,9 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
<Route path="*" element={
|
||||
<NotFoundPage />
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PRODUCT_NAME } from "../../core/config";
|
||||
import { isElectron } from "../../electron/electron";
|
||||
import { PRODUCT_NAME } from "./core/config";
|
||||
import { isElectron } from "./core/electron/electron";
|
||||
|
||||
export function ElectronTitleBar() {
|
||||
return isElectron && (
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
+2
-2
@@ -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;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@use "common/material" as *;
|
||||
@use "../../css/material" as *;
|
||||
|
||||
#electron-title-bar {
|
||||
display: none;
|
||||
@@ -5,6 +5,8 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import "./electron.scss";
|
||||
|
||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
|
||||
|
||||
if (isElectron) {
|
||||
+5
-5
@@ -1,8 +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 serviceWorker from "../service-worker/service-worker.ts?worker&url";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
|
||||
import serviceWorker from "./service-worker?worker&url";
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
@@ -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
|
||||
-71
@@ -30,77 +30,6 @@ button, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: relative;
|
||||
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: block;
|
||||
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;
|
||||
}
|
||||
|
||||
&.faded {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes context-menu-open {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog content styles
|
||||
.dialog-content {
|
||||
h3 {
|
||||
@@ -1,21 +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 "404" as not-found;
|
||||
@use "homepage";
|
||||
@use "reactions";
|
||||
@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 './pages/app/resources/css/style.scss';
|
||||
import "mdui/mdui.css";
|
||||
import './css/style.scss';
|
||||
|
||||
import "./pages/app/utils/material";
|
||||
import "./pages/app/core/init";
|
||||
import "./pages/app/electron/electron";
|
||||
import "./utils/material";
|
||||
import "./core/init";
|
||||
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 />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppState } from "./app/ui/state";
|
||||
import { useAppState } from "./chat/state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
|
||||
@@ -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,984 +0,0 @@
|
||||
@use "common/colors" as *;
|
||||
@use "common/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;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
background-color: $color-dark-surface-container;
|
||||
color: white;
|
||||
padding: 16px 16px;
|
||||
justify-content: end;
|
||||
width: fit-content;
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#logouts {
|
||||
display: none;
|
||||
list-style: none;
|
||||
gap: 10px;
|
||||
|
||||
li {
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.chat-main {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quote.contextual-content > .quote-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.reply-username {
|
||||
font-weight: 600;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
z-index: 100;
|
||||
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.file-overlay-wrapper {
|
||||
border-radius: 30px;
|
||||
outline: 3px dashed $color-dark-primary;
|
||||
outline-offset: -20px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.file-overlay-inner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(18, 18, 18, 0.8);
|
||||
border: 1px solid $color-dark-surface-container-high;
|
||||
border-radius: 12px;
|
||||
color: $color-dark-on-surface;
|
||||
|
||||
mdui-icon {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
margin: 0 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-profile-pic {
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid $color-dark-outline;
|
||||
|
||||
&.loading {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-username {
|
||||
&.loading {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: $color-dark-surface;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem 0;
|
||||
min-width: 160px;
|
||||
z-index: 1000;
|
||||
|
||||
&.entering {
|
||||
animation: fadeInDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-left {
|
||||
animation: fadeInLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up {
|
||||
animation: fadeInUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up-left {
|
||||
animation: fadeInUpLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
animation: fadeOutUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-left {
|
||||
animation: fadeOutRight 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up {
|
||||
animation: fadeOutDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up-left {
|
||||
animation: fadeOutDownRight 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-dark-surface-container;
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 1.1rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen Image Viewer
|
||||
.fullscreen-image-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
z-index: 9999;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&.closing {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fullscreen-animated-image {
|
||||
position: absolute;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease;
|
||||
}
|
||||
|
||||
.fullscreen-controls {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
&.top-right {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji Menu Styles
|
||||
.emoji-menu {
|
||||
$transition: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
background: $color-dark-surface-container;
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(20px);
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform-origin: bottom left;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: transform 0.25s $transition, opacity 0.25s $transition;
|
||||
user-select: none;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.emoji-menu-header {
|
||||
background: $color-dark-surface-container-high;
|
||||
border-bottom: 1px solid rgba($color-dark-outline-variant, 0.2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
.emoji-category-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
padding: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
}
|
||||
|
||||
.emoji-category-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 1.2rem;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background: $color-dark-surface-container;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $color-dark-primary-container;
|
||||
color: $color-dark-on-primary-container;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: $color-dark-primary-container;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color-dark-surface-container-high;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.emoji-category-section {
|
||||
.emoji-category-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $color-dark-on-surface-variant;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.emoji-category-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
$size: 30px;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: $size;
|
||||
width: $size;
|
||||
height: $size;
|
||||
box-sizing: content-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: $color-dark-surface-container-high;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-empty-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
// Integrated mode styles (inside reaction bar)
|
||||
&.integrated {
|
||||
position: relative !important;
|
||||
width: 320px !important;
|
||||
height: 400px !important;
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: $color-dark-surface-container;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
@use "../common/material" as *;
|
||||
|
||||
.reply-dialog .dialog-content {
|
||||
width: 300px;
|
||||
overflow-x:hidden;
|
||||
|
||||
.reply-preview-dialog {
|
||||
margin-bottom: 1rem;
|
||||
padding: 16px;
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 16px;
|
||||
|
||||
.reply-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
.reply-username {
|
||||
font-weight: 600;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export type AlertType = "success" | "danger"
|
||||
|
||||
export interface Alert {
|
||||
type: AlertType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
||||
return (
|
||||
<div>
|
||||
{alerts.slice(-3).map((alert, i) => {
|
||||
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useAppState } from "../../state";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Reaction } from "../../../core/types";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
reactions?: Reaction[];
|
||||
onReactionClick: (emoji: string) => void;
|
||||
messageId?: number; // Add messageId to ensure unique keys
|
||||
}
|
||||
|
||||
export function MessageReactions({ 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
|
||||
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]);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Message } from "../../../core/types";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
import { MaterialTextField } from "../core/TextField";
|
||||
|
||||
interface ReplyMessageDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
replyToMessage: Message | null;
|
||||
onSendReply: (content: string, replyToId: number) => void;
|
||||
}
|
||||
|
||||
export function ReplyMessageDialog({ isOpen, onOpenChange, replyToMessage, onSendReply }: ReplyMessageDialogProps) {
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (replyToMessage) {
|
||||
setReplyContent("");
|
||||
}
|
||||
}, [replyToMessage]);
|
||||
|
||||
const handleSendReply = () => {
|
||||
if (replyToMessage && replyContent.trim()) {
|
||||
onSendReply(replyContent.trim(), replyToMessage.id);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
setReplyContent("");
|
||||
};
|
||||
|
||||
if (!replyToMessage) return null;
|
||||
|
||||
return (
|
||||
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc className="reply-dialog">
|
||||
<div className="dialog-content">
|
||||
<h3>Ответить на сообщение</h3>
|
||||
<div className="reply-preview-dialog">
|
||||
<div className="reply-content">
|
||||
<span className="reply-username">{replyToMessage.username}</span>
|
||||
<span className="reply-text">{replyToMessage.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
<MaterialTextField
|
||||
value={replyContent}
|
||||
onInput={(e) => setReplyContent((e.target as HTMLInputElement).value)}
|
||||
label="Reply"
|
||||
variant="outlined"
|
||||
placeholder="Type your reply..."
|
||||
maxlength={1000} />
|
||||
<div className="dialog-actions">
|
||||
<mdui-button onClick={handleCancel} variant="outlined">Cancel</mdui-button>
|
||||
<mdui-button onClick={handleSendReply}>Send Reply</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { LeftPanel } from "../components/chat/LeftPanel";
|
||||
import { RightPanel } from "../components/chat/RightPanel";
|
||||
|
||||
export default function ChatScreen() {
|
||||
return (
|
||||
<div id="chat-interface">
|
||||
<div className="all-container">
|
||||
<LeftPanel />
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export default function DownloadAppScreen() {
|
||||
return (
|
||||
<div className="download-app-screen">
|
||||
<div className="inner">
|
||||
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
|
||||
<p>
|
||||
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
|
||||
вам нужно скачать приложение мессенджера.
|
||||
</p>
|
||||
|
||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
||||
<mdui-button>Скачать на GitHub</mdui-button>
|
||||
</a>
|
||||
|
||||
<p>
|
||||
Если возникнут сложности или есть вопросы, нажмите кнопку!
|
||||
</p>
|
||||
|
||||
<a href="https://t.me/denis0001-dev">
|
||||
<mdui-button>Написать в поддержку</mdui-button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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 { useRef } from "react";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import { useAppState } from "../state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MaterialTextField } from "../components/core/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "../../utils/push-notifications";
|
||||
import { isElectron } from "../../electron/electron";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
|
||||
<div className="auth-body">
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: LoginRequest = {
|
||||
username: username,
|
||||
password: password
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
|
||||
// Initialize notifications
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(data.token);
|
||||
|
||||
// For Electron, start the notification receiver
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
console.log("Notifications enabled");
|
||||
} else {
|
||||
console.log("Notification permission denied");
|
||||
}
|
||||
} else {
|
||||
console.log("Notifications not supported");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed:", e);
|
||||
}
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
id="login-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
required
|
||||
ref={usernameElement} />
|
||||
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
id="login-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
|
||||
<mdui-button type="submit">Войти</mdui-button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Ещё нет аккаунта?
|
||||
<a
|
||||
href="#"
|
||||
className="link"
|
||||
onClick={() => navigate("/register")}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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 { useRef } from "react";
|
||||
import { TextField } from "mdui/components/text-field";
|
||||
import type { ErrorResponse, RegisterRequest, LoginResponse } from "../../core/types";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { useAppState } from "../state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MaterialTextField } from "../components/core/TextField";
|
||||
import { ensureKeysOnLogin } from "../../auth/crypto";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
const confirmPasswordElement = useRef<TextField>(null);
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
|
||||
<div className="auth-body">
|
||||
<AlertsContainer alerts={alerts} />
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password || !confirmPassword) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showAlert("danger", "Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 5 || password.length > 50) {
|
||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: RegisterRequest = {
|
||||
username: username,
|
||||
password: password,
|
||||
confirm_password: confirmPassword
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: LoginResponse = await response.json();
|
||||
// Store the JWT token first
|
||||
setUser(data.token, data.user);
|
||||
|
||||
// Setup keys with the token we just received
|
||||
try {
|
||||
await ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
} else {
|
||||
const data: ErrorResponse = await response.json();
|
||||
showAlert("danger", data.message || "Ошибка при регистрации");
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert("danger", "Ошибка соединения с сервером");
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
id="register-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
maxlength={20}
|
||||
counter
|
||||
required
|
||||
ref={usernameElement} />
|
||||
<MaterialTextField
|
||||
label="Пароль"
|
||||
id="register-password"
|
||||
name="password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
<MaterialTextField
|
||||
label="Подтвердите пароль"
|
||||
id="register-confirm-password"
|
||||
name="confirm_password"
|
||||
variant="outlined"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={confirmPasswordElement} />
|
||||
|
||||
<mdui-button type="submit">Зарегистрироваться</mdui-button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<p>
|
||||
Уже есть аккаунт?
|
||||
<a
|
||||
href="#"
|
||||
id="login-link"
|
||||
className="link"
|
||||
onClick={() => navigate("/login")}>
|
||||
Войдите
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -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,16 +1,17 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "./app/ui/components/Alerts";
|
||||
import { AuthContainer, AuthHeader } from "./app/ui/components/Auth";
|
||||
import type { ErrorResponse, LoginRequest, LoginResponse } from "./app/core/types";
|
||||
import { ensureKeysOnLogin } from "./app/auth/crypto";
|
||||
import { API_BASE_URL } from "./app/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 "./app/ui/state";
|
||||
import { MaterialTextField } from "./app/ui/components/core/TextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "./app/utils/push-notifications";
|
||||
import { isElectron } from "./app/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";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useImmer } from "use-immer";
|
||||
import { AuthContainer, AuthHeader } from "./app/ui/components/Auth";
|
||||
import { AlertsContainer, type Alert, type AlertType } from "./app/ui/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, LoginResponse } from "./app/core/types";
|
||||
import { API_BASE_URL } from "./app/core/config";
|
||||
import { useAppState } from "./app/ui/state";
|
||||
import { MaterialTextField } from "./app/ui/components/core/TextField";
|
||||
import { ensureKeysOnLogin } from "./app/auth/crypto";
|
||||
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";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+64
-161
@@ -1,62 +1,7 @@
|
||||
@use "common/material" as *;
|
||||
@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;
|
||||
}
|
||||
|
||||
// Reaction bar styles (standalone)
|
||||
.reaction-bar {
|
||||
background: $color-dark-surface-container;
|
||||
@@ -138,6 +83,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: $color-dark-surface;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem 0;
|
||||
min-width: 160px;
|
||||
z-index: 1000;
|
||||
|
||||
&.entering {
|
||||
animation: fadeInDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-left {
|
||||
animation: fadeInLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up {
|
||||
animation: fadeInUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.entering-up-left {
|
||||
animation: fadeInUpLeft 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
animation: fadeOutUp 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-left {
|
||||
animation: fadeOutRight 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up {
|
||||
animation: fadeOutDown 0.2s ease forwards;
|
||||
}
|
||||
|
||||
&.closing-up-left {
|
||||
animation: fadeOutDownRight 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-dark-surface-container;
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 1.1rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reaction bar inside context menu wrapper
|
||||
.context-menu-reaction-bar {
|
||||
display: flex;
|
||||
@@ -192,16 +199,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiMenuEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-bar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -273,100 +270,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile responsive
|
||||
@media (max-width: 768px) {
|
||||
.reaction-bar {
|
||||
@@ -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,14 +1,14 @@
|
||||
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";
|
||||
|
||||
export interface DMUser extends User {
|
||||
lastMessage?: string;
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
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 default function useProfile() {
|
||||
const { user } = useAppState();
|
||||
@@ -1,14 +1,14 @@
|
||||
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";
|
||||
|
||||
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LeftPanel } from "./app/ui/components/chat/LeftPanel";
|
||||
import { RightPanel } from "./app/ui/components/chat/RightPanel";
|
||||
import { LeftPanel } from "./left/LeftPanel";
|
||||
import { RightPanel } from "./right/RightPanel";
|
||||
import "@/pages/chat/css/chat.scss";
|
||||
|
||||
export default function ChatPage() {
|
||||
return (
|
||||
+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();
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useAppState } from "../../state";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
|
||||
export function ChatTabs() {
|
||||
const { chat, setActiveTab, switchToPublicChat } = useAppState();
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDM, type DMUser } 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();
|
||||
+6
-6
@@ -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 } from "@/pages/chat/state";
|
||||
|
||||
function BottomAppBar() {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Size2D, Rect } from "../../../core/types";
|
||||
import type { Size2D, Rect } from "@/core/types";
|
||||
|
||||
interface ImageCropperProps {
|
||||
onCrop: (croppedImageData: string) => void;
|
||||
+5
-5
@@ -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();
|
||||
+7
-7
@@ -1,12 +1,12 @@
|
||||
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 { 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");
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useRef } 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 { 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";
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { useAppState } from "../../state";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
|
||||
export function ChatMainHeader() {
|
||||
const { currentChat } = useAppState().chat;
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
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 { request } from "../../../core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "../../../core/types";
|
||||
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[];
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
||||
import type { Size2D } from "../../../core/types";
|
||||
import type { Size2D } from "@/core/types";
|
||||
|
||||
interface BaseEmojiMenuProps {
|
||||
isOpen: boolean;
|
||||
+126
-12
@@ -1,19 +1,133 @@
|
||||
import { formatTime, id } 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 { 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";
|
||||
import { MessageReactions } from "./MessageReactions";
|
||||
|
||||
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
|
||||
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]);
|
||||
|
||||
// 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;
|
||||
@@ -375,7 +489,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
||||
</mdui-list>
|
||||
)}
|
||||
|
||||
<MessageReactions
|
||||
<Reactions
|
||||
reactions={message.reactions}
|
||||
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
|
||||
messageId={message.id}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Message, Size2D } from "../../../core/types";
|
||||
import type { Message, Size2D } from "@/core/types";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
@@ -274,7 +274,7 @@ export function MessageContextMenu({
|
||||
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
|
||||
style={isEmojiMenuExpanded && !expandUpward ? {
|
||||
position: 'fixed',
|
||||
top: `${(-(contextMenuHeight || 0) + 5)}px`,
|
||||
top: `${(-(contextMenuHeight || 0) + 95)}px`,
|
||||
width: '320px',
|
||||
height: '400px',
|
||||
zIndex: 1001
|
||||
+7
-7
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppState } from "../../state";
|
||||
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
|
||||
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 "../../../resources/images/default-avatar.png";
|
||||
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
|
||||
import type { DMPanel } from "../../panels/DMPanel";
|
||||
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;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useAppState } from "../../state";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MessagePanelRenderer } from "./MessagePanelRenderer";
|
||||
|
||||
export function RightPanel() {
|
||||
+5
-5
@@ -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;
|
||||
+3
-3
@@ -6,9 +6,9 @@ import {
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope
|
||||
} from "../../api/dmApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
} from "../../../../../core/api/dmApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||
import type { UserState } from "@/pages/chat/state";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { Message, WebSocketMessage } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
import type { Message, WebSocketMessage } from "@/core/types";
|
||||
import type { UserState } from "@/pages/chat/state";
|
||||
|
||||
export interface MessagePanelState {
|
||||
id: string;
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { getAuthHeaders } from "../../auth/api";
|
||||
import { request } from "../../core/websocket";
|
||||
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types";
|
||||
import type { UserState } from "@/pages/chat/state";
|
||||
|
||||
export class PublicChatPanel extends MessagePanel {
|
||||
private messagesLoaded: boolean = false;
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
import "./download-app.scss";
|
||||
|
||||
export default function DownloadAppPage() {
|
||||
return (
|
||||
<div className="download-app-screen">
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useAppState } from "./app/ui/state";
|
||||
import { isElectron } from "./app/electron/electron";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import "./home.scss";
|
||||
|
||||
function GitHubLink({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@use "common/material" as *;
|
||||
@use "../../css/material" as *;
|
||||
|
||||
.homepage {
|
||||
min-height: 100vh;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./not-found.scss";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user