mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Clean up
This commit is contained in:
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
Назад
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user