This commit is contained in:
2025-10-19 20:31:29 +03:00
Unverified
parent 66f0b756a0
commit f5527ca14e
67 changed files with 891 additions and 902 deletions
+2 -2
View File
@@ -9,13 +9,13 @@ interface ProtectedRouteProps {
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user } = useAppState();
const navigate = useNavigate();
useEffect(() => {
if (!user.authToken) {
navigate("/login");
return;
}
}, [user.authToken, user.currentUser, navigate]);
return <>{children}</>;
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
return (
<div className="auth-header">
<h2>
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
{title}
</h2>
<p>{subtitle}</p>
+13 -13
View File
@@ -33,19 +33,19 @@ export default function LoginPage() {
<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,
@@ -59,12 +59,12 @@ export default function LoginPage() {
},
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);
@@ -73,19 +73,19 @@ export default function LoginPage() {
}
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");
@@ -113,7 +113,7 @@ export default function LoginPage() {
autocomplete="username"
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="login-password"
@@ -128,13 +128,13 @@ export default function LoginPage() {
<mdui-button type="submit">Войти</mdui-button>
</form>
<div className="text-center">
<p>
Ещё нет аккаунта?
Ещё нет аккаунта?
<a
href="#"
className="link"
className="link"
onClick={() => navigate("/register")}>
Зарегистрируйтесь
</a>
+29 -29
View File
@@ -32,41 +32,41 @@ export default function RegisterPage() {
<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: {
@@ -74,12 +74,12 @@ export default function RegisterPage() {
},
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);
@@ -97,9 +97,9 @@ export default function RegisterPage() {
}
}}>
<MaterialTextField
label="Имя пользователя"
id="register-username"
name="username"
label="Имя пользователя"
id="register-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
@@ -108,22 +108,22 @@ export default function RegisterPage() {
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="register-password"
name="password"
variant="outlined"
type="password"
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"
label="Подтвердите пароль"
id="register-confirm-password"
name="confirm_password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
@@ -132,14 +132,14 @@ export default function RegisterPage() {
<mdui-button type="submit">Зарегистрироваться</mdui-button>
</form>
<div className="text-center">
<p>
Уже есть аккаунт?
<a
href="#"
id="login-link"
className="link"
Уже есть аккаунт?
<a
href="#"
id="login-link"
className="link"
onClick={() => navigate("/login")}>
Войдите
</a>
+2 -2
View File
@@ -18,7 +18,7 @@
max-width: 450px;
overflow: hidden;
}
.auth-header {
margin: 0;
padding: 16px;
@@ -36,7 +36,7 @@
justify-content: center;
}
}
.auth-body {
padding: 25px;
padding-bottom: 16px;
+18 -18
View File
@@ -9,10 +9,10 @@
display: flex;
flex-direction: column;
user-select: none;
// Base transition for all properties
transition: all 0.4s $transition;
// Disable all transitions while dragging for immediate feedback
&.dragging {
transition: none !important;
@@ -100,7 +100,7 @@
.call-header {
padding: 12px;
min-height: auto;
.call-header-info {
.username {
font-size: 14px;
@@ -170,7 +170,7 @@
// Dynamic gradients based on call state
&.gradient-calling {
border-color: rgba(255, 193, 7, 0.5);
&::before {
content: '';
position: absolute;
@@ -178,9 +178,9 @@
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.12) 0%,
rgba(255, 152, 0, 0.12) 50%,
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.12) 0%,
rgba(255, 152, 0, 0.12) 50%,
rgba(255, 193, 7, 0.12) 100%);
border-radius: inherit;
animation: pulse-gradient 2s ease-in-out infinite;
@@ -190,7 +190,7 @@
&.gradient-connecting {
border-color: rgba(33, 150, 243, 0.5);
&::before {
content: '';
position: absolute;
@@ -198,9 +198,9 @@
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(33, 150, 243, 0.12) 0%,
rgba(63, 81, 181, 0.12) 50%,
background: linear-gradient(135deg,
rgba(33, 150, 243, 0.12) 0%,
rgba(63, 81, 181, 0.12) 50%,
rgba(33, 150, 243, 0.12) 100%);
border-radius: inherit;
animation: connecting-gradient 1.5s ease-in-out infinite;
@@ -210,7 +210,7 @@
&.gradient-active {
border-color: rgba(76, 175, 80, 0.5);
&::before {
content: '';
position: absolute;
@@ -218,9 +218,9 @@
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(76, 175, 80, 0.12) 0%,
rgba(56, 142, 60, 0.12) 50%,
background: linear-gradient(135deg,
rgba(76, 175, 80, 0.12) 0%,
rgba(56, 142, 60, 0.12) 50%,
rgba(76, 175, 80, 0.12) 100%);
border-radius: inherit;
animation: active-gradient 3s ease-in-out infinite;
@@ -288,7 +288,7 @@
font-size: 24px;
display: inline-block;
animation: emoji-pulse 2s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
@@ -355,7 +355,7 @@
overflow-y: auto;
overflow-x: hidden;
padding: 5px;
// Custom scrollbar
&::-webkit-scrollbar {
width: 6px;
@@ -369,7 +369,7 @@
&::-webkit-scrollbar-thumb {
background: rgba($color-dark-primary, 0.5);
border-radius: 3px;
&:hover {
background: rgba($color-dark-primary, 0.7);
}
+10 -10
View File
@@ -19,12 +19,12 @@
display: flex;
align-items: flex-start;
gap: 16px;
mdui-icon {
align-self: center;
box-sizing: content-box;
}
.reply-cancel {
margin-left: auto;
}
@@ -45,13 +45,13 @@
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;
@@ -59,13 +59,13 @@
transition: color 0.2s ease;
flex-shrink: 0;
align-self: flex-end;
&:hover {
color: $color-dark-primary;
}
}
}
.message-input {
flex: 1;
padding: 20px 0;
@@ -81,7 +81,7 @@
font-size: 13pt;
height: 100%;
width: 100%;
&::placeholder {
color: $color-dark-on-surface-variant;
opacity: 0.7;
@@ -104,7 +104,7 @@
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);
@@ -156,7 +156,7 @@
overflow-y: hidden;
scroll-behavior: smooth;
padding: 8px;
&::-webkit-scrollbar {
height: 4px;
}
@@ -231,7 +231,7 @@
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 6px;
}
+32 -32
View File
@@ -12,7 +12,7 @@
transition: all 0.15s ease;
backdrop-filter: blur(8px);
transform: translateY(0);
&.closing {
opacity: 0;
transform: scale(0.8);
@@ -32,50 +32,50 @@
.context-menu-wrapper {
position: relative;
display: block;
// Animation states
&.entering {
opacity: 0;
transform: scale(0.8);
animation: contextMenuEnter 0.2s ease forwards;
}
&.entering-left {
opacity: 0;
transform: translateX(-20px) scale(0.8);
animation: contextMenuEnterLeft 0.2s ease forwards;
}
&.entering-up {
opacity: 0;
transform: translateY(20px) scale(0.8);
animation: contextMenuEnterUp 0.2s ease forwards;
}
&.entering-up-left {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.8);
animation: contextMenuEnterUpLeft 0.2s ease forwards;
}
&.closing {
opacity: 1;
transform: scale(1);
animation: contextMenuClose 0.2s ease forwards;
}
&.closing-left {
opacity: 1;
transform: translateX(0) scale(1);
animation: contextMenuCloseLeft 0.2s ease forwards;
}
&.closing-up {
opacity: 1;
transform: translateY(0) scale(1);
animation: contextMenuCloseUp 0.2s ease forwards;
}
&.closing-up-left {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
@@ -90,39 +90,39 @@
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;
@@ -132,11 +132,11 @@
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;
@@ -158,31 +158,31 @@
justify-content: center;
margin-bottom: 10px;
transition: width 0.3s ease-out, height 0.3s ease-out;
&.left {
left: 0;
transform: translateX(0);
}
&.right {
right: 0;
transform: translateX(0);
}
&.expanded {
padding: 0;
overflow: hidden;
width: 320px;
height: 400px;
border-radius: 16px;
// Default: expand downward from the reaction bar's bottom edge
position: absolute;
bottom: auto;
top: 0;
left: 0;
transform: translateY(0);
&.expand-upward {
// Expand upward from the reaction bar's top edge
bottom: 100%;
@@ -203,7 +203,7 @@
align-items: center;
gap: 4px;
transition: opacity 0.3s ease-out;
&.faded {
opacity: 0;
}
@@ -221,13 +221,13 @@
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
font-size: 18px;
&:hover {
background: var(--mdui-color-surface-container-high);
transform: scale(1.3);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
@@ -245,25 +245,25 @@
background: var(--mdui-color-surface);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
background: var(--mdui-color-surface-container-high);
border-color: var(--mdui-color-primary);
transform: scale(1.1);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
.material-symbols {
font-size: 18px;
color: var(--mdui-color-on-surface);
transition: transform 0.2s ease;
}
&:hover .material-symbols {
transform: rotate(90deg);
}
+2 -2
View File
@@ -6,7 +6,7 @@
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;
@@ -14,7 +14,7 @@
left: 0;
right: 0;
bottom: 0;
background:
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%);
+6 -6
View File
@@ -111,7 +111,7 @@
.product-name {
flex-grow: 1;
}
.profile {
font-size: 24px;
display: flex;
@@ -125,7 +125,7 @@
border: solid 2px $color-dark-on-surface-variant;
padding: 5px;
border-radius: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.241);
}
@@ -209,18 +209,18 @@
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;
@@ -229,7 +229,7 @@
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);
@@ -26,21 +26,21 @@
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%);
}
+9 -9
View File
@@ -37,11 +37,11 @@
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 100%;
height: 100%;
@@ -69,7 +69,7 @@
margin: 10px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
@@ -102,7 +102,7 @@
a {
text-decoration: none;
}
.attachement-image {
max-width: 200px;
border-radius: 8px;
@@ -199,7 +199,7 @@
border: 1px solid rgba($color-dark-outline-variant, 0.4);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
@@ -211,13 +211,13 @@
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
}
.message-time {
color: $color-dark-on-surface-variant;
font-weight: 500;
@@ -236,7 +236,7 @@
border: 1px solid rgba($color-dark-primary, 0.5);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
@@ -248,7 +248,7 @@
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
@@ -20,7 +20,7 @@
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
&.open {
opacity: 1;
visibility: visible;
@@ -42,26 +42,26 @@
transform: scale(0.9);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
&.open {
transform: scale(1);
opacity: 1;
}
.profile-dialog-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
.profile-picture-section {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 16px;
.profile-picture {
width: 120px;
height: 120px;
@@ -69,7 +69,7 @@
object-fit: cover;
border: 3px solid $color-dark-outline;
}
.profile-picture-edit-overlay {
position: absolute;
top: 0;
@@ -84,16 +84,16 @@
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
.username-section {
text-align: center;
.username-input {
background: none;
border: none;
@@ -114,24 +114,24 @@
align-items: center;
justify-content: center;
gap: 8px;
.online-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color-dark-primary;
&.offline {
background: $color-dark-on-surface-variant;
}
}
.status-text {
font-size: 0.875rem;
color: $color-dark-on-surface;
}
}
.profile-sections {
margin: 16px;
border-radius: 24px;
@@ -161,7 +161,7 @@
font-size: small;
color: $color-dark-on-surface-variant;
}
.value {
color: $color-dark-on-surface;
font-size: medium;
@@ -182,7 +182,7 @@
}
}
}
.profile-dialog-fab {
position: absolute;
bottom: 24px;
@@ -190,7 +190,7 @@
z-index: 1002;
transform: translateY(100px);
transition: transform 0.3s ease;
&.visible {
transform: translateY(0);
}
@@ -89,11 +89,11 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
backdrop-filter: blur(20px);
.file-overlay-wrapper {
@@ -40,7 +40,7 @@
flex: 1;
overflow-y: auto;
position: relative;
.settings-panel {
display: flex;
flex-direction: column;
@@ -53,43 +53,43 @@
top: 0;
left: 0;
width: 100%;
&.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
position: relative;
}
h3 {
margin: 0 0 16px 0;
color: $color-dark-on-surface;
}
mdui-text-field,
mdui-select,
mdui-switch,
mdui-button {
margin-bottom: 8px;
}
mdui-switch {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid $color-dark-outline;
&:last-child {
border-bottom: none;
}
}
p {
margin: 8px 0;
color: $color-dark-on-surface-variant;
}
mdui-linear-progress {
margin: 16px 0;
}
+19 -19
View File
@@ -14,21 +14,21 @@ let globalLocalScreenShareRef = createRef<HTMLVideoElement>();
let globalRemoteScreenShareRef = createRef<HTMLVideoElement>();
export default function useCall() {
const {
chat,
startCall,
endCall,
const {
chat,
startCall,
endCall,
setCallStatus,
toggleMute,
toggleVideo,
toggleMute,
toggleVideo,
toggleScreenShare,
setCallEncryption,
setCallSessionKeyHash,
setCallSessionKeyHash,
setRemoteVideoEnabled,
setRemoteScreenSharing,
user
user
} = useAppState();
const remoteAudioRef = globalRemoteAudioRef;
const localVideoRef = globalLocalVideoRef;
const remoteVideoRef = globalRemoteVideoRef;
@@ -37,12 +37,12 @@ export default function useCall() {
useEffect(() => {
// Initialize call signaling handler
const signalingHandler = new CallSignalingHandler(() => ({
const signalingHandler = new CallSignalingHandler(() => ({
receiveCall: (userId: number, username: string) => {
// Use the receiveCall function from state
const state = useAppState.getState();
state.receiveCall(userId, username);
},
},
endCall,
setCallSessionKeyHash,
setRemoteVideoEnabled,
@@ -195,11 +195,11 @@ export default function useCall() {
async function requestAudioPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
video: false
});
// Stop the stream immediately as we just needed permission
stream.getTracks().forEach(track => track.stop());
return true;
@@ -211,7 +211,7 @@ export default function useCall() {
async function initiateCall(userId: number, username: string) {
const hasPermission = await requestAudioPermissions();
if (!hasPermission) {
return;
}
@@ -221,7 +221,7 @@ export default function useCall() {
// Generate call session key and emojis
sessionKey = await generateCallSessionKey();
const emojis = generateCallEmojis(sessionKey.hash);
// Start the call in state
startCall(userId, username);
setCallStatus("calling");
@@ -234,11 +234,11 @@ export default function useCall() {
// Initiate WebRTC call
const success = await WebRTC.initiateCall(userId, username);
if (success && sessionKey) {
// Set the session key for ourselves (initiator)
await WebRTC.setSessionKey(userId, sessionKey.key);
// Send session key hash to the receiver for visual verification
await WebRTC.sendCallSessionKey(userId, sessionKey.hash);
// Also wrap and send the actual session key for E2EE media
@@ -255,7 +255,7 @@ export default function useCall() {
setCallStatus("connecting");
const success = await WebRTC.acceptCall(chat.call.remoteUserId);
if (!success) {
endCall();
}
+47 -47
View File
@@ -1,9 +1,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "@/pages/chat/state";
import {
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
import {
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
fetchDMConversations,
type DMConversationResponse
@@ -19,9 +19,9 @@ export interface DMUser extends User {
// Utility function for consistent username formatting in DM messages
export function formatDMUsername(
senderId: number,
_recipientId: number,
currentUserId: number,
senderId: number,
_recipientId: number,
currentUserId: number,
otherUsername: string
): string {
const isFromCurrentUser = senderId === currentUserId;
@@ -30,15 +30,15 @@ export function formatDMUsername(
// Utility function for consistent message content formatting
export function formatDMMessageContent(
content: string,
senderId: number,
content: string,
senderId: number,
currentUserId: number
): string {
const isFromCurrentUser = senderId === currentUserId;
const prefix = isFromCurrentUser ? "Вы: " : "";
const maxContentLength = 50 - prefix.length;
const truncatedContent = content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
const truncatedContent = content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
return prefix + truncatedContent;
}
@@ -66,7 +66,7 @@ export function useDM() {
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
try {
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
console.log(lastPlaintext);
@@ -84,10 +84,10 @@ export function useDM() {
}
// Update user state
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
unreadCount,
publicKey
@@ -102,24 +102,24 @@ export function useDM() {
// Load DM conversations when chats tab is active
const loadUsers = useCallback(async () => {
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
usersLoadedRef.current = true;
setIsLoadingUsers(true);
try {
const conversations = await fetchDMConversations(user.authToken);
// Process conversations and decrypt last messages
const dmUsersWithState: DMUser[] = await Promise.all(
conversations.map(async (conv: DMConversationResponse) => {
let lastMessageContent: string | undefined = undefined;
if (conv.lastMessage) {
try {
// Get the public key for the other user
const otherUserId = conv.lastMessage.senderId === user.currentUser?.id
? conv.lastMessage.recipientId
const otherUserId = conv.lastMessage.senderId === user.currentUser?.id
? conv.lastMessage.recipientId
: conv.lastMessage.senderId;
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
// Decrypt the last message
@@ -131,7 +131,7 @@ export function useDM() {
console.error("Failed to decrypt last message for user", conv.user.id, error);
}
}
return {
...conv.user,
unreadCount: conv.unreadCount,
@@ -140,10 +140,10 @@ export function useDM() {
};
})
);
setDmUsersState(dmUsersWithState);
setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user));
} catch (error) {
console.error("Failed to load DM conversations:", error);
} finally {
@@ -159,7 +159,7 @@ export function useDM() {
// Load DM history for active conversation
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
if (!user.authToken || isLoadingHistory) return;
setIsLoadingHistory(true);
try {
const messages = await fetchDMHistory(userId, user.authToken, 50);
@@ -171,7 +171,7 @@ export function useDM() {
const text = await decryptDm(env, publicKey);
const isAuthor = env.senderId !== userId;
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
decryptedMessages.push({
id: env.id,
content: text,
@@ -196,7 +196,7 @@ export function useDM() {
if (maxIncomingId > 0) {
setLastReadId(userId, maxIncomingId);
// Clear unread count
setDmUsersState(prev => prev.map(u =>
setDmUsersState(prev => prev.map(u =>
u.id === userId ? { ...u, unreadCount: 0 } : u
));
}
@@ -257,17 +257,17 @@ export function useDM() {
try {
const conversations = await fetchDMConversations(user.authToken);
const userConversation = conversations.find(conv => conv.user.id === userId);
if (userConversation) {
let lastMessageContent: string | undefined = undefined;
if (userConversation.lastMessage) {
try {
// Get the public key for the other user
const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id
? userConversation.lastMessage.recipientId
const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id
? userConversation.lastMessage.recipientId
: userConversation.lastMessage.senderId;
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
// Decrypt the last message
@@ -279,7 +279,7 @@ export function useDM() {
console.error("Failed to decrypt last message for user", userId, error);
}
}
// Update the specific user in the state
setDmUsersState(prev => prev.map(u => {
if (u.id === userId) {
@@ -311,13 +311,13 @@ export function useDM() {
const msg = JSON.parse(e.data);
if (msg.type === "dmNew") {
const { senderId, recipientId, ...envelope } = msg.data;
// Update conversation list (not active conversation - that's handled by DMPanel)
if (!user.currentUser?.id) {
return;
}
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
// Update unread count and last message preview
try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
@@ -326,11 +326,11 @@ export function useDM() {
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content;
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
lastMessage: formattedMessage,
publicKey
@@ -343,7 +343,7 @@ export function useDM() {
}
} else if (msg.type === "dmEdited") {
const { id, senderId, recipientId, ...envelope } = msg.data;
// Update last message preview for conversation list
if (!user.currentUser?.id) {
return;
@@ -356,10 +356,10 @@ export function useDM() {
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content;
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
lastMessage: formattedMessage,
publicKey
}
@@ -371,7 +371,7 @@ export function useDM() {
}
} else if (msg.type === "dmDeleted") {
const { senderId, recipientId } = msg.data;
// Reload only the specific user's conversation
if (!user.currentUser?.id) return;
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
+42 -42
View File
@@ -27,7 +27,7 @@ export interface ProfileDialogData {
}
interface ActiveDM {
userId: number;
userId: number;
username: string;
publicKey: string | null
}
@@ -89,7 +89,7 @@ interface AppState {
applyPendingPanel: () => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
// Call state
startCall: (userId: number, username: string) => void;
endCall: () => void;
@@ -104,17 +104,17 @@ interface AppState {
setRemoteVideoEnabled: (enabled: boolean) => void;
setRemoteScreenSharing: (enabled: boolean) => void;
toggleCallMinimized: () => void;
// User state
user: UserState;
setUser: (token: string, user: User) => void;
logout: () => void;
restoreUserFromStorage: () => Promise<void>;
// Profile dialog state
setProfileDialog: (data: ProfileDialogData | null) => void;
closeProfileDialog: () => void;
// Online status and typing state
updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void;
addTypingUser: (userId: number, username: string) => void;
@@ -168,7 +168,7 @@ export const useAppState = create<AppState>((set, get) => ({
if (messageExists) {
return state; // Return unchanged state if message already exists
}
return {
chat: {
...state.chat,
@@ -179,7 +179,7 @@ export const useAppState = create<AppState>((set, get) => ({
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
chat: {
...state.chat,
messages: state.chat.messages.map(msg =>
messages: state.chat.messages.map(msg =>
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
)
}
@@ -220,7 +220,7 @@ export const useAppState = create<AppState>((set, get) => ({
activeDm: dm
}
})),
// User state
user: {
currentUser: null,
@@ -284,7 +284,7 @@ export const useAppState = create<AppState>((set, get) => ({
restoreUserFromStorage: async () => {
try {
const token = localStorage.getItem('authToken');
if (token) {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
headers: getAuthHeaders(token)
@@ -324,7 +324,7 @@ export const useAppState = create<AppState>((set, get) => ({
const initialized = await initialize();
if (initialized) {
await subscribe(token);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
@@ -345,7 +345,7 @@ export const useAppState = create<AppState>((set, get) => ({
localStorage.removeItem('currentUser');
}
},
// Panel management
setActivePanel: (panel: MessagePanel | null) => set((state) => ({
chat: {
@@ -377,15 +377,15 @@ export const useAppState = create<AppState>((set, get) => ({
pendingPanel: null
}
})),
switchToPublicChat: async (chatName: string) => {
const { user, chat } = get();
if (!user.authToken) return;
// Start chat switching animation
chat.setIsSwitching(true);
// Create or get public chat panel
let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) {
@@ -396,10 +396,10 @@ export const useAppState = create<AppState>((set, get) => ({
// Reset messages for the new chat
publicChatPanel.clearMessages();
}
// Activate panel
await publicChatPanel.activate();
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
@@ -408,19 +408,19 @@ export const useAppState = create<AppState>((set, get) => ({
activeTab: "chats"
}
}));
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
},
switchToDM: async (dmData: DMPanelData) => {
const { user, chat } = get();
if (!user.authToken) return;
// Start chat switching animation
chat.setIsSwitching(true);
// Create or get DM panel
let dmPanel = chat.dmPanel;
if (!dmPanel) {
@@ -430,13 +430,13 @@ export const useAppState = create<AppState>((set, get) => ({
// Reset messages for the new DM
dmPanel.clearMessages();
}
// Set DM data
dmPanel.setDMData(dmData);
// Activate panel
await dmPanel.activate();
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
@@ -450,7 +450,7 @@ export const useAppState = create<AppState>((set, get) => ({
activeTab: "chats"
}
}));
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
},
@@ -477,7 +477,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
endCall: () => set((state) => ({
chat: {
...state.chat,
@@ -499,7 +499,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setCallStatus: (status: CallStatus) => set((state) => ({
chat: {
...state.chat,
@@ -510,7 +510,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
toggleMute: () => set((state) => ({
chat: {
...state.chat,
@@ -520,7 +520,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
toggleCallMinimize: () => set((state) => ({
chat: {
...state.chat,
@@ -552,7 +552,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({
chat: {
...state.chat,
@@ -563,7 +563,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({
chat: {
...state.chat,
@@ -573,7 +573,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
toggleVideo: () => set((state) => ({
chat: {
...state.chat,
@@ -583,7 +583,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
toggleScreenShare: () => set((state) => ({
chat: {
...state.chat,
@@ -593,7 +593,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({
chat: {
...state.chat,
@@ -603,7 +603,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setRemoteScreenSharing: (enabled: boolean) => set((state) => ({
chat: {
...state.chat,
@@ -622,7 +622,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
// Profile dialog state management
setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({
chat: {
@@ -630,14 +630,14 @@ export const useAppState = create<AppState>((set, get) => ({
profileDialog: data
}
})),
closeProfileDialog: () => set((state) => ({
chat: {
...state.chat,
profileDialog: null
}
})),
// Online status and typing state management
updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({
chat: {
@@ -645,14 +645,14 @@ export const useAppState = create<AppState>((set, get) => ({
onlineStatuses: new Map(state.chat.onlineStatuses).set(userId, { online, lastSeen })
}
})),
addTypingUser: (userId: number, username: string) => set((state) => ({
chat: {
...state.chat,
typingUsers: new Map(state.chat.typingUsers).set(userId, username)
}
})),
removeTypingUser: (userId: number) => set((state) => {
const newTypingUsers = new Map(state.chat.typingUsers);
newTypingUsers.delete(userId);
@@ -663,7 +663,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
};
}),
setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => {
const newDmTypingUsers = new Map(state.chat.dmTypingUsers);
if (isTyping) {
+9 -9
View File
@@ -29,7 +29,7 @@ export function ProfileDialog() {
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.remove('open');
dialogRef.current.classList.remove('open');
// Wait for animation to complete before closing
setTimeout(() => {
setIsOpen(false);
@@ -119,13 +119,13 @@ export function ProfileDialog() {
const hasChanges = useMemo(() => {
if (!originalData || !currentData) return false;
// Normalize values for comparison (handle empty strings, undefined, null)
const normalizeValue = (value: string | undefined | null) => {
if (value === null || value === undefined) return "";
return value.trim();
};
return (
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
@@ -155,7 +155,7 @@ export function ProfileDialog() {
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.remove('open');
dialogRef.current.classList.remove('open');
// Wait for animation to complete before closing
setTimeout(() => {
closeProfileDialog();
@@ -232,7 +232,7 @@ export function ProfileDialog() {
// Update the original data to match current data
setOriginalData(currentData);
// Close dialog with animation after successful save
triggerCloseAnimation();
} catch (error) {
@@ -253,7 +253,7 @@ export function ProfileDialog() {
if (!isOpen || !currentData) return null;
return createPortal(
<div
<div
ref={backdropRef}
className="profile-dialog-backdrop"
onClick={handleBackdropClick}
@@ -262,7 +262,7 @@ export function ProfileDialog() {
<div className="profile-dialog-content">
{/* Profile Picture */}
<div className="profile-picture-section">
<img
<img
className="profile-picture"
src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture"
@@ -272,7 +272,7 @@ export function ProfileDialog() {
}}
/>
{currentData.isOwnProfile && (
<div
<div
className="profile-picture-edit-overlay"
onClick={handleProfilePictureClick}
>
@@ -296,7 +296,7 @@ export function ProfileDialog() {
)}
{/* Online Status */}
{currentData.userId && !currentData.isOwnProfile && (
{currentData?.userId && (
<div className="online-status-section">
<OnlineStatus userId={currentData.userId} />
</div>
@@ -29,7 +29,7 @@ export function ChatHeader() {
<div className="profile">
<a href="#" id="profile-open" onClick={handleProfileClick}>
<img
src={profilePictureUrl}
src={profilePictureUrl}
alt=""
id="preview1"
onError={() => setProfilePictureUrl(defaultAvatar)} />
+2 -2
View File
@@ -12,8 +12,8 @@ export function ChatTabs() {
return (
<div className="chat-tabs">
<mdui-tabs
value={chat.activeTab}
<mdui-tabs
value={chat.activeTab}
full-width
onChange={handleChange}>
<mdui-tab value="chats">
@@ -19,9 +19,9 @@ function BottomAppBar() {
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
<div style={{ flexGrow: 1 }}></div>
<mdui-button-icon
icon="logout--filled"
id="logout-btn"
<mdui-button-icon
icon="logout--filled"
id="logout-btn"
onClick={handleLogout}
title="Выйти"
></mdui-button-icon>
@@ -33,7 +33,7 @@ type ChatItem = PublicChat | DMConversation;
export function UnifiedChatsList() {
const { user, switchToPublicChat, switchToDM, chat } = useAppState();
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
const [publicChats] = useState<PublicChat[]>([
{ id: "general", name: "Общий чат", type: "public" },
{ id: "general2", name: "Общий чат 2", type: "public" }
@@ -54,7 +54,7 @@ export function UnifiedChatsList() {
const data = await response.json();
if (data.messages && data.messages.length > 0) {
const lastMessage = data.messages[data.messages.length - 1];
setLastMessages({
general: lastMessage,
general2: lastMessage
@@ -104,7 +104,7 @@ export function UnifiedChatsList() {
const handleWebSocketMessage = (e: MessageEvent) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "newMessage") {
const newMessage = msg.data as Message;
// Update all public chats with the new message
@@ -130,7 +130,7 @@ export function UnifiedChatsList() {
} else if (msg.type === "messageDeleted") {
const deletedMessageId = msg.data?.message_id;
let needsReload = false;
setLastMessages(prev => {
const updated = { ...prev };
publicChats.forEach(chat => {
@@ -141,7 +141,7 @@ export function UnifiedChatsList() {
});
return updated;
});
if (needsReload) {
loadLastMessages();
}
@@ -158,7 +158,7 @@ export function UnifiedChatsList() {
// Subscribe to online status for all DM users
useEffect(() => {
const dmUsers = allChats.filter(chat => chat.type === "dm") as DMConversation[];
// Subscribe to all DM users
dmUsers.forEach(dmUser => {
onlineStatusManager.subscribe(dmUser.id);
@@ -180,12 +180,12 @@ export function UnifiedChatsList() {
const isCurrentUser = lastMessage.username === user.currentUser?.username;
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
const maxContentLength = 50 - prefix.length;
const content = lastMessage.content.length > maxContentLength
? lastMessage.content.substring(0, maxContentLength) + "..."
const content = lastMessage.content.length > maxContentLength
? lastMessage.content.substring(0, maxContentLength) + "..."
: lastMessage.content;
return prefix + content;
};
@@ -198,7 +198,7 @@ export function UnifiedChatsList() {
if (!dmConversation.publicKey) {
const authToken = useAppState.getState().user.authToken;
if (!authToken) return;
const publicKey = await fetchUserPublicKey(dmConversation.id, authToken);
if (publicKey) {
dmConversation.publicKey = publicKey;
@@ -207,7 +207,7 @@ export function UnifiedChatsList() {
return;
}
}
await switchToDM({
userId: dmConversation.id,
username: dmConversation.username,
@@ -239,9 +239,9 @@ export function UnifiedChatsList() {
{formatPublicChatMessage(chat.id)}
</span>
)}
<img
src={defaultAvatar}
alt={chat.name}
<img
src={defaultAvatar}
alt={chat.name}
slot="icon"
style={{
width: "40px",
@@ -264,9 +264,9 @@ export function UnifiedChatsList() {
{chat.lastMessage || "Нет сообщений"}
</span>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img
src={chat.profile_picture || defaultAvatar}
alt={chat.username}
<img
src={chat.profile_picture || defaultAvatar}
alt={chat.username}
style={{
width: "40px",
height: "40px",
@@ -151,7 +151,7 @@ export function UsernameSearch() {
style={{ cursor: "pointer" }}
>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img
<img
src={searchUser.profile_picture || defaultAvatar}
alt={searchUser.username}
style={{
@@ -55,13 +55,13 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if click is within crop area
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
@@ -77,16 +77,16 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
const y = e.clientY - rect.top;
const newX = Math.max(
0,
0,
Math.min(
x - dragStart.x,
x - dragStart.x,
imageRef.current.naturalWidth - cropArea.width
)
);
const newY = Math.max(
0,
0,
Math.min(
y - dragStart.y,
y - dragStart.y,
imageRef.current.naturalHeight - cropArea.height
)
);
@@ -162,7 +162,7 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
ref={canvasRef}
width={400}
height={400}
style={{
style={{
cursor: isDragging ? 'grabbing' : 'grab',
border: '1px solid #ccc',
maxWidth: '100%',
@@ -33,22 +33,22 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const initialized = await initialize();
if (initialized) {
await subscribe(user.authToken);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
}
setPushNotificationsEnabled(true);
}
} else {
await unsubscribe();
// For Electron, stop the notification receiver
if (isElectron) {
stopElectronReceiver();
}
// Call API to unsubscribe on server (for web browsers)
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
@@ -71,63 +71,63 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
</div>
<div id="settings-menu">
<mdui-list>
<mdui-list-item
icon="notifications--filled"
rounded
<mdui-list-item
icon="notifications--filled"
rounded
active={activePanel === "notifications-settings"}
onClick={() => handlePanelChange("notifications-settings")}
style={{ cursor: "pointer" }}
>
Уведомления
</mdui-list-item>
<mdui-list-item
icon="palette--filled"
rounded
<mdui-list-item
icon="palette--filled"
rounded
active={activePanel === "appearance-settings"}
onClick={() => handlePanelChange("appearance-settings")}
style={{ cursor: "pointer" }}
>
Внешний вид
</mdui-list-item>
<mdui-list-item
icon="security--filled"
rounded
<mdui-list-item
icon="security--filled"
rounded
active={activePanel === "security-settings"}
onClick={() => handlePanelChange("security-settings")}
style={{ cursor: "pointer" }}
>
Безопасность
</mdui-list-item>
<mdui-list-item
icon="language--filled"
rounded
<mdui-list-item
icon="language--filled"
rounded
active={activePanel === "language-settings"}
onClick={() => handlePanelChange("language-settings")}
style={{ cursor: "pointer" }}
>
Язык
</mdui-list-item>
<mdui-list-item
icon="storage--filled"
rounded
<mdui-list-item
icon="storage--filled"
rounded
active={activePanel === "storage-settings"}
onClick={() => handlePanelChange("storage-settings")}
style={{ cursor: "pointer" }}
>
Хранилище
</mdui-list-item>
<mdui-list-item
icon="help--filled"
rounded
<mdui-list-item
icon="help--filled"
rounded
active={activePanel === "help-settings"}
onClick={() => handlePanelChange("help-settings")}
style={{ cursor: "pointer" }}
>
Помощь
</mdui-list-item>
<mdui-list-item
icon="info--filled"
rounded
<mdui-list-item
icon="info--filled"
rounded
active={activePanel === "about-settings"}
onClick={() => handlePanelChange("about-settings")}
style={{ cursor: "pointer" }}
@@ -139,7 +139,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3>
{pushSupported && (
<mdui-switch
<mdui-switch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
>
@@ -151,7 +151,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<mdui-switch>Уведомления о статусе</mdui-switch>
<mdui-switch checked>Email уведомления</mdui-switch>
</div>
<div id="appearance-settings" className={`settings-panel ${activePanel === "appearance-settings" ? "active" : ""}`}>
<h3>Внешний вид</h3>
<mdui-select label="Тема" variant="outlined">
@@ -165,14 +165,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<mdui-menu-item value="large">Большой</mdui-menu-item>
</mdui-select>
</div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<h3>Безопасность</h3>
<mdui-button variant="outlined">Изменить пароль</mdui-button>
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
<mdui-switch>Автоматический выход</mdui-switch>
</div>
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
<h3>Язык</h3>
<mdui-select label="Выберите язык" variant="outlined">
@@ -181,21 +181,21 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<mdui-menu-item value="es">Español</mdui-menu-item>
</mdui-select>
</div>
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
<h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value={25}></mdui-linear-progress>
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div>
<div id="help-settings" className={`settings-panel ${activePanel === "help-settings" ? "active" : ""}`}>
<h3>Помощь</h3>
<mdui-button variant="outlined">Руководство пользователя</mdui-button>
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button>
<mdui-button variant="outlined">FAQ</mdui-button>
</div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
<h3>О приложении</h3>
<p>Версия: 1.0.0</p>
@@ -25,16 +25,16 @@ interface ChatInputWrapperProps {
}
export function ChatInputWrapper(
{
onSendMessage,
onSaveEdit,
replyTo,
replyToVisible,
{
onSendMessage,
onSaveEdit,
replyTo,
replyToVisible,
onClearReply,
onCloseReply,
editingMessage,
editVisible = false,
onClearEdit,
onCloseReply,
editingMessage,
editVisible = false,
onClearEdit,
onCloseEdit,
onProvideFileAdder,
messagePanelRef,
@@ -77,7 +77,7 @@ export function ChatInputWrapper(
if (chatInputWrapperRef.current && messagePanelRef?.current) {
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
const panelRect = messagePanelRef.current.getBoundingClientRect();
// Position menu 10px from message panel edge and 10px above the chat input
// The animation will start 30px below this position
setEmojiMenuPosition({
@@ -207,9 +207,9 @@ export function ChatInputWrapper(
className="emoji-btn" />
</div>
<RichTextArea
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
text={message}
rows={1}
@@ -228,7 +228,7 @@ export function ChatInputWrapper(
<div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
</MaterialDialog>
<EmojiMenu
isOpen={emojiMenuOpen}
onClose={() => setEmojiMenuOpen(false)}
@@ -20,9 +20,9 @@ interface ChatMessagesProps {
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { user } = useAppState();
// Use prop messages (panels provide their own messages)
// Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
@@ -89,13 +89,13 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
async function handleReactionClick(messageId: number, emoji: string) {
if (!user.authToken) return;
try {
if (isDm) {
// For DM messages, we need to find the dm_envelope_id from the message
const message = messages.find(m => m.id === messageId);
const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id;
if (dmEnvelopeId) {
await request<AddDmReactionRequest["data"]>({
type: "addDmReaction",
@@ -131,8 +131,8 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
<Message
key={message.id}
message={message}
isAuthor={isDm ?
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
isAuthor={isDm ?
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(message.username === user.currentUser?.username)
}
onContextMenu={handleContextMenu}
@@ -142,7 +142,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
))}
{children}
</div>
<MaterialDialog
headline="Удалить сообщение?"
@@ -151,13 +151,13 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button>
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button>
</MaterialDialog>
{/* Context Menu */}
{contextMenu.message && (
<MessageContextMenu
message={contextMenu.message}
isAuthor={isDm ?
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
isAuthor={isDm ?
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(contextMenu.message.username === user.currentUser?.username)
}
onEdit={handleEdit}
+11 -11
View File
@@ -38,13 +38,13 @@ export function EmojiMenu(props: EmojiMenuProps) {
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
// Find which category is currently visible
for (const [categoryName, element] of categoryRefs.current) {
if (element) {
const rect = element.getBoundingClientRect();
const containerRect = scrollRef.current.getBoundingClientRect();
// Check if category header is in view
if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) {
if (activeCategory !== categoryName) {
@@ -60,9 +60,9 @@ export function EmojiMenu(props: EmojiMenuProps) {
function scrollToCategory(categoryName: string) {
const element = categoryRefs.current.get(categoryName);
if (element && scrollRef.current) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
@@ -72,7 +72,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
if (tabElement && tabsRef.current) {
const tabsRect = tabsRef.current.getBoundingClientRect();
const tabRect = tabElement.getBoundingClientRect();
// Check if tab is outside the visible area
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
tabElement.scrollIntoView({
@@ -116,7 +116,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
return (
<div
<div
ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
style={mode === "standalone" && position ? {
@@ -146,17 +146,17 @@ export function EmojiMenu(props: EmojiMenuProps) {
))}
</div>
</div>
<div
<div
ref={scrollRef}
className="emoji-grid"
onScroll={handleScroll}
>
{EMOJI_CATEGORIES.map((category) => {
const emojis = category.name === "recent" ? recentEmojis : category.emojis;
return (
<div
<div
key={category.name}
ref={(el) => {
if (el) categoryRefs.current.set(category.name, el);
+28 -28
View File
@@ -84,7 +84,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
// 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);
@@ -97,7 +97,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
}
}
});
return updated;
});
}, [reactions]);
@@ -112,7 +112,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
{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}`}
@@ -140,9 +140,9 @@ interface MessageProps {
}
interface Rect {
left: number;
top: number;
width: number;
left: number;
top: number;
width: number;
height: number
}
@@ -200,12 +200,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
console.warn("Conditions not met")
return null;
}
// Check if already decrypted
if (decryptedFiles.has(file.path)) {
return decryptedFiles.get(file.path) || null;
}
try {
// no-op decrypt indicator removed from UI
// Fetch encrypted file
@@ -213,32 +213,32 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
headers: getAuthHeaders(user.authToken!)
});
if (!response.ok) throw new Error("Failed to fetch file");
const encryptedData = await response.arrayBuffer();
// Get current user's keys
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Derive shared secret with the recipient's public key
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
// Derive wrapping key using the salt from the DM envelope
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Unwrap the message key
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
// Decrypt the file using the message key
const iv = new Uint8Array(encryptedData, 0, 12);
const ciphertext = new Uint8Array(encryptedData, 12);
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
// Create blob URL for download
const blob = new Blob([decrypted.buffer as ArrayBuffer]);
const url = URL.createObjectURL(blob);
updateDecryptedFiles(draft => {
draft.set(file.path, url);
});
@@ -390,7 +390,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
async function handleProfileClick() {
if (!user.authToken || !message.username) return;
try {
const userProfile = await fetchUserProfile(user.authToken, message.username);
if (userProfile) {
@@ -421,10 +421,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const emojiRegex = /^[\p{Emoji}]+$/u;
return messageText.length > 0 && emojiRegex.test(messageText) && messageText.length <= 4; // Most emojis are 1-4 characters
}, [messageText]);
return (
<>
<div
<div
className={`message ${isAuthor ? "sent" : "received"} ${isEmojiMessage ? "emoji-message" : ""} ${isSingleEmojiMessage ? "single-emoji" : ""}`}
data-id={message.id}
onContextMenu={handleContextMenu}
@@ -444,7 +444,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
<div className="message-inner">
{!isAuthor && !isDm && !isSingleEmojiMessage && (
<div
<div
className="message-username"
onClick={handleProfileClick}>
{message.username}
@@ -474,11 +474,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
<div className="attachment" key={idx}>
{isImage ? (
<div className="image-wrapper">
<img
<img
ref={(el) => {
if (el) imageRefs.current.set(file.path, el);
}}
src={imageSrc}
src={imageSrc}
alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)}
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
@@ -491,8 +491,8 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
)}
</div>
) : (
<a
href="#"
<a
href="#"
onClick={async (e) => {
e.preventDefault();
await downloadFile(file);
@@ -512,7 +512,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
</mdui-list>
)}
<Reactions
<Reactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
@@ -521,11 +521,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
)}
{isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator">
{message.runtimeData.sendingState.status === 'sending' && (
@@ -545,7 +545,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && createPortal(
<div
<div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
onClick={closeFullscreen}>
<img
@@ -21,12 +21,12 @@ export interface ContextMenuState {
position: Size2D;
}
export function MessageContextMenu({
message,
isAuthor,
onEdit,
onReply,
onDelete,
export function MessageContextMenu({
message,
isAuthor,
onEdit,
onReply,
onDelete,
onRetry,
onReactionClick,
position,
@@ -42,7 +42,7 @@ export function MessageContextMenu({
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
const [expandUpward, setExpandUpward] = useState(false);
const [contextMenuHeight, setContextMenuHeight] = useState<number | null>(null);
// Refs for measuring actual dimensions
const wrapperRef = useRef<HTMLDivElement>(null);
const reactionBarRef = useRef<HTMLDivElement>(null);
@@ -92,13 +92,13 @@ export function MessageContextMenu({
y = viewportHeight - sharedRect.height;
animation = 'entering-up';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
setReactionBarPosition(reactionPosition);
}
});
return () => cancelAnimationFrame(frameId);
}
}, [isOpen, position, isAuthor]);
@@ -146,7 +146,7 @@ export function MessageContextMenu({
// Set appropriate closing animation based on opening animation
const closingAnimation = animationClass.replace('entering', 'closing');
setAnimationClass(closingAnimation);
// Wait for animation to complete before calling onOpenChange
setTimeout(() => {
onOpenChange(false);
@@ -229,7 +229,7 @@ export function MessageContextMenu({
// Measure the actual dimensions of the reaction bar content
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
const wrapperRect = wrapperRef.current.getBoundingClientRect();
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
setContextMenuHeight(wrapperRect.height);
@@ -257,7 +257,7 @@ export function MessageContextMenu({
}
return isOpen && (
<div
<div
ref={wrapperRef}
className={`context-menu-wrapper ${animationClass}`}
style={{
@@ -267,7 +267,7 @@ export function MessageContextMenu({
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}>
{/* Reaction Bar */}
<div
ref={reactionBarRef}
@@ -303,8 +303,8 @@ export function MessageContextMenu({
</button>
</div>
) : (
<div
ref={emojiMenuRef}
<div
ref={emojiMenuRef}
className="emoji-menu-wrapper">
<EmojiMenu
isOpen={true}
@@ -317,12 +317,12 @@ export function MessageContextMenu({
</div>
{/* Context Menu */}
<div
<div
ref={contextMenuRef}
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
{actions.map((action, i) => (
action.show && (
<div
<div
className="context-menu-item"
onClick={action.onClick}
key={i}
@@ -33,7 +33,7 @@ function ChatHeaderText({ panel }: { panel: MessagePanel | null }) {
if (panel instanceof DMPanel) {
const recipientId = panel.getRecipientId()!;
const isTyping = chat.dmTypingUsers.get(recipientId);
content = isTyping ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
} else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) {
content = <TypingIndicator typingUsers={otherTypingUsers} />;
@@ -90,12 +90,12 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
useEffect(() => {
if (panel) {
setPanelState(panel.getState());
// Store the handler for cleanup
panel.onStateChange = (newState: MessagePanelState) => {
setPanelState(newState);
};
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler((message: WebSocketMessage<any>) => panel.handleWebSocketMessage(message));
@@ -104,7 +104,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
setPanelState(null);
setGlobalMessageHandler(null);
}
return () => {
if (panel) {
if (panel.onStateChange) {
@@ -122,11 +122,11 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
useEffect(() => {
if (chat.isSwitching) {
setSwitchOut(true);
// Use animation event listeners instead of hardcoded delays
function handleAnimationEnd(event: Event) {
const animationEvent = event as AnimationEvent;
if (animationEvent.animationName === 'fadeOutUp') {
// Apply pending panel exactly at the boundary between animations
applyPendingPanel();
@@ -138,10 +138,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
chat.setIsSwitching(false);
}
};
// Add event listener to document to catch all animation events
document.addEventListener('animationend', handleAnimationEnd);
// Cleanup function
return () => {
document.removeEventListener('animationend', handleAnimationEnd);
@@ -152,9 +152,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
// Load messages when panel changes and animation is not running
useEffect(() => {
if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return;
const panelState = chat.activePanel.getState();
if (panelState.messages.length === 0 && !panelState.isLoading) {
chat.activePanel.loadMessages();
}
@@ -180,7 +180,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const id = requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "end" });
});
return () => cancelAnimationFrame(id);
}
@@ -193,7 +193,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const dmPanel = panel as DMPanel;
const userId = dmPanel.getDMUserId();
const username = dmPanel.getDMUsername();
if (userId && username) {
initiateCall(userId, username);
}
@@ -202,7 +202,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
async function handleProfileClick() {
if (!panel) return;
try {
const profileData = await panel.getProfile();
if (profileData) {
@@ -215,9 +215,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div
<div
ref={messagePanelRef}
className="chat-main"
className="chat-main"
id="chat-inner"
onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return;
@@ -252,9 +252,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
dragCounterRef.current = 0;
} : undefined}>
<div className="chat-header">
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }}
@@ -272,10 +272,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
{panelState?.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
@@ -283,9 +283,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div>
</div>
) : panelState && panel ? (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
@@ -310,10 +310,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</ChatMessages>
) : (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
@@ -324,10 +324,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
{panel && (
<>
<AnimatedOpacity
visible={isDragging}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
<AnimatedOpacity
visible={isDragging}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
@@ -336,13 +336,13 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div>
</div>
</AnimatedOpacity>
<ChatInputWrapper
<ChatInputWrapper
onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null);
}}
}}
onSaveEdit={(content) => {
if (editMessage) {
panel.handleEditMessage(editMessage.id, content);
@@ -397,7 +397,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</>
)}
</div>
{/* Profile Dialog */}
<ProfileDialog />
</div>
@@ -9,19 +9,14 @@ import { useAppState } from "@/pages/chat/state";
interface OnlineStatusProps {
userId: number;
className?: string;
showLastSeen?: boolean;
}
export function OnlineStatus({ userId, className = "", showLastSeen = false }: OnlineStatusProps) {
const { chat } = useAppState();
const status = chat.onlineStatuses.get(userId);
export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) {
const { chat, user } = useAppState();
const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : chat.onlineStatuses.get(userId);
if (!status) {
return null;
}
const formatLastSeen = (lastSeen: string): string => {
function formatLastSeen(lastSeen: string): string {
const date = new Date(lastSeen);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
@@ -40,15 +35,15 @@ export function OnlineStatus({ userId, className = "", showLastSeen = false }: O
} else {
return date.toLocaleDateString();
}
};
}
return (
<div className={`online-status ${className}`}>
<div className={`status-dot ${status.online ? "online" : "offline"}`}></div>
<div className="online-status">
<div className={`status-dot ${status?.online ? "online" : "offline"}`}></div>
<span className="status-text">
{status.online ? "В сети" : "Не в сети"}
{!status ? "Загрузка..." : status?.online ? "В сети" : "Не в сети"}
</span>
{showLastSeen && !status.online && (
{showLastSeen && status && !status.online && (
<span className="last-seen">
{formatLastSeen(status.lastSeen)}
</span>
@@ -3,6 +3,6 @@ import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() {
const { chat } = useAppState();
return <MessagePanelRenderer panel={chat.activePanel} />
}
@@ -8,11 +8,11 @@ import { id } from "@/utils/utils";
export function CallWindow() {
const { chat, toggleCallMinimize, user } = useAppState();
const { call } = chat;
const {
acceptCall,
rejectCall,
remoteAudioRef,
endCall,
const {
acceptCall,
rejectCall,
remoteAudioRef,
endCall,
toggleMute,
toggleVideo,
toggleScreenShare,
@@ -42,7 +42,7 @@ export function CallWindow() {
useEffect(() => {
let interval: NodeJS.Timeout;
if (call.status === "active" && call.startTime) {
interval = setInterval(() => {
setCallDuration(Math.floor((Date.now() - call.startTime!) / 1000));
@@ -185,7 +185,7 @@ export function CallWindow() {
autoPlay
playsInline
controls />
{shouldRender && (
<div
className={`call-window ${(call.isActive ? call.isMinimized : wasMinimized) ? "minimized" : "maximized"} ${isDragging ? "dragging" : ""} ${getGradientClass()} ${isVisible ? "visible" : "hidden"}`}
@@ -209,13 +209,13 @@ export function CallWindow() {
>
<div className="call-header">
<div className="window-controls">
<mdui-button-icon
onClick={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn"
<mdui-button-icon
onClick={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn"
/>
</div>
<div className="call-header-info">
<h3 className="username">{remoteUsername}</h3>
<p className="status">{getStatusText()}</p>
@@ -235,7 +235,7 @@ export function CallWindow() {
{/* Main screen share area - takes most space when active */}
<div className="screen-share-area">
{/* Local screen share */}
<div
<div
className="video-tile screen-share-tile local-screen-share"
style={{ display: call.isSharingScreen ? "flex" : "none" }}>
<video
@@ -246,9 +246,9 @@ export function CallWindow() {
muted />
<div className="tile-label">Your Screen</div>
</div>
{/* Remote screen share */}
<div
<div
className="video-tile screen-share-tile remote-screen-share"
style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}>
<video
@@ -46,7 +46,7 @@ export function MinimizedCallBar() {
<span className="status">{getStatusText()}</span>
</div>
</div>
<div className="call-actions" onClick={(e) => e.stopPropagation()}>
{call.status === "calling" && !call.isInitiator ? (
<mdui-button-icon onClick={endCall} icon="call_end" />
@@ -1,7 +1,7 @@
import { MessagePanel } from "./MessagePanel";
import {
fetchDMHistory,
decryptDm,
import {
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
sendDmWithFiles,
editDmEnvelope,
@@ -43,7 +43,7 @@ export class DMPanel extends MessagePanel {
async activate(): Promise<void> {
// Don't load messages immediately during activation to prevent animation freeze
// Messages will be loaded after the animation completes
// Subscribe to recipient's online status
if (this.dmData?.userId) {
onlineStatusManager.subscribe(this.dmData.userId);
@@ -65,9 +65,9 @@ export class DMPanel extends MessagePanel {
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await decryptDm(env, this.dmData!.publicKey);
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
@@ -146,10 +146,10 @@ export class DMPanel extends MessagePanel {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try {
const payload: DmEncryptedJSON = {
type: "text",
data: {
content: content.trim(),
const payload: DmEncryptedJSON = {
type: "text",
data: {
content: content.trim(),
reply_to_id: replyToId ?? undefined
}
}
@@ -193,12 +193,12 @@ export class DMPanel extends MessagePanel {
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
if (response.type === "dmNew" && this.dmData) {
const envelope = response.data;
// If this is for the active DM conversation
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
try {
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
// Check if this is a confirmation of a message we sent
const isOurMessage = envelope.senderId !== this.dmData.userId;
if (isOurMessage) {
@@ -211,7 +211,7 @@ export class DMPanel extends MessagePanel {
}
}
}
this.addMessage(dmMsg);
// Update last read if it's from the other user
@@ -228,17 +228,17 @@ export class DMPanel extends MessagePanel {
try {
// Decrypt new content in-place
const plaintext = await decryptDm(
{
id,
senderId: 0,
recipientId: 0,
iv,
ciphertext,
salt,
iv2,
wrappedMk,
timestamp: new Date().toISOString()
},
{
id,
senderId: 0,
recipientId: 0,
iv,
ciphertext,
salt,
iv2,
wrappedMk,
timestamp: new Date().toISOString()
},
this.dmData.publicKey
);
let content = plaintext;
@@ -272,7 +272,7 @@ export class DMPanel extends MessagePanel {
if (this.dmData?.userId) {
onlineStatusManager.unsubscribe(this.dmData.userId);
}
this.dmData = null;
this.messagesLoaded = false;
this.clearMessages();
@@ -324,10 +324,10 @@ export class DMPanel extends MessagePanel {
async handleDeleteMessage(messageId: number): Promise<void> {
if (!this.currentUser.authToken || !this.dmData) return;
// Remove message immediately from UI
this.deleteMessageImmediately(messageId);
// Fire and forget server deletion; UI already updated
await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken);
}
@@ -348,14 +348,14 @@ export class DMPanel extends MessagePanel {
console.error("Failed to edit DM:", e);
});
}
async getProfile(): Promise<ProfileDialogData | null> {
if (!this.dmData || !this.currentUser.authToken) return null;
try {
const userProfile = await fetchUserProfile(this.currentUser.authToken, this.dmData.username);
if (!userProfile) return null;
return {
userId: userProfile.id,
username: userProfile.username,
@@ -373,10 +373,10 @@ export class DMPanel extends MessagePanel {
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
const messages = this.getMessages();
const messageIndex = messages.findIndex(msg =>
const messageIndex = messages.findIndex(msg =>
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
);
if (messageIndex !== -1) {
const updatedMessage = { ...messages[messageIndex] };
updatedMessage.reactions = reactions;
@@ -89,7 +89,7 @@ export abstract class MessagePanel {
protected updateMessageReactions(messageId: number, reactions: any[]): void {
this.updateState({
messages: this.state.messages.map(msg =>
messages: this.state.messages.map(msg =>
msg.id === messageId ? { ...msg, reactions } : msg
)
});
@@ -125,7 +125,7 @@ export abstract class MessagePanel {
}
// ========== PUBLIC API ==========
// Event handlers
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
this.sendMessageWithImmediateDisplay(content, replyToId, files);
@@ -136,10 +136,10 @@ export abstract class MessagePanel {
if (!message?.runtimeData?.sendingState?.retryData) return;
const { content, replyToId, files } = message.runtimeData.sendingState.retryData;
// Create new temp ID for retry
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
// Update status back to sending and create new temp message
const retryMessage: Message = {
...message,
@@ -184,7 +184,7 @@ export abstract class MessagePanel {
// Clear the timeout since we're handling the failure immediately
clearTimeout(timeoutId);
this.pendingMessages.delete(tempId);
// Update message to failed state directly
this.updateState({
messages: this.state.messages.map(msg => {
@@ -211,7 +211,7 @@ export abstract class MessagePanel {
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingMessages.delete(tempId);
// Replace temporary message with confirmed one
this.updateState({
messages: this.state.messages.map(msg => {
@@ -249,7 +249,7 @@ export abstract class MessagePanel {
}
// ========== PRIVATE METHODS ==========
// Create and display message immediately with sending state
private async sendMessageWithImmediateDisplay(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!content.trim() && files.length === 0) return;
@@ -326,7 +326,7 @@ export abstract class MessagePanel {
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingMessages.delete(tempId);
// Update message to failed state
this.updateState({
messages: this.state.messages.map(msg => {
@@ -70,7 +70,7 @@ export class PublicChatPanel extends MessagePanel {
if (files.length === 0) {
const response = await request({
data: {
content: content.trim(),
content: content.trim(),
reply_to_id: replyToId ?? null
},
credentials: {
@@ -86,7 +86,7 @@ export class PublicChatPanel extends MessagePanel {
const form = new FormData();
form.append("payload", JSON.stringify({
content: content.trim(),
reply_to_id: replyToId ?? null
reply_to_id: replyToId ?? null
} satisfies SendMessageRequest["data"]));
for (const f of files) form.append("files", f, f.name);
const res = await fetch(`${API_BASE_URL}/send_message`, {
@@ -119,7 +119,7 @@ export class PublicChatPanel extends MessagePanel {
case 'newMessage':
if (response.data) {
const newMsg = response.data;
// Check if this is a confirmation of a message we sent
const isOurMessage = newMsg.username === this.currentUser.currentUser?.username;
if (isOurMessage) {
@@ -132,7 +132,7 @@ export class PublicChatPanel extends MessagePanel {
}
}
}
this.addMessage(newMsg);
}
break;
@@ -185,18 +185,18 @@ export class PublicChatPanel extends MessagePanel {
async handleDeleteMessage(id: number): Promise<void> {
// Remove message immediately from UI
this.deleteMessageImmediately(id);
// Fire and forget server deletion; UI already updated
await request({
type: "deleteMessage",
data: { message_id: id },
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken!
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken!
}
});
}
async getProfile(): Promise<ProfileDialogData | null> {
return {
username: "Общий чат",
@@ -13,7 +13,7 @@ export default function DownloadAppPage() {
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<mdui-button>Скачать на GitHub</mdui-button>
</a>
<p>
Если возникнут сложности или есть вопросы, нажмите кнопку!
</p>
+20 -20
View File
@@ -68,13 +68,13 @@ export default function HomePage() {
Безопасный мессенджер с открытым исходным кодом
</h2>
<p className="hero-description">
FromChat это полностью открытый мессенджер с end-to-end шифрованием,
FromChat это полностью открытый мессенджер с end-to-end шифрованием,
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
</p>
<div className="hero-actions">
{openBtn}
{!isMobile && <mdui-button
variant="outlined"
variant="outlined"
onClick={() => navigate("/register")}
>
Зарегистрироваться
@@ -126,22 +126,22 @@ export default function HomePage() {
</div>
<h4>End-to-End Шифрование</h4>
<p>
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
Только вы и получатель можете прочитать сообщения.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="code" />
</div>
<h4>100% открытый код</h4>
<p>
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
внести изменения или развернуть свой сервер.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="attach_file" />
@@ -152,36 +152,36 @@ export default function HomePage() {
В общем чате шифрования нет, так как ваши сообщения могут читать все пользователи FromChat.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="notifications" />
</div>
<h4>Уведомления</h4>
<p>
Получайте push-уведомления в браузере и настольном приложении.
Получайте push-уведомления в браузере и настольном приложении.
Никогда не пропустите важное сообщение.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="edit" />
</div>
<h4>Редактирование</h4>
<p>
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
для лучшего контекста общения.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="computer" />
</div>
<h4>Кроссплатформенность</h4>
<p>
Работает в браузере и как настольное приложение для Windows,
Работает в браузере и как настольное приложение для Windows,
macOS и Linux. Единый интерфейс везде.
</p>
</div>
@@ -194,15 +194,15 @@ export default function HomePage() {
<div className="download-content">
<h3>Скачайте приложение</h3>
<p>
Для лучшего опыта используйте настольное приложение с поддержкой
Для лучшего опыта используйте настольное приложение с поддержкой
уведомлений и автономной работы.
</p>
<div className="download-buttons">
{!isMobile ? (
<>
<a
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
target="_blank"
<a
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
target="_blank"
rel="noopener noreferrer"
>
<mdui-button variant="filled">
@@ -239,13 +239,13 @@ export default function HomePage() {
</mdui-button>
) : (
<>
<mdui-button
variant="filled"
<mdui-button
variant="filled"
onClick={() => navigate("/register")}>
Создать аккаунт
</mdui-button>
<mdui-button
variant="outlined"
<mdui-button
variant="outlined"
onClick={() => navigate("/login")}>
Войти
</mdui-button>
+81 -81
View File
@@ -6,7 +6,7 @@
color: $color-dark-on-background;
font-family: 'Montserrat', sans-serif;
position: relative;
&::before {
content: '';
position: fixed;
@@ -14,27 +14,27 @@
left: 0;
right: 0;
bottom: 0;
background:
background:
radial-gradient(circle at 20% 80%, rgba($color-dark-primary, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.3) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.2) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
// Cascaded styles for all child elements
* {
position: relative;
z-index: 1;
}
// Container styles
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
// Header styles
.homepage-header {
padding: 1rem 0;
@@ -46,12 +46,12 @@
top: 0;
z-index: 1000;
transition: all 0.3s ease;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.logo {
h1 {
font-size: 2rem;
@@ -63,12 +63,12 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.tagline {
font-size: 0.9rem;
}
}
.header-nav {
display: flex;
align-items: center;
@@ -80,7 +80,7 @@
}
}
}
// Hero section
.hero {
padding: 4rem 0;
@@ -88,11 +88,11 @@
align-items: center;
min-height: 80vh;
margin-top: 0;
.hero-content {
flex: 1;
max-width: 600px;
.hero-title {
font-size: 3.5rem;
font-weight: 800;
@@ -105,36 +105,36 @@
text-shadow: 0 0 30px rgba($color-dark-primary, 0.5);
animation: neonGlow 3s ease-in-out infinite alternate;
}
.hero-description {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.hero-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
}
.hero-visual {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
.chat-preview {
perspective: 1000px;
.chat-window {
background: rgba($color-dark-surface-container, 0.95);
border-radius: 20px;
padding: 1.5rem;
box-shadow:
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.5),
0 0 20px rgba($color-dark-primary, 0.3),
inset 0 1px 0 rgba($color-dark-primary, 0.2);
@@ -143,7 +143,7 @@
max-width: 400px;
width: 100%;
border: 1px solid rgba($color-dark-primary, 0.3);
.chat-header {
display: flex;
justify-content: space-between;
@@ -151,12 +151,12 @@
padding-bottom: 1rem;
border-bottom: 1px solid rgba($color-dark-primary, 0.3);
margin-bottom: 1rem;
.chat-title {
font-weight: 600;
font-size: 1.1rem;
}
.online-indicator {
color: $color-dark-primary;
font-size: 0.8rem;
@@ -164,27 +164,27 @@
animation: pulse 2s ease-in-out infinite;
}
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 1rem;
.message {
display: flex;
gap: 0.75rem;
align-items: flex-start;
&.sent {
flex-direction: row-reverse;
.message-content {
background: linear-gradient(135deg, $color-dark-primary, $color-dark-primary-container);
color: $color-dark-on-primary;
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
}
}
&.received {
.message-content {
background: rgba($color-dark-surface-variant, 0.8);
@@ -192,7 +192,7 @@
border: 1px solid rgba($color-dark-outline-variant, 0.3);
}
}
.message-avatar {
width: 32px;
height: 32px;
@@ -207,18 +207,18 @@
flex-shrink: 0;
box-shadow: 0 0 10px rgba($color-dark-primary, 0.4);
}
.message-content {
max-width: 70%;
padding: 0.75rem 1rem;
border-radius: 18px;
position: relative;
.message-text {
font-size: 0.9rem;
line-height: 1.4;
}
.message-time {
font-size: 0.75rem;
opacity: 0.7;
@@ -231,7 +231,7 @@
}
}
}
// Features section
.features {
padding: 6rem 0;
@@ -239,7 +239,7 @@
backdrop-filter: blur(20px);
border-top: 1px solid rgba($color-dark-primary, 0.2);
border-bottom: 1px solid rgba($color-dark-primary, 0.2);
.section-title {
text-align: center;
font-size: 2.5rem;
@@ -251,12 +251,12 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
.feature-card {
background: rgba($color-dark-surface-container, 0.6);
backdrop-filter: blur(20px);
@@ -266,7 +266,7 @@
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
@@ -279,24 +279,24 @@
transition: opacity 0.3s ease;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
&:hover {
transform: translateY(-5px);
box-shadow:
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.3),
0 0 30px rgba($color-dark-primary, 0.2);
border-color: rgba($color-dark-primary, 0.5);
&::before {
opacity: 1;
}
}
.feature-icon {
width: 60px;
height: 60px;
@@ -309,14 +309,14 @@
border: 1px solid rgba($color-dark-primary, 0.4);
box-shadow: 0 0 15px rgba($color-dark-primary, 0.2);
user-select: none;
mdui-icon {
font-size: 1.5rem;
color: $color-dark-primary;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.8);
}
}
h4 {
font-size: 1.25rem;
font-weight: 600;
@@ -324,7 +324,7 @@
color: $color-dark-on-surface;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.3);
}
p {
line-height: 1.6;
opacity: 0.9;
@@ -333,16 +333,16 @@
}
}
}
// Download section
.download {
padding: 6rem 0;
.download-content {
text-align: center;
max-width: 600px;
margin: 0 auto;
h3 {
font-size: 2.5rem;
font-weight: 700;
@@ -353,14 +353,14 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
p {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.download-buttons {
display: flex;
gap: 1rem;
@@ -369,19 +369,19 @@
}
}
}
// CTA section
.cta {
padding: 6rem 0;
background: rgba($color-dark-surface-container, 0.3);
backdrop-filter: blur(20px);
border-top: 1px solid rgba($color-dark-primary, 0.2);
.cta-content {
text-align: center;
max-width: 600px;
margin: 0 auto;
h3 {
font-size: 2.5rem;
font-weight: 700;
@@ -392,14 +392,14 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
p {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.cta-actions {
display: flex;
gap: 1rem;
@@ -408,7 +408,7 @@
}
}
}
// Footer
.homepage-footer {
background: rgba($color-dark-surface-container, 0.8);
@@ -416,13 +416,13 @@
padding: 3rem 0 1rem;
border-top: 1px solid rgba($color-dark-primary, 0.3);
box-shadow: 0 -4px 20px rgba($color-dark-primary, 0.1);
.footer-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
.footer-section {
h4 {
font-size: 1.1rem;
@@ -431,13 +431,13 @@
color: $color-dark-on-surface;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.3);
}
p {
opacity: 0.8;
line-height: 1.6;
color: $color-dark-on-surface-variant;
}
a {
color: $color-dark-on-surface;
text-decoration: none;
@@ -447,7 +447,7 @@
transition: all 0.3s ease;
padding: 0.25rem 0;
border-radius: 4px;
&:hover {
opacity: 1;
color: $color-dark-primary;
@@ -457,12 +457,12 @@
}
}
}
.footer-bottom {
text-align: center;
padding-top: 2rem;
border-top: 1px solid rgba($color-dark-primary, 0.3);
p {
opacity: 0.7;
margin: 0;
@@ -503,13 +503,13 @@
max-width: 1200px;
border-radius: 20px;
border: 1px solid rgba($color-dark-primary, 0.3);
box-shadow:
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 20px rgba($color-dark-primary, 0.2);
backdrop-filter: blur(30px);
background: rgba($color-dark-surface-container, 0.9);
}
.hero {
margin-top: 6rem;
}
@@ -534,32 +534,32 @@
.container {
padding: 0 1rem;
}
.homepage-header {
.header-content {
flex-direction: column;
gap: 1rem;
}
}
.hero {
flex-direction: column;
text-align: center;
padding: 2rem 0;
.hero-content {
.hero-title {
font-size: 2.5rem;
}
.hero-description {
font-size: 1.1rem;
}
}
.hero-visual {
padding: 1rem;
.chat-preview {
.chat-window {
transform: none;
@@ -568,47 +568,47 @@
}
}
}
.features {
.features-grid {
grid-template-columns: 1fr;
.feature-card {
padding: 1.5rem;
}
}
.section-title {
font-size: 2rem;
}
}
.download {
.download-content {
h3 {
font-size: 2rem;
}
.download-buttons {
flex-direction: column;
align-items: center;
}
}
}
.cta {
.cta-content {
h3 {
font-size: 2rem;
}
.cta-actions {
flex-direction: column;
align-items: center;
}
}
}
.homepage-footer {
.footer-content {
grid-template-columns: 1fr;
@@ -625,19 +625,19 @@
.hero-title {
font-size: 2rem;
}
.hero-description {
font-size: 1rem;
}
}
}
.features {
.section-title {
font-size: 1.75rem;
}
}
.download {
.download-content {
h3 {
@@ -645,7 +645,7 @@
}
}
}
.cta {
.cta-content {
h3 {
@@ -14,14 +14,14 @@ export default function NotFoundPage() {
К сожалению, запрашиваемая страница не существует или была перемещена.
</p>
<div className="not-found-actions">
<mdui-button
variant="filled"
<mdui-button
variant="filled"
onClick={() => navigate("/")}
>
На главную
</mdui-button>
<mdui-button
variant="outlined"
<mdui-button
variant="outlined"
onClick={() => navigate(-1)}
>
Назад
+3 -3
View File
@@ -69,15 +69,15 @@
gap: 2rem;
padding: 2rem;
}
.error-code {
font-size: 4rem;
}
.not-found-content h1 {
font-size: 2rem;
}
.not-found-actions {
justify-content: center;
}