Backend moved to a separate repository

This commit is contained in:
2026-07-14 09:59:03 +03:00
Unverified
parent 1cc5294d52
commit 5f9d9a78d3
340 changed files with 198 additions and 21988 deletions
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import { useUserStore } from "@/state/user";
import { Navigate } from "react-router-dom";
interface ProtectedRouteProps {
children: ReactNode;
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user } = useUserStore();
return !user.authToken ? <Navigate to="/login" /> : children;
}
+154
View File
@@ -0,0 +1,154 @@
import type React from "react";
import { motion, AnimatePresence } from "motion/react";
import styles from "./auth.module.scss";
export function AuthContainer({ children }: { children?: React.ReactNode }) {
return (
<div className={styles.authContainer}>
<div className={styles.gradientBackground} />
<motion.div
className={styles.authCard}
initial={{
opacity: 0,
scale: 0.95,
y: 10
}}
animate={{
opacity: 1,
scale: 1,
y: 0
}}
transition={{
duration: 0.4,
ease: "easeInOut"
}}
>
{children}
</motion.div>
</div>
)
}
export type IconType = "filled" | "outlined";
export interface AuthHeaderIcon {
name: string;
type: IconType
}
export interface AuthHeaderProps {
title: string;
icon: string | AuthHeaderIcon;
subtitle: string;
}
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
const iconType = typeof icon == "string" ? "filled" : icon.type;
const iconName = typeof icon == "string" ? icon : icon.name;
return (
<motion.div
className={styles.authHeader}
initial={{
opacity: 0,
y: -10
}}
animate={{
opacity: 1,
y: 0
}}
transition={{
duration: 0.4,
delay: 0.1,
ease: "easeInOut"
}}
>
<h2>
<motion.span
className={`material-symbols ${iconType} large`}
initial={{
opacity: 0,
scale: 0.8,
rotate: -10
}}
animate={{
opacity: 1,
scale: 1,
rotate: 0
}}
transition={{
duration: 0.5,
delay: 0.2,
ease: "easeOut"
}}
>
{iconName}
</motion.span>
{title}
</h2>
<motion.p
initial={{
opacity: 0,
y: 10
}}
animate={{
opacity: 1,
y: 0
}}
transition={{
duration: 0.4,
delay: 0.3,
ease: "easeInOut"
}}
>
{subtitle}
</motion.p>
</motion.div>
)
}
export type AlertType = "success" | "danger"
export interface Alert {
type: AlertType;
message: string;
}
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
const displayAlerts = alerts.slice(-3);
return (
<div className={styles.alertContainer}>
<AnimatePresence mode="popLayout">
{displayAlerts.map((alert, i) => (
<motion.div
key={`${i}-${alert.message}`}
className={`${styles.alert} alert-${alert.type}`}
initial={{
opacity: 0,
y: -20,
scale: 0.95
}}
animate={{
opacity: 1,
y: 0,
scale: 1
}}
exit={{
opacity: 0,
y: -10,
scale: 0.95
}}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
layout
>
{alert.message}
</motion.div>
))}
</AnimatePresence>
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { AuthContainer } from "./Auth";
import { useState, useEffect, useRef } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "motion/react";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { LoginForm } from "./LoginForm";
import { RegisterForm } from "./RegisterForm";
import type { Variants, Transition } from "motion/react";
import styles from "./auth.module.scss";
const MIN_HEIGHT = 400;
const slideVariants: Variants = {
enter: (direction: number) => ({
x: direction > 0 ? 300 : -300,
opacity: 0,
y: 0 // Ensure no vertical movement
}),
center: {
x: 0,
opacity: 1,
y: 0 // Ensure no vertical movement
},
exit: (direction: number) => ({
x: direction > 0 ? -300 : 300,
opacity: 0,
y: 0 // Ensure no vertical movement
})
};
const slideTransition: Transition = {
x: {
type: "spring",
stiffness: 300,
damping: 30
},
opacity: { duration: 0.2 }
};
export default function AuthPage() {
const [searchParams] = useSearchParams();
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
if (navigateDownloadApp) return navigateDownloadApp;
const navigate = useNavigate();
const [direction, setDirection] = useState(0);
const prevMode = useRef(searchParams.get("mode") || "login");
const containerRef = useRef<HTMLDivElement>(null);
const loginFormRef = useRef<HTMLDivElement>(null);
const registerFormRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(400);
const [isTransitioning, setIsTransitioning] = useState(false);
const currentMode = searchParams.get("mode") || "login";
useEffect(() => {
if (prevMode.current !== currentMode) {
setDirection(currentMode === "register" ? 1 : -1);
prevMode.current = currentMode;
setIsTransitioning(true);
}
}, [currentMode]);
// Setup ResizeObserver to watch for content changes
useEffect(() => {
const activeRef = currentMode === "login" ? loginFormRef : registerFormRef;
if (activeRef.current) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = entry.contentRect.height;
if (height > 0) {
setContainerHeight(Math.max(height, MIN_HEIGHT));
}
}
});
resizeObserver.observe(activeRef.current);
// Initial measurement
const initialHeight = activeRef.current.scrollHeight;
if (initialHeight > 0) {
setContainerHeight(Math.max(initialHeight, MIN_HEIGHT));
}
return () => {
resizeObserver.disconnect();
};
}
}, [currentMode]);
function switchMode(newMode: "login" | "register") {
navigate(`/auth?mode=${newMode}`, { replace: true });
}
function handleAnimationComplete() {
setIsTransitioning(false);
}
return (
<AuthContainer>
<div
ref={containerRef}
style={{
position: "relative",
width: "100%",
height: `${containerHeight}px`,
transition: isTransitioning ? "height 0.3s ease" : "none"
}}
>
<AnimatePresence mode="sync" custom={direction}>
{currentMode === "login" ? (
<motion.div
key="login"
ref={loginFormRef}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={slideTransition}
onAnimationComplete={handleAnimationComplete}
className={styles.formWrapper}
>
<LoginForm onSwitchMode={() => switchMode("register")} />
</motion.div>
) : (
<motion.div
key="register"
ref={registerFormRef}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={slideTransition}
onAnimationComplete={handleAnimationComplete}
className={styles.formWrapper}
>
<RegisterForm onSwitchMode={() => switchMode("login")} />
</motion.div>
)}
</AnimatePresence>
</div>
</AuthContainer>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { forwardRef, useImperativeHandle, useRef, useState, useEffect } from "react";
import { motion } from "motion/react";
import styles from "./auth.module.scss";
export interface AuthTextFieldHandle {
value: string;
focus: () => void;
blur: () => void;
}
export interface AuthTextFieldProps {
label: string;
name?: string;
type?: string;
icon?: string;
autocomplete?: string;
required?: boolean;
maxlength?: number;
counter?: boolean;
"toggle-password"?: boolean;
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
className?: string;
}
export const AuthTextField = forwardRef<AuthTextFieldHandle, AuthTextFieldProps>(
({
label,
name,
type = "text",
icon,
autocomplete,
required = false,
maxlength,
counter = false,
"toggle-password": togglePassword = false,
defaultValue = "",
value: controlledValue,
onChange,
className = ""
}, ref) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const [isFocused, setIsFocused] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [charCount, setCharCount] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const displayType = togglePassword && type === "password" ? (showPassword ? "text" : "password") : type;
useEffect(() => {
if (!isControlled) {
setInternalValue(defaultValue);
}
}, [defaultValue, isControlled]);
useEffect(() => {
setCharCount(value.length);
}, [value]);
useImperativeHandle(ref, () => ({
get value() {
return value;
},
focus: () => {
inputRef.current?.focus();
},
blur: () => {
inputRef.current?.blur();
}
}));
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (!isControlled) {
setInternalValue(newValue);
}
onChange?.(newValue);
};
const hasError = false; // Can be extended for validation
return (
<motion.div
className={`${styles.authTextField} ${className}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
whileFocus={{ scale: 1.01 }}
>
<div className={`${styles.fieldContainer} ${isFocused ? styles.focused : ""} ${hasError ? styles.error : ""} ${!icon ? styles.noIcon : ""} ${togglePassword && type === "password" ? styles.hasToggle : ""}`}>
{icon && (
<span className={`material-symbols filled ${styles.fieldIcon}`}>
{icon.replace("--filled", "").replace("--outlined", "")}
</span>
)}
<div className={styles.inputWrapper}>
<input
ref={inputRef}
type={displayType}
name={name}
value={value}
onChange={handleChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
autoComplete={autocomplete}
required={required}
maxLength={maxlength}
placeholder={label + (required ? " *" : "")}
className={styles.input}
/>
</div>
{togglePassword && type === "password" && (
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
<span className="material-symbols filled">
{showPassword ? "visibility_off" : "visibility"}
</span>
</button>
)}
</div>
{counter && maxlength && (
<div className={styles.counter}>
{charCount} / {maxlength}
</div>
)}
</motion.div>
);
}
);
AuthTextField.displayName = "AuthTextField";
+220
View File
@@ -0,0 +1,220 @@
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion, type Transition, type Variants } from "motion/react";
import { useImmer } from "use-immer";
import type { LoginRequest } from "@/core/types";
import { useUserStore } from "@/state/user";
import { MaterialButton } from "@/utils/material";
import api from "@/core/api";
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import type { Alert, AlertType } from "./Auth";
import { AuthHeader, AlertsContainer } from "./Auth";
import styles from "./auth.module.scss";
import { ensureAuthenticated } from "@/core/websocket";
const loginFieldVariants: Variants = {
initial: {
opacity: 0,
y: 10
},
animate: {
opacity: 1,
y: 0
}
};
const loginFieldTransition: Transition = {
duration: 0.3,
ease: "easeInOut"
};
const loginButtonVariants: Variants = {
initial: {
opacity: 0,
y: 10
},
animate: {
opacity: 1,
y: 0
}
};
const loginButtonTransition: Transition = {
duration: 0.3,
delay: 0.4,
ease: "easeInOut"
};
interface LoginFormProps {
onSwitchMode: () => void;
}
export function LoginForm({ onSwitchMode }: LoginFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const usernameElement = useRef<AuthTextFieldHandle>(null);
const passwordElement = useRef<AuthTextFieldHandle>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (isLoading) return;
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
if (!username || !password) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
setIsLoading(true);
try {
const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: LoginRequest = {
username: username,
password: derived
}
try {
const data = await api.user.auth.login(request);
setUser(data.token, data.user);
try {
await api.user.auth.ensureKeysOnLogin(password, data.token);
} catch (e) {
console.error("Key setup failed:", e);
try {
await api.user.auth.syncPublicKeyToServerIfMissing(data.token);
} catch (e2) {
console.error("Public key re-sync failed:", e2);
}
}
// Ensure WebSocket is connected and authenticated
try {
await ensureAuthenticated();
} catch (e) {
console.error("WebSocket authentication failed:", e);
}
navigate("/chat");
try {
if (isSupported()) {
const initialized = await initialize();
if (initialized) {
await subscribe(data.token);
if (isElectron) {
await startElectronReceiver();
}
console.log("Notifications enabled");
} else {
console.log("Notification permission denied");
}
} else {
console.log("Notifications not supported");
}
} catch (e) {
console.error("Notification setup failed:", e);
}
} catch (error: any) {
if (error.message && error.message.includes("suspension")) {
const setSuspended = useUserStore.getState().setSuspended;
setSuspended(error.message || "No reason provided");
return;
}
showAlert("danger", error.message || "Неверное имя пользователя или пароль");
}
} catch (error: any) {
showAlert("danger", error.message || "Ошибка соединения с сервером");
} finally {
setIsLoading(false);
}
}
return (
<>
<AuthHeader
icon="login"
title="Добро пожаловать!"
subtitle="Войдите в свой аккаунт"
/>
<div className={styles.authBody}>
<AlertsContainer alerts={alerts} />
<motion.form onSubmit={handleSubmit}>
<motion.div
initial="initial"
animate="animate"
variants={loginFieldVariants}
transition={loginFieldTransition}
>
<AuthTextField
label="@Имя пользователя"
name="username"
icon="person--filled"
autocomplete="username"
required
ref={usernameElement} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={loginFieldVariants}
transition={loginFieldTransition}
>
<AuthTextField
label="Пароль"
name="password"
type="password"
toggle-password
icon="password--filled"
autocomplete="current-password"
required
ref={passwordElement} />
</motion.div>
<div className={styles.authButtons}>
<motion.div
initial="initial"
animate="animate"
variants={loginButtonVariants}
transition={loginButtonTransition}
>
<MaterialButton type="submit" disabled={isLoading}>
{isLoading ? "Вход..." : "Войти"}
</MaterialButton>
</motion.div>
</div>
</motion.form>
<p className={styles.registerLink}>
Ещё нет аккаунта?
<a
href="#"
className="link"
onClick={(e) => {
e.preventDefault();
onSwitchMode();
}}>
Зарегистрируйтесь
</a>
</p>
</div>
</>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion, type Transition, type Variants } from "motion/react";
import { useImmer } from "use-immer";
import type { RegisterRequest } from "@/core/types";
import { useUserStore } from "@/state/user";
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import api from "@/core/api";
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
import type { Alert, AlertType } from "./Auth";
import { AuthHeader, AlertsContainer } from "./Auth";
import { LegalInlineLinks } from "@/core/legal/LegalInlineLinks";
import styles from "./auth.module.scss";
const registerFieldVariants: Variants = {
initial: {
opacity: 0,
y: 10
},
animate: {
opacity: 1,
y: 0
}
};
const registerFieldTransition: Transition = {
duration: 0.3,
ease: "easeInOut"
};
const registerButtonVariants: Variants = {
initial: {
opacity: 0,
y: 10
},
animate: {
opacity: 1,
y: 0
}
};
const registerButtonTransition: Transition = {
duration: 0.3,
delay: 0.6,
ease: "easeInOut"
};
interface RegisterFormProps {
onSwitchMode: () => void;
}
export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const displayNameElement = useRef<AuthTextFieldHandle>(null);
const usernameElement = useRef<AuthTextFieldHandle>(null);
const passwordElement = useRef<AuthTextFieldHandle>(null);
const confirmPasswordElement = useRef<AuthTextFieldHandle>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (isLoading) return;
const displayName = displayNameElement.current!.value.trim();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
const confirmPassword = confirmPasswordElement.current!.value.trim();
if (!displayName || !username || !password || !confirmPassword) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
if (password !== confirmPassword) {
showAlert("danger", "Пароли не совпадают");
return;
}
if (displayName.length < 1 || displayName.length > 64) {
showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов");
return;
}
if (username.length < 3 || username.length > 20) {
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания");
return;
}
if (password.length < 5 || password.length > 50) {
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
return;
}
setIsLoading(true);
try {
const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: RegisterRequest = {
display_name: displayName,
username: username,
password: derived,
confirm_password: derived
}
try {
const data = await api.user.auth.register(request);
setUser(data.token, data.user);
try {
await api.user.auth.ensureKeysOnLogin(password, data.token);
} catch (e) {
console.error("Key setup failed:", e);
try {
await api.user.auth.syncPublicKeyToServerIfMissing(data.token);
} catch (e2) {
console.error("Public key re-sync failed:", e2);
}
}
navigate("/chat");
} catch (error: any) {
showAlert("danger", error.message || "Ошибка при регистрации");
}
} catch (error: any) {
showAlert("danger", error.message || "Ошибка соединения с сервером");
} finally {
setIsLoading(false);
}
}
return (
<>
<AuthHeader
icon="person_add"
title="Регистрация"
subtitle="Создайте новый аккаунт"
/>
<div className={styles.authBody}>
<AlertsContainer alerts={alerts} />
<motion.form onSubmit={handleSubmit}>
<motion.div
initial="initial"
animate="animate"
variants={registerFieldVariants}
transition={registerFieldTransition}
>
<AuthTextField
label="Отображаемое имя"
name="display_name"
icon="badge--filled"
autocomplete="name"
maxlength={64}
counter
required
ref={displayNameElement} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={registerFieldVariants}
transition={registerFieldTransition}
>
<AuthTextField
label="@Имя пользователя"
name="username"
icon="person--filled"
autocomplete="username"
maxlength={20}
counter
required
ref={usernameElement} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={registerFieldVariants}
transition={registerFieldTransition}
>
<AuthTextField
label="Пароль"
name="password"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={passwordElement} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={registerFieldVariants}
transition={registerFieldTransition}
>
<AuthTextField
label="Подтвердите пароль"
name="confirm_password"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={confirmPasswordElement} />
</motion.div>
<div className={styles.authButtons}>
<motion.div
initial="initial"
animate="animate"
variants={registerButtonVariants}
transition={registerButtonTransition}
>
<MaterialIconButton icon="arrow_back" onClick={onSwitchMode} />
</motion.div>
<motion.div
initial="initial"
animate="animate"
variants={registerButtonVariants}
transition={registerButtonTransition}
>
<MaterialButton type="submit" disabled={isLoading} loading={isLoading} icon="person_add">
{isLoading ? "Регистрация..." : "Зарегистрироваться"}
</MaterialButton>
</motion.div>
</div>
<LegalInlineLinks />
</motion.form>
</div>
</>
);
}
+327
View File
@@ -0,0 +1,327 @@
@use "sass:color";
@use "../../css/colors" as *;
@use "../../css/material" as *;
@keyframes rotateGradient {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-4px);
}
20%, 40%, 60%, 80% {
transform: translateX(4px);
}
}
.authContainer {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
width: 100vw;
padding: 2rem;
position: fixed;
top: 0;
left: 0;
overflow: hidden;
background: $color-dark-surface;
.gradientBackground {
$size: 550px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: $size;
height: $size;
background: conic-gradient(
from 0deg,
rgba(147, 51, 234, 0.5) 0%,
rgba(99, 102, 241, 0.6) 12.5%,
rgba(59, 130, 246, 0.55) 25%,
rgba(168, 85, 247, 0.5) 37.5%,
rgba(217, 70, 239, 0.6) 50%,
rgba(236, 72, 153, 0.55) 62.5%,
rgba(192, 132, 252, 0.5) 75%,
rgba(126, 34, 206, 0.6) 87.5%,
rgba(147, 51, 234, 0.5) 100%
);
animation: rotateGradient 8s linear infinite;
border-radius: 50%;
filter: blur(80px);
z-index: 0;
will-change: transform;
backface-visibility: hidden;
}
.authCard {
background: rgba($color-dark-surface-container, 0.7);
backdrop-filter: blur(20px);
color: $color-dark-on-surface;
border-radius: 24px;
border: 1px solid rgba($color-dark-outline, 0.1);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba($color-dark-primary, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
width: 100%;
max-width: 450px;
overflow: hidden;
position: relative;
z-index: 1;
.formWrapper {
position: absolute;
width: 100%;
top: 0;
left: 0;
&.relative {
position: relative;
top: auto;
left: auto;
}
.authHeader {
margin: 0;
padding: 24px;
padding-bottom: 8px;
text-align: center;
h2 {
font-size: 1.8rem;
margin: 0;
margin-bottom: 0.5rem;
align-items: center;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: center;
font-weight: 600;
.material-symbols {
color: $color-dark-primary;
filter: drop-shadow(0 0 8px rgba($color-dark-primary, 0.4));
}
}
p {
color: $color-dark-on-surface-variant;
font-size: 0.95rem;
margin: 0;
}
}
.authBody {
padding: 24px;
padding-bottom: 20px;
form {
display: flex;
flex-direction: column;
gap: 16px;
.authButtons {
display: flex;
flex-direction: row;
gap: 16px;
}
}
.registerLink {
text-align: center;
margin-top: 16px;
font-size: 0.9rem;
color: $color-dark-on-surface-variant;
a {
color: $color-dark-primary;
margin-inline-start: 3px;
}
}
}
}
}
}
// AuthTextField Styles
.authTextField {
position: relative;
width: 100%;
.fieldContainer {
position: relative;
display: flex;
align-items: center;
gap: 10px;
background: rgba($color-dark-surface-variant, 0.3);
border: 1px solid rgba($color-dark-outline, 0.2);
border-radius: 12px;
padding: 0 0 0 12px;
transition: all 0.3s ease;
min-height: 44px;
&:hover {
border-color: rgba($color-dark-outline, 0.4);
background: rgba($color-dark-surface-variant, 0.4);
}
&.focused {
border-color: $color-dark-primary;
background: rgba($color-dark-surface-variant, 0.5);
box-shadow:
0 0 0 4px rgba($color-dark-primary, 0.1),
0 4px 12px rgba($color-dark-primary, 0.2);
}
&.error {
border-color: $color-dark-error;
animation: shake 0.4s ease;
&.focused {
box-shadow:
0 0 0 4px rgba($color-dark-error, 0.1),
0 4px 12px rgba($color-dark-error, 0.2);
}
}
&.noIcon {
gap: 0;
.inputWrapper {
margin-left: 0;
}
}
&.hasToggle {
padding-right: 12px;
}
}
.fieldIcon {
color: $color-dark-on-surface-variant;
font-size: 18px;
flex-shrink: 0;
transition: color 0.3s ease;
.fieldContainer.focused & {
color: $color-dark-primary;
}
}
.inputWrapper {
position: relative;
flex: 1;
display: flex;
align-items: center;
min-height: 44px;
}
.input {
width: 100%;
background: transparent;
border: none;
outline: none;
color: $color-dark-on-surface;
font-size: 0.95rem;
font-family: inherit;
padding: 12px 0 12px 0;
line-height: 1.4;
height: auto;
min-height: 20px;
&::placeholder {
color: $color-dark-on-surface-variant;
opacity: 0.7;
}
&:focus::placeholder {
opacity: 0.5;
}
}
.togglePassword {
background: none;
border: none;
color: $color-dark-on-surface-variant;
cursor: pointer;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
background: rgba($color-dark-on-surface, 0.1);
color: $color-dark-on-surface;
}
&:active {
transform: scale(0.95);
}
.material-symbols {
font-size: 18px;
}
}
.counter {
margin-top: 4px;
padding-left: 16px;
font-size: 0.75rem;
color: $color-dark-on-surface-variant;
text-align: right;
}
}
// Alert Styles
.alertContainer {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
.alert {
padding: 12px 16px;
border-radius: 12px;
font-size: 0.9rem;
line-height: 1.5;
animation: slideInDown 0.3s ease;
&.alert-success {
background: rgba($color-dark-primary-container, 0.3);
color: $color-dark-on-primary-container;
border: 1px solid rgba($color-dark-primary, 0.3);
}
&.alert-danger {
background: rgba($color-dark-error-container, 0.3);
color: $color-dark-on-error-container;
border: 1px solid rgba($color-dark-error, 0.3);
}
}
}
@@ -0,0 +1,123 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chatInputWrapper {
position: relative;
margin: 0 10px 10px 10px;
position: sticky;
bottom: 10px;
z-index: 1;
.inputGroup {
display: flex;
background: rgba($color-dark-surface-container, 0.7);
border-radius: 30px;
flex-direction: column;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px);
.contextualPreview {
padding: 12px 16px 0 16px;
display: flex;
align-items: flex-start;
gap: 16px;
mdui-icon {
align-self: center;
box-sizing: content-box;
}
.replyCancel {
margin-left: auto;
}
}
.attachmentsPreview {
align-items: center;
.attachmentsChips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.chatInput {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.buttons, .leftButtons {
display: flex;
flex-direction: row;
align-items: center;
}
.leftButtons {
.emojiBtn {
margin: 10px;
color: $color-dark-on-surface-variant;
transition: color 0.2s ease;
flex-shrink: 0;
align-self: flex-end;
&:hover {
color: $color-dark-primary;
}
}
}
.messageInput {
flex: 1;
padding: 20px 0;
border: none;
border-radius: 25px;
font-size: 1rem;
outline: none;
caret-color: $color-dark-primary;
color: $color-dark-on-surface;
background: transparent;
resize: none;
font: inherit;
font-size: 13pt;
height: 100%;
width: 100%;
&::placeholder {
color: $color-dark-on-surface-variant;
opacity: 0.7;
}
}
.buttons {
.sendBtn {
margin: 10px;
width: 50px;
height: 50px;
border-radius: 50%;
background-color: $color-dark-primary;
color: $color-dark-on-primary;
border: 1px solid rgba($color-dark-primary, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
align-self: flex-end;
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
&:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba($color-dark-primary, 0.6);
}
}
}
}
}
}
// Typing indicator styles
@@ -0,0 +1,193 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Emoji Menu Styles
.emojiMenu {
$transition: cubic-bezier(0.4, 0, 0.2, 1);
background: $color-dark-surface-container;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(20px);
width: 320px;
height: 400px;
overflow: hidden;
display: flex;
flex-direction: column;
transform-origin: bottom left;
opacity: 0;
transform: translateY(30px);
transition: transform 0.25s $transition, opacity 0.25s $transition;
user-select: none;
&.open {
opacity: 1;
transform: translateY(0);
}
.emojiMenuHeader {
position: sticky;
top: 0;
z-index: 1;
.emojiCategoryTabs {
$scrollbar-height: 2px;
display: flex;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
padding: 8px;
padding-bottom: 8px - $scrollbar-height;
&::-webkit-scrollbar {
height: $scrollbar-height; // slightly taller to accommodate inner padding
}
&::-webkit-scrollbar-track {
background: transparent;
margin: 0 6px; // add space at both ends (left/right)
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 2px;
border: 0 solid transparent; // remove vertical padding
border-left: 2px solid transparent; // keep horizontal padding (left/right)
border-right: 2px solid transparent;
background-clip: padding-box; // keep color inside the border
}
.emojiCategoryTab {
background-color: transparent;
border: none;
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
aspect-ratio: 1 / 1;
transform: scale(1);
&:hover {
background-color: $color-dark-surface-container-high;
}
&:active {
transform: scale(0.8);
}
&.active {
background-color: $color-dark-primary-container;
}
span {
position: relative;
z-index: 1;
display: flex;
}
}
}
}
.emojiGrid {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 3px;
}
.emojiCategorySection {
.emojiCategoryTitle {
position: sticky;
top: 5px;
padding: 5px 12px;
font-size: 0.85rem;
font-weight: 600;
color: $color-dark-on-surface-variant;
z-index: 2;
margin: 0;
backdrop-filter: blur(10px);
border-radius: 10px;
margin: 5px;
background-color: rgba($color-dark-surface-container-high, 0.7);
}
.emojiCategoryGrid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2px;
padding: 8px;
}
}
.emojiItem {
$size: 30px;
background: transparent;
border: none;
border-radius: 6px;
padding: 5px;
cursor: pointer;
transition: all 0.15s ease;
font-size: $size;
width: $size;
height: $size;
box-sizing: content-box;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: $color-dark-surface-container-high;
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
.emojiEmptyState {
padding: 20px;
text-align: center;
color: $color-dark-on-surface-variant;
font-size: 0.9rem;
}
// Integrated mode styles (inside reaction bar)
&.integrated {
position: relative !important;
width: 320px !important;
height: 400px !important;
transform: none !important;
opacity: 1 !important;
box-shadow: none;
border: none;
background: $color-dark-surface-container;
overflow: visible;
}
}
+495
View File
@@ -0,0 +1,495 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.message {
$status-indicator-size: 16px;
margin-bottom: 10px;
max-width: 70%;
position: relative;
width: fit-content;
display: flex;
align-items: flex-start;
gap: 8px;
.messageInner {
border-radius: 20px 20px 8px 8px; // Top corners rounded, bottom corners sharper
position: relative;
word-wrap: break-word;
overflow-wrap: anywhere;
word-break: break-word;
width: fit-content;
max-width: 100%;
display: inline-block;
.messageUsername {
font-weight: 600;
margin-bottom: 0.3rem;
font-size: 0.9rem;
margin: 10px;
cursor: pointer;
transition: transform 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
&:hover {
transform: scale(1.05);
}
}
.messageContent {
word-wrap: break-word;
margin: 10px 10px 0 10px;
white-space: pre-wrap;
> p:first-child {
margin-block-start: 0;
}
> p:last-child {
margin-block-end: 0;
}
}
:global(.quote).replyPreview {
user-select: none;
margin: 5px;
border-radius: 16px;
}
.messageAttachments {
padding: 5px 0 0 0;
overflow: hidden;
.attachment {
a {
text-decoration: none;
}
.attachementImage {
max-width: 200px;
border-radius: 8px;
cursor: pointer;
margin-left: 3px;
margin-right: 3px;
margin-bottom: 3px;
&:last-child {
margin-bottom: 0;
}
&.loading {
filter: blur(10px);
transition: filter 200ms ease;
}
}
.attachementImage.placeholder {
background: $color-dark-surface-container-highest;
pointer-events: none;
}
.imageWrapper {
position: relative;
display: inline-block;
}
.loadingOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.08);
backdrop-filter: blur(6px);
border-radius: 8px;
}
.preloadImage {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.withIconGap {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
}
.messageTime {
font-size: 0.7rem;
color: $color-dark-on-surface-variant;
margin-top: 0.3rem;
text-align: right;
user-select: none;
margin: 4px 8px 8px 8px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
.messageStatusIndicator {
display: flex;
align-items: center;
width: $status-indicator-size;
height: $status-indicator-size;
.errorIcon, .successIcon {
font-size: $status-indicator-size;
width: $status-indicator-size;
height: $status-indicator-size;
}
mdui-circular-progress {
width: $status-indicator-size;
height: $status-indicator-size;
}
}
}
}
&.received {
.messageProfilePic {
width: 40px;
height: 40px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
align-self: flex-end;
&:hover {
transform: scale(1.05);
}
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
}
.deletedUserAvatar {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid $color-dark-outline;
.deletedUserAvatarIcon {
font-size: 24px;
color: white;
}
}
}
.messageInner {
background: $color-dark-surface-container;
color: $color-dark-on-surface;
border-radius: 20px 20px 20px 8px; // Top-left: 5px, top-right: 20px, bottom: 8px
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid rgba($color-dark-outline-variant, 0.4);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(147, 51, 234, 0.05), rgba(99, 102, 241, 0.03));
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
}
.messageTime {
color: $color-dark-on-surface-variant;
font-weight: 500;
}
}
&.sent {
margin-left: auto;
flex-direction: row-reverse;
:global(.quote).replyPreview {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08));
border: 1px solid rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.5);
:global(.quote-inner) {
position: relative;
z-index: 1;
}
}
.messageInner {
background: linear-gradient(135deg, #9333EA, #6366F1, #2f68c5);
border-radius: 20px 20px 8px 20px; // Top-left: 20px, top-right: 5px, bottom: 8px
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
border: 1px solid rgba($color-dark-primary, 0.5);
position: relative;
overflow: hidden;
> * {
position: relative;
z-index: 1;
}
}
&.emojiMessage {
.messageInner {
align-items: flex-end;
display: flex;
flex-direction: column;
.messageContent {
&.emojiContent {
text-align: right;
}
}
}
}
.messageTime {
font-weight: 500;
}
}
// Emoji message styles
&.emojiMessage {
.messageInner {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
border-radius: 0;
&::before {
display: none;
}
}
.messageContent {
margin: 0;
padding: 0;
text-align: right;
&.emojiContent {
font-size: 2rem;
line-height: 1.2;
}
&.singleEmojiContent {
font-size: 4rem;
line-height: 1;
margin-bottom: 8px;
}
}
.messageTime {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 4px 8px;
margin-top: 8px;
margin-right: 0;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
user-select: none;
width: fit-content;
}
}
}
.messageUsername {
&.loading {
opacity: 0.6;
cursor: default;
}
}
// Fullscreen Image Viewer
.fullscreenImageOverlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
z-index: 9999;
opacity: 1;
transition: opacity 0.3s ease;
&.closing {
opacity: 0;
}
.fullscreenAnimatedImage {
position: absolute;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease;
}
.fullscreenControls {
position: absolute;
display: flex;
gap: 8px;
&.topRight {
top: 12px;
right: 12px;
}
}
.progressWrapper {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
}
// Mention link styling
.messageContent {
.mentionLink {
color: $color-dark-primary;
text-decoration: none;
font-weight: 500;
border-radius: 4px;
padding: 2px 4px;
transition: all 0.2s ease;
background-color: rgba(145, 206, 244, 0.1); // TODO adjust
&:hover {
background-color: rgba(145, 206, 244, 0.2); // TODO adjust
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
// Reaction styles
.messageReactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
margin-left: 10px;
margin-right: 10px;
animation: messageReactionsFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.reactionButton {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: none;
border-radius: 16px;
background-color: $color-dark-surface-container;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
font-size: 1px;
min-height: 28px;
animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.removing {
animation: reactionFadeOut 0.2s ease forwards;
}
&:hover {
background-color: $color-dark-surface-container-high;
transform: scale(1.05);
}
&.reacted {
background-color: $color-dark-primary-container;
border-color: $color-dark-primary;
color: $color-dark-on-primary-container;
&:hover {
background-color: color.adjust($color-dark-primary-container, $lightness: 20%);
}
}
}
.reactionEmoji {
font-size: 17px;
line-height: 1;
}
.reactionCount {
font-size: 12px;
font-weight: 500;
line-height: 1;
}
// Animation for reactions appearing/disappearing
@keyframes messageReactionsFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes reactionFadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes reactionFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.8);
}
}
@@ -0,0 +1,483 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Glassmorphism background mixin
%background {
background: rgba($color-dark-surface-container, 0.7);
backdrop-filter: blur(20px);
border: 1px solid rgba($color-dark-outline-variant, 0.3);
}
// Context menu reaction bar - main container
.contextMenuReactionBar {
position: fixed;
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
@extend %background;
border-radius: 16px;
z-index: 1001;
transition: width 0.3s ease-out, height 0.3s ease-out;
user-select: none;
// Animation states
&.entering {
opacity: 0;
transform: translateY(20px) scale(0.9);
animation: reactionBarEnter 0.2s ease forwards;
}
&.enteringLeft {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.9);
animation: reactionBarEnterLeft 0.2s ease forwards;
}
&.enteringUp {
opacity: 0;
transform: translateY(-20px) scale(0.9);
animation: reactionBarEnterUp 0.2s ease forwards;
}
&.enteringRight {
opacity: 0;
transform: translateX(20px) scale(0.9);
animation: reactionBarEnterRight 0.2s ease forwards;
}
&.closing {
opacity: 1;
transform: translateY(0) scale(1);
animation: reactionBarClose 0.2s ease forwards;
}
&.closingLeft {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
animation: reactionBarCloseLeft 0.2s ease forwards;
}
&.closingUp {
opacity: 1;
transform: translateY(0) scale(1);
animation: reactionBarCloseUp 0.2s ease forwards;
}
// Expanded state - contains emoji menu
&.expanded {
padding: 0;
overflow: hidden;
width: 320px;
height: 400px;
border-radius: 16px;
// Emoji menu wrapper inside expanded reaction bar
.emojiMenuWrapper {
width: 320px;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
animation: emojiMenuEnterDown 0.3s ease;
}
// Upward expansion animation
&.expandUpward .emojiMenuWrapper {
animation: emojiMenuEnterUp 0.3s ease !important;
}
}
// Reaction bar content - contains emoji buttons
.reactionBarContent {
display: flex;
align-items: center;
gap: 4px;
transition: opacity 0.3s ease-out;
&.faded {
opacity: 0;
}
}
// Individual emoji buttons
.reactionEmojiButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: transparent;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
font-size: 18px;
&:hover {
background: var(--mdui-color-surface-container-high);
transform: scale(1.3);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
}
// Expand button for emoji menu
.reactionExpandButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--mdui-color-outline);
border-radius: 16px;
background: var(--mdui-color-surface);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
background: var(--mdui-color-surface-container-high);
border-color: var(--mdui-color-primary);
transform: scale(1.1);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
:global(.material-symbols) {
font-size: 18px;
color: var(--mdui-color-on-surface);
transition: transform 0.2s ease;
}
&:hover :global(.material-symbols) {
transform: rotate(90deg);
}
}
}
// Context menu - separate element
.contextMenu {
position: fixed;
@extend %background;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2), 0 8px 32px rgba(0, 0, 0, 0.15);
padding: 8px;
min-width: 160px;
z-index: 1000;
user-select: none;
gap: 4px;
display: flex;
flex-direction: column;
// Animation states
&.entering {
animation: fadeInDown 0.2s ease forwards;
}
&.enteringLeft {
animation: fadeInLeft 0.2s ease forwards;
}
&.enteringUp {
animation: fadeInUp 0.2s ease forwards;
}
&.enteringUpLeft {
animation: fadeInUpLeft 0.2s ease forwards;
}
&.closing {
animation: fadeOutUp 0.2s ease forwards;
}
&.closingLeft {
animation: fadeOutRight 0.2s ease forwards;
}
&.closingUp {
animation: fadeOutDown 0.2s ease forwards;
}
&.closingUpLeft {
animation: fadeOutDownRight 0.2s ease forwards;
}
&.faded {
opacity: 0;
}
// Context menu items
.contextMenuItem {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 4px 6px;
cursor: pointer;
color: $color-dark-on-surface;
font-size: 0.9rem;
transition: background-color 0.25s ease, transform 0.25s ease;
border-radius: 8px;
&:hover {
background-color: rgba($color-dark-surface-container, 0.5);
}
&:active {
transform: scale(0.95);
}
:global(.material-symbols) {
font-size: 18px;
}
}
}
// Context menu wrapper animations
@keyframes contextMenuEnter {
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes contextMenuEnterLeft {
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes contextMenuEnterUp {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes contextMenuEnterUpLeft {
to {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
}
}
@keyframes contextMenuClose {
to {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes contextMenuCloseLeft {
to {
opacity: 0;
transform: translateX(-20px) scale(0.8);
}
}
@keyframes contextMenuCloseUp {
to {
opacity: 0;
transform: translateY(20px) scale(0.8);
}
}
@keyframes contextMenuCloseUpLeft {
to {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.8);
}
}
@keyframes emojiMenuEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes emojiMenuEnterDown {
from {
opacity: 0;
transform: scaleY(0);
transform-origin: top;
}
to {
opacity: 1;
transform: scaleY(1);
}
}
@keyframes emojiMenuEnterUp {
from {
opacity: 0;
transform: scaleY(0);
transform-origin: bottom;
}
to {
opacity: 1;
transform: scaleY(1);
}
}
// Reaction bar animations
@keyframes reactionBarEnter {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes reactionBarEnterLeft {
to {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
}
}
@keyframes reactionBarEnterUp {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes reactionBarEnterRight {
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes reactionBarClose {
to {
opacity: 0;
transform: translateY(-20px) scale(0.9);
}
}
@keyframes reactionBarCloseLeft {
to {
opacity: 0;
transform: translateX(-20px) translateY(-20px) scale(0.9);
}
}
@keyframes reactionBarCloseUp {
to {
opacity: 0;
transform: translateY(20px) scale(0.9);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUpLeft {
from {
opacity: 0;
transform: translate(10px, 10px);
}
to {
opacity: 1;
transform: translate(0, 0);
}
}
@keyframes fadeOutUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes fadeOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(10px);
}
}
@keyframes fadeOutDown {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes fadeOutDownRight {
from {
opacity: 1;
transform: translate(0, 0);
}
to {
opacity: 0;
transform: translate(10px, 10px);
}
}
@@ -0,0 +1,108 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Unified typing indicator styles (used for both public chat and DMs)
.typingIndicator {
display: flex;
align-items: center;
gap: 8px;
color: $color-dark-primary;
.typingDots {
display: flex;
gap: 2px;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: $color-dark-primary;
animation: typingDot 1.4s infinite ease-in-out;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
.typingText {
font-size: 0.875rem;
font-weight: 500;
}
}
// Online status display (used in DMs when not typing)
.onlineStatus {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: $color-dark-on-surface-variant;
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.online {
background: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
}
&.offline {
background: $color-dark-on-surface-variant;
opacity: 0.6;
}
}
.statusText {
font-weight: 500;
font-size: 0.75rem;
opacity: 0.8;
}
}
// Online indicator for profile pictures (positioned at bottom right)
.onlineIndicator {
position: absolute;
bottom: 0px;
right: 0px;
z-index: 10;
pointer-events: none;
transform: none;
.indicatorDot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid $color-dark-surface;
box-sizing: border-box;
display: block;
background: #4caf50;
position: relative;
transform: none;
&.online {
background: #4caf50;
}
}
}
// Typing dot animation
@keyframes typingDot {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@@ -0,0 +1,558 @@
@use "../../../css/material" as *;
@use "sass:color";
.callWindow {
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
user-select: none;
// Add transitions for smooth mode switching
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1),
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
top 0.4s cubic-bezier(0.4, 0, 0.2, 1),
left 0.4s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
backdrop-filter 0.4s cubic-bezier(0.4, 0, 0.2, 1),
border 0.4s cubic-bezier(0.4, 0, 0.2, 1),
border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1);
// Maximized mode (fullscreen)
&.maximized {
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
background-color: rgba($color-dark-surface, 0.98);
backdrop-filter: blur(40px);
border: none;
border-radius: 0;
cursor: default;
.callContent {
padding: 32px;
gap: 24px;
.videoTilesGrid {
gap: 24px;
min-height: 400px;
}
}
.callHeader {
.windowControls {
.windowControlBtn {
color: $color-dark-on-surface-variant;
&:hover {
color: $color-dark-on-surface;
background: rgba($color-dark-on-surface, 0.08);
}
}
}
}
}
// Minimized mode (PiP)
&.minimized {
width: 400px;
height: 300px;
background-color: rgba($color-dark-surface, 0.95);
backdrop-filter: blur(20px);
border: 2px solid rgba($color-dark-outline, 0.4);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
cursor: grab;
&.dragging {
cursor: grabbing;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1),
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
backdrop-filter 0.4s cubic-bezier(0.4, 0, 0.2, 1),
border 0.4s cubic-bezier(0.4, 0, 0.2, 1),
border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.callHeader {
padding: 12px;
min-height: auto;
.callHeaderInfo {
.username {
font-size: 14px;
}
.status {
font-size: 12px;
}
}
.windowControls {
.windowControlBtn {
color: $color-dark-primary;
&:hover {
color: color.adjust($color-dark-primary, $lightness: 10%);
background: rgba($color-dark-primary, 0.1);
}
}
}
}
.callContent {
padding: 8px;
gap: 8px;
.videoTilesGrid {
gap: 8px;
min-height: 120px;
}
.videoTile {
.tileLabel {
font-size: 10px;
padding: 2px 6px;
}
.videoPlaceholder {
.placeholderAvatar {
width: 40px;
height: 40px;
}
.placeholderUsername {
font-size: 12px;
}
}
}
.screenShareTile {
.screenShareVideo {
object-fit: contain;
}
}
}
.callControls {
padding: 8px;
gap: 8px;
:global(mdui-button-icon) {
--mdui-comp-icon-button-size: 36px;
}
}
}
// Dynamic gradients based on call state
&.gradientCalling {
border-color: rgba(255, 193, 7, 0.5);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
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: pulseGradient 2s ease-in-out infinite;
pointer-events: none;
}
}
&.gradientConnecting {
border-color: rgba(33, 150, 243, 0.5);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
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: connectingGradient 1.5s ease-in-out infinite;
pointer-events: none;
}
}
&.gradientActive {
border-color: rgba(76, 175, 80, 0.5);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
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: activeGradient 3s ease-in-out infinite;
pointer-events: none;
}
}
&.gradientDefault {
border-color: rgba(158, 158, 158, 0.3);
}
}
.callHeader {
padding: 20px;
border-bottom: 1px solid $color-dark-outline-variant;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
.windowControls {
display: flex;
gap: 8px;
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
.windowControlBtn {
transition: all 0.2s ease;
}
}
.callHeaderInfo {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
.username {
margin: 0;
font-size: 20px;
font-weight: 600;
color: $color-dark-on-surface;
}
.status {
margin: 0;
font-size: 14px;
color: $color-dark-on-surface-variant;
font-weight: 500;
}
.encryptionEmojis {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 8px;
.encryptionEmoji {
font-size: 24px;
display: inline-block;
animation: emojiPulse 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; }
&:nth-child(4) { animation-delay: 0.6s; }
}
}
}
}
.callContent {
padding: 24px;
display: flex;
flex-direction: row;
gap: 20px;
position: relative;
z-index: 1;
flex: 1;
overflow: hidden;
&.withScreenShare {
padding: 12px;
gap: 16px;
.screenShareArea {
display: flex;
align-items: center;
justify-content: center;
flex: 4;
min-width: 0;
max-width: 80vw;
position: relative;
padding: 0;
margin: 0;
}
.videoTilesSidebar {
display: flex;
flex-direction: column;
grid-template-columns: none;
flex: 1;
min-width: 250px;
max-width: 20%;
flex-shrink: 0;
gap: 16px;
overflow-y: auto;
overflow-x: hidden;
padding: 5px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba($color-dark-surface-variant, 0.3);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba($color-dark-primary, 0.5);
border-radius: 3px;
&:hover {
background: rgba($color-dark-primary, 0.7);
}
}
.videoTile {
min-height: 168px;
}
}
}
.screenShareArea {
display: none;
}
.videoTilesSidebar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
flex: 1;
}
.videoTile {
position: relative;
background: rgba($color-dark-surface-variant, 0.5);
border-radius: 16px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba($color-dark-outline, 0.3);
transition: all 0.3s ease;
&:hover {
border-color: rgba($color-dark-outline, 0.5);
transform: scale(1.02);
}
.videoElement {
width: 100%;
height: 100%;
object-fit: cover;
}
.videoPlaceholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
height: 100%;
background: rgba($color-dark-surface-variant, 0.6);
.placeholderAvatar {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.placeholderUsername {
font-size: 18px;
font-weight: 600;
color: $color-dark-on-surface;
}
}
.tileLabel {
position: absolute;
bottom: 12px;
left: 12px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.75);
color: white;
font-size: 13px;
font-weight: 600;
border-radius: 8px;
backdrop-filter: blur(10px);
}
&.localVideo {
.videoElement {
transform: scaleX(-1);
}
}
}
.screenShareTile {
position: relative;
border: 2px solid rgba($color-dark-primary, 0.6);
border-radius: 12px;
overflow: hidden;
background: rgba(0, 0, 0, 0.95);
display: inline-block;
max-width: 100%;
max-height: 100%;
.screenShareVideo {
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
display: block;
object-fit: contain;
}
.tileLabel {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
font-size: 14px;
padding: 8px 16px;
background: rgba($color-dark-primary, 0.9);
color: $color-dark-on-primary;
border-radius: 8px;
backdrop-filter: blur(10px);
pointer-events: none;
z-index: 1;
}
}
.localScreenShare {
display: flex;
}
.remoteScreenShare {
display: flex;
}
.remoteVideo {
display: block;
}
}
.callControls {
padding: 20px;
display: flex;
justify-content: center;
gap: 16px;
border-top: 1px solid $color-dark-outline-variant;
position: relative;
z-index: 1;
flex-shrink: 0;
:global(mdui-button-icon) {
transition: all 0.2s ease;
&[icon="call_end"] {
background: rgba(244, 67, 54, 0.2);
color: rgb(244, 67, 54);
&:hover {
background: rgba(244, 67, 54, 0.35);
transform: scale(1.1);
}
}
&[icon="videocam"],
&[icon="videocam_off"],
&[icon="screen_share"],
&[icon="stop_screen_share"],
&[icon="mic"],
&[icon="mic_off"] {
&:hover {
background: rgba($color-dark-primary, 0.15);
transform: scale(1.1);
}
}
}
}
.remoteAudio {
position: fixed;
bottom: 0px;
right: 0px;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
}
// Animations
@keyframes pulseGradient {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 0.7;
}
}
@keyframes connectingGradient {
0%, 100% {
opacity: 0.3;
}
50% {
opacity: 0.6;
}
}
@keyframes activeGradient {
0%, 100% {
opacity: 0.2;
}
50% {
opacity: 0.4;
}
}
@keyframes emojiPulse {
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.15);
opacity: 1;
}
}
@@ -0,0 +1,36 @@
.cpdContainer {
padding: 16px;
.cpdTitlebar {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 16px;
gap: 10px;
.cpdTitle {
font-size: 20px;
}
}
.cpdContent form {
display: flex;
flex-direction: column;
gap: 16px;
.cpdLogoutAll {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.cpdActions {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
}
}
@@ -0,0 +1,15 @@
@use "@/css/material" as *;
.deletedUserAvatar {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid $color-dark-outline;
}
.deletedUserAvatarIcon {
color: white;
}
@@ -0,0 +1,40 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chatInterface {
height: 100%;
background: $color-dark-background;
// background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba($color-dark-primary, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.15) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: 10;
}
.allContainer {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
.chatContainer {
display: flex;
width: 100%;
flex-direction: column;
overflow: hidden;
}
}
}
@@ -0,0 +1,194 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chatList {
display: flex;
flex-direction: column;
flex-grow: 0;
width: 40%;
background-color: $color-dark-surface-container;
height: 100%;
z-index: 1000;
min-height: 0; // allow children to manage their own scrolling
position: relative; // provide positioning context for absolute children
overflow: hidden;
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
.chatHeaderLeft {
display: flex;
color: white;
font-size: 25px;
background-color: $color-dark-surface-container;
width: 100%;
justify-content: center;
align-items: center;
padding: 16px;
user-select: none;
.logo {
$size: 35px;
width: $size;
height: $size;
margin-right: 8px;
}
.productName {
flex-grow: 1;
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #C084FC, #7E22CE);
background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 0 20px rgba(147, 51, 234, 0.5);
}
.profile {
a {
display: flex;
align-items: center;
text-decoration: none;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba($color-dark-primary, 0.4);
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 0 25px rgba($color-dark-primary, 0.5);
border-color: rgba($color-dark-primary, 0.6);
}
}
}
}
}
.unifiedChatsList {
flex: 1;
min-height: 0; // allow scroll area to size correctly
overflow-y: auto;
margin-top: 10px;
}
// Search container
.searchContainer {
position: relative;
flex-shrink: 0;
height: 48px + 8px;
.searchLoading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px;
color: $color-dark-on-surface-variant;
mdui-circular-progress {
--mdui-circular-progress-color: $color-dark-primary;
}
}
.searchEmpty,
.searchHint {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
color: $color-dark-on-surface-variant;
text-align: center;
overflow: hidden;
}
// Custom styling for search result images
mdui-list-item {
.searchResultContainer {
display: flex;
.searchResult {
display: flex;
flex-direction: row;
align-items: center;
margin: 8px 12px;
box-sizing: border-box;
.searchResultIcon {
position: relative;
width: 40px;
height: 40px;
display: flex;
margin-right: 16px;
.searchResultIconImg {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
}
.searchResultBody {
display: flex;
flex-direction: column;
.searchResultHeadline {
display: flex;
align-items: center;
gap: 6px;
}
.searchResultDescription {
display: flex;
align-items: center;
}
}
}
}
}
}
mdui-bottom-app-bar {
position: relative;
width: 100%;
padding-left: 16px;
padding-right: 16px;
margin-top: auto;
}
}
// Description styling for list items
.listDescription {
word-wrap: break-word;
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.deletedUserAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.deletedUserAvatarIcon {
font-size: 24px;
color: white;
}
}
@@ -0,0 +1,229 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Override StyledDialog's content
.profileDialogContent {
align-items: center;
}
.errorMessage {
color: $color-dark-error;
font-size: small;
}
.profilePictureSection {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 16px;
}
.profilePicture {
width: 120px;
height: 120px;
border-radius: 60px;
object-fit: cover;
border: 3px solid $color-dark-outline;
}
.deletedAvatar {
width: 120px;
height: 120px;
border-radius: 60px;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid $color-dark-outline;
}
.deletedAvatarIcon {
width: 64px;
height: 64px;
font-size: 64px;
color: white;
}
.profilePictureEditOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 60px;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.usernameSection {
text-align: center;
.usernameWithBadge {
gap: 0;
display: flex;
flex-direction: row;
align-items: center;
.usernameInput {
background: none;
border: none;
font-size: 1.5rem;
font-weight: 500;
color: $color-dark-on-surface;
text-align: center;
outline: none;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
cursor: text;
}
}
}
.onlineStatusSection {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.onlineIndicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color-dark-primary;
&.offline {
background: $color-dark-on-surface-variant;
}
}
.statusText {
font-size: 0.875rem;
color: $color-dark-on-surface;
}
}
.profileSections {
margin: 16px;
display: flex;
flex-direction: column;
gap: 4px;
width: calc(100% - (16px * 2));
box-sizing: border-box;
}
.section {
$edge-radius: 24px;
background: $color-dark-surface-container-high;
border-radius: 10px;
padding: 8px 16px;
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
transition: outline 0.1s ease;
outline: 0px solid transparent;
outline-offset: -1px;
.contentContainer {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
.label {
font-size: small;
color: $color-dark-on-surface-variant;
user-select: none;
}
.value {
color: $color-dark-on-surface;
font-size: medium;
width: 100%;
line-height: 1.4;
font-family: inherit;
cursor: text;
outline: none;
background: transparent;
border: none;
caret-color: $color-dark-primary;
&::placeholder {
color: $color-dark-on-surface-variant;
}
}
}
// First and last section
&:first-child {
border-top-left-radius: $edge-radius;
border-top-right-radius: $edge-radius;
}
&:last-child {
border-bottom-left-radius: $edge-radius;
border-bottom-right-radius: $edge-radius;
}
&.error {
outline: 1px solid $color-dark-error;
.errorMessage {
margin-top: 4px;
}
}
}
.profileDialogFab {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 1002;
transform: translateY(100px);
transition: transform 0.3s ease;
&.visible {
transform: translateY(0);
}
}
// Admin Actions Section
.adminActionsSection {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.adminActionsHeader {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px 0;
color: #f44336;
}
.adminButtons {
display: flex;
flex-direction: column;
gap: 12px;
:global(mdui-button) {
width: 100%;
}
}
.verifySection {
display: block;
}
@@ -0,0 +1,19 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
:global(.quote).contextualContent > :global(.quote-inner) {
display: flex;
flex-direction: column;
gap: 4px;
.replyUsername {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.replyText {
overflow: hidden;
text-overflow: ellipsis;
}
}
@@ -0,0 +1,146 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.chatWrapper {
flex-grow: 1;
height: 100%;
position: relative;
overflow: hidden;
.chatMain {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
overflow-y: auto;
.chatHeader {
padding: 16px;
margin: 10px 10px 0 10px;
background: rgba($color-dark-surface-container, 0.7);
backdrop-filter: blur(20px);
border-radius: 30px;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
display: flex;
align-items: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: sticky;
top: 10px;
z-index: 5;
.chatHeaderAvatar {
width: 45px;
height: 45px;
border-radius: 20%;
object-fit: cover;
margin-right: 1rem;
}
.chatHeaderInfo {
display: flex;
justify-content: space-between;
align-items: center;
flex: 1;
.infoChat {
display: flex;
flex-direction: column;
h4 {
font-size: 1.1rem;
margin: 0 0 0.2rem;
}
p {
margin: 0;
font-size: 0.8rem;
color: #718096;
}
}
a {
display: flex;
flex-direction: row;
text-decoration: none;
color: white;
justify-content: end;
padding: 0;
margin: 0;
position: absolute;
right: 2%;
top: 2%;
&:hover {
border: none;
}
}
}
}
.chatMessages {
flex: 1;
padding: 10px 20px;
position: relative;
z-index: 1;
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 20px;
}
}
}
.fileOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
backdrop-filter: blur(20px);
pointer-events: none;
.fileOverlayWrapper {
border-radius: 30px;
outline: 3px dashed $color-dark-primary;
outline-offset: -20px;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
.fileOverlayInner {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: rgba(18, 18, 18, 0.8);
border: 1px solid $color-dark-surface-container-high;
border-radius: 12px;
color: $color-dark-on-surface;
mdui-icon {
color: $color-dark-primary;
}
}
}
}
}
.deleteChatBar {
display: flex;
justify-content: center;
padding: 12px 16px 16px;
}
@@ -0,0 +1,112 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
.settingsDialog {
width: calc(100vw - 60px) !important;
height: calc(100vh - 60px) !important;
max-width: none !important;
max-height: none !important;
.settingsDialogInner {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 24px;
overflow-y: auto;
.settingsHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.settingsTitle {
margin: 0;
font-size: 22px;
font-weight: 500;
color: $color-dark-on-surface;
flex: 1;
}
}
.settingsLayout {
display: flex;
flex: 1;
overflow: hidden;
gap: 1px;
.sidebar {
width: 240px;
overflow-y: auto;
}
.contentPanel {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
.panelContent {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
.panelTitle {
margin: 0;
font-size: 20px;
font-weight: 500;
color: $color-dark-on-surface;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline-variant;
}
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
padding: 32px;
}
.sectionActions {
display: flex;
justify-content: flex-end;
padding: 8px 0;
}
}
}
}
}
}
.clickableItem {
cursor: pointer;
border-radius: 16px;
overflow: hidden;
}
.dangerItem {
color: $color-dark-error;
&::part(icon) {
color: $color-dark-error;
}
&::part(headline) {
color: $color-dark-error;
}
&::part(description) {
color: $color-dark-error;
}
}
@@ -0,0 +1,72 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Suspension Dialog Content Styles
.suspensionDialogContent {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
padding: 24px;
.suspensionIconSection {
margin-bottom: 24px;
.suspensionIcon {
font-size: 80px;
color: #f44336;
display: block;
}
}
.suspensionText {
max-width: 400px;
.suspensionHeadline {
font-size: 28px;
font-weight: 600;
margin: 0 0 16px 0;
color: #f44336;
line-height: 1.2;
}
.suspensionBody {
font-size: 16px;
margin: 0 0 20px 0;
color: $color-dark-on-surface;
line-height: 1.5;
}
.suspensionReason {
text-align: left;
strong {
color: $color-dark-on-surface;
font-weight: 600;
}
.suspensionReasonText {
background: $color-dark-surface-container-high;
border-radius: 12px;
padding: 16px;
margin-top: 5px;
font-size: 14px;
color: $color-dark-on-surface;
text-align: left;
}
}
.suspensionSecondary {
font-size: 14px;
margin: 20px 0 0 0;
color: $color-dark-on-surface-variant;
line-height: 1.4;
a {
margin-left: 4px;
}
}
}
}
+360
View File
@@ -0,0 +1,360 @@
import { useCallStore } from "@/state/call";
import { useUserStore } from "@/state/user";
import * as WebRTC from "@/core/calls/webrtc";
import { CallSignalingHandler } from "@/core/calls/signaling";
import { setCallSignalingHandler } from "@/core/websocket";
import { generateCallSessionKey, generateCallEmojis } from "@/core/calls/encryption";
import { createRef, useEffect } from "react";
import { doAfterInteraction } from "@/utils/utils";
// Global refs shared across all instances
let globalRemoteAudioRef = createRef<HTMLAudioElement>();
let globalLocalVideoRef = createRef<HTMLVideoElement>();
let globalRemoteVideoRef = createRef<HTMLVideoElement>();
let globalLocalScreenShareRef = createRef<HTMLVideoElement>();
let globalRemoteScreenShareRef = createRef<HTMLVideoElement>();
export default function useCall() {
const {
call,
startCall,
endCall,
setCallStatus,
toggleMute,
toggleVideo,
toggleScreenShare,
setCallEncryption,
setCallSessionKeyHash,
setRemoteVideoEnabled,
setRemoteScreenSharing,
receiveCall
} = useCallStore();
const { user } = useUserStore();
const remoteAudioRef = globalRemoteAudioRef;
const localVideoRef = globalLocalVideoRef;
const remoteVideoRef = globalRemoteVideoRef;
const localScreenShareRef = globalLocalScreenShareRef;
const remoteScreenShareRef = globalRemoteScreenShareRef;
useEffect(() => {
// Initialize call signaling handler
const signalingHandler = new CallSignalingHandler(() => ({
receiveCall: (userId: number, username: string) => {
// Use the receiveCall function from state
receiveCall(userId, username);
},
endCall,
setCallSessionKeyHash,
setRemoteVideoEnabled,
setRemoteScreenSharing
}));
setCallSignalingHandler(signalingHandler);
// Set up call state change handler
WebRTC.callbacks.onCallStateChange = (userId: number, state: string) => {
const currentCall = call;
if (currentCall.remoteUserId === userId) {
switch (state) {
case "connecting":
setCallStatus("connecting");
break;
case "connected":
setCallStatus("active");
break;
case "disconnected":
case "failed":
case "closed":
endCall();
break;
}
}
};
// Set up remote audio stream handler
WebRTC.callbacks.onRemoteStream = (_userId: number, stream: MediaStream) => {
if (!remoteAudioRef.current) {
return;
}
const el = remoteAudioRef.current;
try {
el.srcObject = stream;
el.muted = false;
el.volume = 1.0;
el.autoplay = true;
// Handle audio events
el.addEventListener("error", () => {
console.warn("[AUDIO] element error", (el.error?.message) || el.error);
});
el.play().catch(() => {
doAfterInteraction(() => el.play());
});
} catch (e) {
console.warn("failed to attach remote stream:", e);
}
};
// Set up local video stream handler
WebRTC.callbacks.onLocalVideoStream = (_userId: number, stream: MediaStream | null) => {
if (!localVideoRef.current) {
return;
}
const el = localVideoRef.current;
try {
el.srcObject = stream;
el.muted = true; // Always mute local video to avoid feedback
el.autoplay = true;
if (stream) {
el.play().catch((err) => {
console.error("Failed to play local video:", err);
doAfterInteraction(() => el.play()).catch(() => {});
});
}
} catch (e) {
console.warn("failed to attach local video stream:", e);
}
};
// Set up remote video stream handler
WebRTC.callbacks.onRemoteVideoStream = (_userId: number, stream: MediaStream | null) => {
if (!remoteVideoRef.current) {
return;
}
const el = remoteVideoRef.current;
try {
el.srcObject = stream;
el.muted = false;
el.autoplay = true;
if (stream) {
el.play().catch((err) => {
console.error("Failed to play remote video:", err);
doAfterInteraction(() => el.play()).catch(() => {});
});
}
} catch (e) {
console.warn("failed to attach remote video stream:", e);
}
};
// Set up local screen share handler
WebRTC.callbacks.onLocalScreenShare = (_userId: number, stream: MediaStream | null) => {
if (!localScreenShareRef.current) {
return;
}
const el = localScreenShareRef.current;
try {
el.srcObject = stream;
el.muted = true;
el.autoplay = true;
if (stream) {
el.play().catch((err) => {
console.error("Failed to play local screen share:", err);
doAfterInteraction(() => el.play()).catch(() => {});
});
}
} catch (e) {
console.warn("failed to attach local screen share stream:", e);
}
};
// Set up remote screen share handler
WebRTC.callbacks.onRemoteScreenShare = (_userId: number, stream: MediaStream | null) => {
if (!remoteScreenShareRef.current) {
return;
}
const el = remoteScreenShareRef.current;
try {
el.srcObject = stream;
el.muted = false;
el.autoplay = true;
if (stream) {
el.play().catch((err) => {
console.error("Failed to play remote screen share:", err);
doAfterInteraction(() => el.play()).catch(() => {});
});
}
} catch (e) {
console.warn("failed to attach remote screen share stream:", e);
}
};
return () => {
WebRTC.cleanup();
setCallSignalingHandler(null);
};
}, [user.authToken, call.remoteUserId, setCallStatus, endCall, startCall, setRemoteVideoEnabled, setRemoteScreenSharing]);
// Watch for session key hash changes and generate emojis
useEffect(() => {
if (call.sessionKeyHash && call.encryptionEmojis.length === 0) {
const emojis = generateCallEmojis(call.sessionKeyHash);
setCallEncryption(call.sessionKeyHash, emojis);
}
}, [call.sessionKeyHash, call.encryptionEmojis.length, setCallEncryption]);
async function requestAudioPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
// Stop the stream immediately as we just needed permission
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
console.error("Failed to get audio permissions:", error);
return false;
}
}
async function initiateCall(userId: number, username: string) {
const hasPermission = await requestAudioPermissions();
if (!hasPermission) {
return;
}
let sessionKey;
try {
// Generate call session key and emojis
sessionKey = await generateCallSessionKey();
const emojis = generateCallEmojis(sessionKey.hash);
// Start the call in state
startCall(userId, username);
setCallStatus("calling");
setCallEncryption(sessionKey.hash, emojis);
} catch (error) {
console.error("Failed to generate call encryption:", error);
endCall();
return;
}
// 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
await WebRTC.sendWrappedCallSessionKey(userId, sessionKey.key, sessionKey.hash);
} else {
endCall();
}
}
async function acceptCall() {
if (!call.remoteUserId) {
return;
}
setCallStatus("connecting");
const success = await WebRTC.acceptCall(call.remoteUserId);
if (!success) {
endCall();
}
}
async function rejectCall() {
if (!call.remoteUserId) {
return;
}
await WebRTC.rejectCall(call.remoteUserId);
endCall();
}
async function handleEndCall() {
if (call.remoteUserId) {
await WebRTC.endCall(call.remoteUserId);
}
endCall();
}
function handleToggleMute() {
if (call.remoteUserId) {
const isMuted = WebRTC.toggleMute(call.remoteUserId);
// Update mute state in store
if (isMuted !== call.isMuted) {
toggleMute();
}
}
}
async function handleToggleVideo() {
if (call.remoteUserId) {
const isEnabled = await WebRTC.toggleVideo(call.remoteUserId);
// Update video state in store
if (isEnabled !== call.isVideoEnabled) {
toggleVideo();
}
}
}
async function handleToggleScreenShare() {
if (call.remoteUserId) {
const isEnabled = await WebRTC.toggleScreenShare(call.remoteUserId);
// Update screen share state in store
if (isEnabled !== call.isSharingScreen) {
toggleScreenShare();
}
}
}
async function handleIncomingCall(userId: number, username: string) {
// Don't generate session key here - wait for it from the caller
await WebRTC.handleIncomingCall(userId, username);
}
async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit) {
await WebRTC.handleCallOffer(userId, offer);
}
async function handleCallAnswer(userId: number, answer: RTCSessionDescriptionInit) {
await WebRTC.handleCallAnswer(userId, answer);
}
async function handleIceCandidate(userId: number, candidate: RTCIceCandidateInit) {
await WebRTC.handleIceCandidate(userId, candidate);
}
async function handleCallSessionKey(sessionKeyHash: string) {
try {
// Just generate and display the emojis from the hash
// The actual session key will arrive via the wrapped key mechanism
const emojis = generateCallEmojis(sessionKeyHash);
setCallEncryption(sessionKeyHash, emojis);
} catch (error) {
console.error("Failed to generate call emojis from hash:", error);
}
}
return {
call: call,
initiateCall,
acceptCall,
rejectCall,
endCall: handleEndCall,
toggleMute: handleToggleMute,
toggleVideo: handleToggleVideo,
toggleScreenShare: handleToggleScreenShare,
handleIncomingCall,
handleCallOffer,
handleCallAnswer,
handleIceCandidate,
handleCallSessionKey,
remoteAudioRef,
localVideoRef,
remoteVideoRef,
localScreenShareRef,
remoteScreenShareRef
};
}
+429
View File
@@ -0,0 +1,429 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat";
import api from "@/core/api";
import type { ConversationResponse } from "@/core/api/chats/dm";
import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket";
export interface DMUser extends User {
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
}
// Utility function for consistent username formatting in DM messages
export function formatDMUsername(
senderId: number,
_recipientId: number,
currentUserId: number,
otherUsername: string
): string {
const isFromCurrentUser = senderId === currentUserId;
return isFromCurrentUser ? "Вы" : otherUsername;
}
// Utility function for consistent message content formatting
export function formatDMMessageContent(
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) + "..."
: content;
return prefix + truncatedContent;
}
export function useDM() {
const { user } = useUserStore();
const { setDmUsers, setActiveDm, addMessage, clearMessages } = useChatStore();
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const usersLoadedRef = useRef(false);
// Load last message and unread count for a specific user
const loadUserLastMessage = useCallback(async (dmUser: DMUser) => {
if (!user.authToken) return;
try {
// Get public key
const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
// Get message history
const { messages } = await api.chats.dm.fetchMessages(dmUser.id, user.authToken, 50);
if (messages.length === 0) return;
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
try {
const decrypted = await api.chats.dm.decrypt(lastMessage, user.currentUser?.id);
try {
lastPlaintext = (JSON.parse(decrypted) as DmEncryptedJSON).data.content;
} catch {
// Fallback: decrypted payload is plain text
lastPlaintext = decrypted;
}
} catch (error) {
console.error("Failed to decrypt last message:", error);
}
// Calculate unread count
const lastReadId = getLastReadId(dmUser.id);
let unreadCount = 0;
for (const msg of messages) {
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
unreadCount++;
}
}
// Update user state
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
unreadCount,
publicKey
}
: u
));
} catch (error) {
console.error("Failed to load last message for user:", dmUser.id, error);
}
}, [user.authToken]);
// 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 api.chats.dm.conversations(user.authToken);
// Process conversations and decrypt last messages
const dmUsersWithState: DMUser[] = await Promise.all(
conversations.map(async (conv: ConversationResponse) => {
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
: conv.lastMessage.senderId;
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
// Decrypt the last message
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, user.currentUser?.id);
let messageText: string;
try {
messageText = (JSON.parse(decryptedJson) as DmEncryptedJSON).data.content;
} catch {
messageText = decryptedJson;
}
lastMessageContent = formatDMMessageContent(messageText, conv.lastMessage.senderId, user.currentUser?.id!);
}
} catch (error) {
console.error("Failed to decrypt last message for user", conv.user.id, error);
}
}
return {
...conv.user,
unreadCount: conv.unreadCount,
lastMessage: lastMessageContent,
publicKey: null
};
})
);
setDmUsersState(dmUsersWithState);
setDmUsers(conversations.map((conv: ConversationResponse) => conv.user));
} catch (error) {
console.error("Failed to load DM conversations:", error);
} finally {
setIsLoadingUsers(false);
}
}, [user.authToken, isLoadingUsers]);
// Reset users loaded flag when user changes
useEffect(() => {
usersLoadedRef.current = false;
}, [user.authToken]);
// Load DM history for active conversation
const loadDMHistory = useCallback(async (userId: number) => {
if (!user.authToken || isLoadingHistory) return;
setIsLoadingHistory(true);
try {
const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
const text = await api.chats.dm.decrypt(env, user.currentUser?.id);
const isAuthor = env.senderId !== userId;
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
decryptedMessages.push({
id: env.id,
user_id: env.senderId,
content: text,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false
});
if (env.senderId === userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (error) {
console.error("Error decrypting message:", error);
}
}
clearMessages();
decryptedMessages.forEach(msg => addMessage(msg));
// Update last read ID
if (maxIncomingId > 0) {
setLastReadId(userId, maxIncomingId);
// Clear unread count
setDmUsersState(prev => prev.map(u =>
u.id === userId ? { ...u, unreadCount: 0 } : u
));
}
} catch (error) {
console.error("Failed to load DM history:", error);
} finally {
setIsLoadingHistory(false);
}
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
// Send DM message
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
if (!user.authToken) return;
try {
await api.chats.dm.send(recipientId, publicKey, content, user.authToken);
} catch (error) {
console.error("Failed to send DM:", error);
}
}, [user.authToken]);
// Start DM conversation
const startDMConversation = useCallback(async (dmUser: DMUser) => {
if (!user.authToken) return;
try {
// Get public key if not already loaded
let publicKey = dmUser.publicKey;
if (!publicKey) {
publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
}
// Set active DM
setActiveDm({
userId: dmUser.id,
username: dmUser.username,
publicKey
});
// Load conversation history
await loadDMHistory(dmUser.id);
} catch (error) {
console.error("Failed to start DM conversation:", error);
}
}, [user.authToken, setActiveDm, loadDMHistory]);
// Force reload users (useful for refreshing the list)
const reloadUsers = useCallback(() => {
usersLoadedRef.current = false;
loadUsers();
}, [loadUsers]);
// Reload a specific user's conversation data
const reloadUserConversation = useCallback(async (userId: number) => {
if (!user.authToken) return;
try {
const conversations = await api.chats.dm.conversations(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
: userConversation.lastMessage.senderId;
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
// Decrypt the last message
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, user.currentUser?.id);
let messageText: string;
try {
messageText = (JSON.parse(decryptedJson) as DmEncryptedJSON).data.content;
} catch {
messageText = decryptedJson;
}
lastMessageContent = formatDMMessageContent(messageText, userConversation.lastMessage.senderId, user.currentUser?.id!);
}
} catch (error) {
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) {
return {
...u,
lastMessage: lastMessageContent,
unreadCount: userConversation.unreadCount
};
}
return u;
}));
} else {
// If conversation no longer exists, remove the user from the list
setDmUsersState(prev => prev.filter(u => u.id !== userId));
// Get current dmUsers and filter out the removed user
const currentDmUsers = useChatStore.getState().dmUsers;
setDmUsers(currentDmUsers.filter((u: User) => u.id !== userId));
}
} catch (error) {
console.error("Failed to reload user conversation:", error);
}
}, [user.authToken]);
// WebSocket message handler for conversation list updates
useEffect(() => {
async function handleWebSocketMessage(e: MessageEvent) {
try {
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 api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await api.chats.dm.decrypt(envelope, user.currentUser?.id);
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,
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
lastMessage: formattedMessage,
publicKey
}
: u
));
}
} catch (error) {
console.error("Failed to update last message preview:", error);
}
} else if (msg.type === "dmEdited") {
const { id, senderId, recipientId, ...envelope } = msg.data;
// Update last message preview for conversation list
if (!user.currentUser?.id) {
return;
}
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
try {
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await api.chats.dm.decrypt(envelope, user.currentUser?.id);
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,
lastMessage: formattedMessage,
publicKey
}
: u
));
}
} catch (error) {
console.error("Failed to update edited message preview:", error);
}
} 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;
reloadUserConversation(otherUserId);
}
} catch (error) {
console.error("Failed to handle WebSocket message:", error);
}
}
websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [user.currentUser, user.authToken, reloadUserConversation]);
return {
dmUsers,
isLoadingUsers,
isLoadingHistory,
loadUsers,
reloadUsers,
reloadUserConversation,
startDMConversation,
sendDMMessage,
loadUserLastMessage
};
}
// Helper functions for localStorage
function getLastReadId(userId: number): number {
try {
const v = localStorage.getItem(`dmLastRead:${userId}`);
return v ? Number(v) : 0;
} catch {
return 0;
}
}
function setLastReadId(userId: number, id: number): void {
try {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {}
}
+99
View File
@@ -0,0 +1,99 @@
import { useState, useCallback, useEffect } from "react";
import { useUserStore } from "@/state/user";
import api from "@/core/api";
import type { ProfileData } from "@/core/api/user/profile";
import { showSuccess, showError } from "@/utils/notification";
export default function useProfile() {
const { user } = useUserStore();
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
// Load profile data
const loadProfileData = useCallback(async () => {
if (!user.authToken) return;
setIsLoading(true);
try {
const data = await api.user.profile.get(user.authToken);
if (data) {
setProfileData(data);
}
} catch (error) {
console.error('Error loading profile:', error);
showError('Ошибка при загрузке профиля');
} finally {
setIsLoading(false);
}
}, [user.authToken]);
// Update profile
const updateProfileData = useCallback(async (data: Partial<ProfileData>) => {
if (!user.authToken) return false;
setIsUpdating(true);
try {
const success = await api.user.profile.update(user.authToken, data);
if (success) {
// Reload profile data to get updated information
await loadProfileData();
showSuccess('Профиль обновлен!');
return true;
} else {
showError('Ошибка при обновлении профиля');
return false;
}
} catch (error) {
console.error('Error updating profile:', error);
showError('Ошибка при обновлении профиля');
return false;
} finally {
setIsUpdating(false);
}
}, [user.authToken, loadProfileData]);
// Upload profile picture
const uploadProfilePictureData = useCallback(async (file: Blob) => {
if (!user.authToken) return false;
setIsUpdating(true);
try {
const result = await api.user.profile.uploadPicture(user.authToken, file);
if (result) {
// Update profile data with new picture URL
setProfileData(prev => prev ? {
...prev,
profile_picture: result.profile_picture_url
} : null);
showSuccess('Фото профиля обновлено!');
return true;
} else {
showError('Ошибка при загрузке фото');
return false;
}
} catch (error) {
console.error('Error uploading profile picture:', error);
showError('Ошибка при загрузке фото');
return false;
} finally {
setIsUpdating(false);
}
}, [user.authToken]);
// Load profile data when user is authenticated
useEffect(() => {
if (user.authToken) {
loadProfileData();
}
}, [user.authToken, loadProfileData]);
return {
profileData,
isLoading,
isUpdating,
loadProfileData,
updateProfileData,
uploadProfilePictureData
};
}
+82
View File
@@ -0,0 +1,82 @@
import { LeftPanel } from "./left/LeftPanel";
import { RightPanel } from "./right/RightPanel";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { CallWindow } from "./right/calls/CallWindow";
import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile";
import api from "@/core/api";
import styles from "@/pages/chat/css/layout.module.scss";
export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
const location = useLocation();
const navigate = useNavigate();
const { user } = useUserStore();
const { setProfileDialog } = useProfileStore();
const processedProfile = useRef<string | null>(null);
// Handle profile links ONLY from navigation state (from SmartCatchAll)
useEffect(() => {
async function handleProfileLink() {
if (!user.authToken) return;
// Only process profile links that come from navigation state (SmartCatchAll)
// This prevents re-processing on page refresh
if (!location.state?.profileInfo) return;
const profileInfo = location.state.profileInfo;
// Create a unique key for this profile
const profileKey = profileInfo.userId
? `user_${profileInfo.userId}`
: `username_${profileInfo.username}`;
// Skip if we've already processed this exact profile
if (processedProfile.current === profileKey) return;
processedProfile.current = profileKey; // Mark this specific profile as processed
try {
let userProfile;
if (profileInfo.userId) {
// Fetch by user ID
userProfile = await api.user.profile.fetchById(user.authToken, profileInfo.userId);
} else if (profileInfo.username) {
// Fetch by username
userProfile = await api.user.profile.fetchByUsername(user.authToken, profileInfo.username);
}
if (userProfile) {
setProfileDialog({
...userProfile,
userId: userProfile.id,
memberSince: userProfile.created_at,
isOwnProfile: userProfile.id === user.currentUser?.id
});
}
// Clear the navigation state to prevent re-processing on refresh
navigate(location.pathname, { replace: true, state: null });
} catch (error) {
console.error("Failed to fetch user profile from URL:", error);
}
}
handleProfileLink();
}, [location.state, user.authToken, user.currentUser?.id, navigate, location.pathname]);
if (navigateDownloadApp) return navigateDownloadApp;
return (
<div className={styles.chatInterface}>
<div className={styles.allContainer}>
<LeftPanel />
<RightPanel />
</div>
<CallWindow />
</div>
);
}
+600
View File
@@ -0,0 +1,600 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { useProfileStore } from "@/state/profile";
import { useUserStore } from "@/state/user";
import type { ProfileDialogData } from "@/state/types";
import defaultAvatar from "@/images/default-avatar.png";
import { confirm } from "mdui/functions/confirm";
import { prompt } from "mdui/functions/prompt";
import api from "@/core/api";
import { RichTextArea } from "@/core/components/RichTextArea";
import { StatusBadge } from "@/core/components/StatusBadge";
import { VerifyButton } from "@/core/components/VerifyButton";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineStatus } from "./right/OnlineStatus";
import { Input } from "@/core/components/Input";
import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialButton, MaterialFab, MaterialIcon } from "@/utils/material";
import { displayNameForUser, isDeletedPeer } from "@/core/userDisplay";
import { DeletedUserAvatar } from "@/core/DeletedUserAvatar";
import { parseApiTimestamp } from "@/utils/utils";
import styles from "@/pages/chat/css/profile-dialog.module.scss";
interface SectionProps {
type: string;
icon: string;
label: string;
error?: string;
value?: string;
onChange?: (value: string) => void;
readOnly: boolean;
placeholder?: string;
textArea?: boolean;
}
function Section({ type, icon, label, error, value, onChange, readOnly, placeholder, textArea = false }: SectionProps) {
let valueComponent: ReactNode = null;
if (onChange) {
if (textArea) {
valueComponent = (
<RichTextArea
text={value || ""}
onTextChange={onChange}
placeholder={placeholder}
className={styles.value}
rows={1}
readOnly={readOnly} />
);
} else {
valueComponent = (
<input
className={styles.value}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
readOnly={readOnly} />
);
}
} else {
valueComponent = <span className={styles.value}>{value}</span>
}
return (
<div className={`${styles.section} ${type} ${error ? styles.error : ''}`}>
<MaterialIcon name={icon} />
<div className={styles.contentContainer}>
<label className={styles.label}>{label}</label>
{valueComponent}
{error && (
<div className={styles.errorMessage}>{error}</div>
)}
</div>
</div>
)
}
export function ProfileDialog() {
const { profileDialog, closeProfileDialog } = useProfileStore();
const { user, setUser } = useUserStore();
const [isOpen, setIsOpen] = useState(false);
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{[key: string]: string}>({});
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle dialog open/close based on state
useEffect(() => {
if (profileDialog && !isOpen) {
// Fetch fresh data when opening dialog
fetchFreshProfileData(profileDialog);
} else if (!profileDialog && isOpen) {
setIsOpen(false);
}
}, [profileDialog, isOpen]);
async function fetchFreshProfileData(profileData: ProfileDialogData) {
if (!user.authToken) return;
try {
let freshData = profileData;
// If it's not the public chat and has a user ID, fetch fresh data
if (profileData.userId && profileData.username !== "Общий чат") {
const userProfile = await api.user.profile.fetchById(user.authToken, profileData.userId);
if (userProfile) {
freshData = {
...userProfile,
userId: userProfile.id,
memberSince: userProfile.created_at,
isOwnProfile: profileData.isOwnProfile,
deleted: userProfile.deleted,
verification_status: userProfile.verification_status,
suspended: userProfile.suspended,
};
}
}
setOriginalData(freshData);
setCurrentData(freshData);
setIsOpen(true);
} catch (error) {
console.error("Failed to fetch fresh profile data:", error);
// Fallback to cached data if fetch fails
setOriginalData(profileData);
setCurrentData(profileData);
setIsOpen(true);
}
}
// Subscribe to user's online status when dialog opens
useEffect(() => {
if (isOpen && currentData?.userId && !currentData.isOwnProfile) {
// Subscribe to the user's status
onlineStatusManager.subscribe(currentData.userId);
// Cleanup function to unsubscribe when dialog closes
return () => {
if (currentData.userId) {
onlineStatusManager.unsubscribe(currentData.userId);
}
};
}
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
// Validate fields when data changes
useEffect(() => {
if (currentData && isOpen) {
validateFields();
}
}, [currentData, isOpen]);
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.display_name) !== normalizeValue(currentData.display_name) ||
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
originalData.profilePicture !== currentData.profilePicture
);
}, [originalData, currentData]);
async function handleClose() {
if (hasChanges) {
try {
await confirm({
headline: "Несохраненные изменения",
description: "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?",
confirmText: "Закрыть",
cancelText: "Отмена"
});
closeProfileDialog();
} catch {
// User cancelled, do nothing
}
} else {
closeProfileDialog();
}
};
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!currentData) return;
const newValue = e.target.value;
setCurrentData({ ...currentData, display_name: newValue });
// Validate display name in real-time
validateDisplayName(newValue);
};
function handleUsernameChange(value: string) {
if (!currentData) return;
setCurrentData({ ...currentData, username: value });
// Validate username in real-time
validateUsername(value);
};
function handleBioChange(newBio: string) {
if (!currentData) return;
setCurrentData({ ...currentData, bio: newBio });
};
function handleProfilePictureClick() {
if (currentData?.isOwnProfile) {
fileInputRef.current?.click();
}
};
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file && file.type.startsWith("image/")) {
// Open cropper dialog here - for now just update the image
const reader = new FileReader();
reader.onload = (event) => {
const imageUrl = event.target?.result as string;
if (currentData) {
setCurrentData({ ...currentData, profilePicture: imageUrl });
}
};
reader.readAsDataURL(file);
}
};
function validateDisplayName(value: string) {
let error = "";
if (!value || value.trim().length === 0) {
error = "Отображаемое имя не может быть пустым";
} else if (value.length > 64) {
error = "Отображаемое имя не может быть длиннее 64 символов";
}
setErrors(prev => ({ ...prev, display_name: error }));
};
function validateUsername(value: string) {
let error = "";
if (!value || value.trim().length === 0) {
error = "Имя пользователя не может быть пустым";
} else if (value.length < 3) {
error = "Имя пользователя должно быть не менее 3 символов";
} else if (value.length > 20) {
error = "Имя пользователя не может быть длиннее 20 символов";
} else if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
error = "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания";
}
setErrors(prev => ({ ...prev, username: error }));
};
function validateFields() {
if (currentData) {
validateDisplayName(currentData.display_name || "");
validateUsername(currentData.username || "");
}
return !errors.display_name && !errors.username;
};
async function handleSave() {
if (!currentData || !user.authToken || !originalData) return;
// Validate fields first
if (!validateFields()) {
return;
}
setIsSaving(true);
try {
// Update profile data
const updateData: any = {};
if (originalData.display_name !== currentData.display_name) {
updateData.display_name = currentData.display_name;
}
if (originalData.username !== currentData.username) {
updateData.username = currentData.username;
}
if (originalData.bio !== currentData.bio) {
updateData.description = currentData.bio;
}
if (Object.keys(updateData).length > 0) {
await api.user.profile.update(user.authToken, updateData);
}
// Update profile picture if changed
if (originalData.profilePicture !== currentData.profilePicture && currentData.profilePicture) {
// Convert data URL to blob if needed
if (currentData.profilePicture.startsWith("data:")) {
const response = await fetch(currentData.profilePicture);
const blob = await response.blob();
await api.user.profile.uploadPicture(user.authToken, blob);
}
}
// Update the original data to match current data
setOriginalData(currentData);
// If this is the current user's profile and username was changed, update the current user data
if (currentData.isOwnProfile && user.currentUser && user.authToken) {
const updatedUser = {
...user.currentUser,
username: currentData.username || user.currentUser.username,
display_name: currentData.display_name || user.currentUser.display_name,
bio: currentData.bio || user.currentUser.bio,
profile_picture: currentData.profilePicture || user.currentUser.profile_picture
};
setUser(user.authToken, updatedUser);
}
// Close dialog after successful save
closeProfileDialog();
} catch (error) {
console.error("Failed to save profile:", error);
// Handle API errors
if (error instanceof Error && error.message.includes("уже занято")) {
setErrors({ username: "Это имя пользователя уже занято" });
} else {
setErrors({ general: "Ошибка при сохранении профиля" });
}
} finally {
setIsSaving(false);
}
}
function formatDate(dateString: string) {
return parseApiTimestamp(dateString).toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
day: "numeric"
});
}
async function handleSuspend() {
if (!currentData?.userId || !user.authToken) return;
const isSuspending = !currentData.suspended;
try {
if (isSuspending) {
const reason = await prompt({
headline: "Suspend Account",
description: "Enter the reason for suspending this account:",
confirmText: "Suspend",
cancelText: "Cancel"
});
if (reason) {
const result = await api.moderation.users.suspend(currentData.userId, reason, user.authToken!);
if (result) {
closeProfileDialog();
} else {
console.error("Failed to suspend user");
}
}
} else {
// Unsuspend user
const result = await api.moderation.users.unsuspend(currentData.userId, user.authToken!);
if (result) {
closeProfileDialog();
} else {
console.error("Failed to unsuspend user");
}
}
} catch (error) {
console.error(`Failed to ${isSuspending ? 'suspend' : 'unsuspend'} user:`, error);
}
}
async function handleDelete() {
if (!currentData?.userId || !user.authToken) return;
try {
await confirm({
headline: "Delete Account",
description: "This will permanently delete user data but preserve messages and conversations. If the user is online, they will be immediately logged out. This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel"
});
const result = await api.moderation.users.deleteUser(currentData.userId, user.authToken!);
if (result) {
closeProfileDialog();
} else {
console.error("Failed to delete user");
}
} catch (error) {
// User cancelled or error occurred
console.error("Failed to delete user:", error);
}
}
const fabVisible = useMemo(() => {
let hasErrors = false;
Object.values(errors).forEach(error => {
if (error) {
hasErrors = true;
}
});
return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors;
}, [hasChanges, currentData?.isOwnProfile, isSaving, errors]);
if (!currentData) return null;
const isDeletedProfile = isDeletedPeer(currentData);
return (
<StyledDialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
onBackdropClick={handleClose}
contentClassName={styles.profileDialogContent}
afterChildren={
currentData.isOwnProfile && (
<MaterialFab
icon="check"
className={`${styles.profileDialogFab} ${fabVisible ? styles.visible : ""}`}
onClick={handleSave}
disabled={isSaving} />
)
}
>
<div className={styles.profilePictureSection}>
{isDeletedProfile ? (
<DeletedUserAvatar
userId={currentData.userId!}
className={styles.deletedAvatar}
iconClassName={styles.deletedAvatarIcon}
/>
) : (
<img
className={styles.profilePicture}
src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture"
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
)}
{currentData.isOwnProfile && !isDeletedProfile && (
<div
className={styles.profilePictureEditOverlay}
onClick={handleProfilePictureClick}
>
<MaterialIcon name="camera_alt--filled" />
</div>
)}
</div>
<div className={`${styles.usernameSection} ${errors.display_name ? styles.error : ''}`}>
<div className={styles.usernameWithBadge}>
<Input
autoresizing={true}
className={styles.usernameInput}
type="text"
value={isDeletedProfile ? displayNameForUser(currentData) : currentData.display_name}
onChange={handleDisplayNameChange}
readOnly={!currentData.isOwnProfile}
placeholder="Имя" />
{!isDeletedProfile && (
<StatusBadge
verificationStatus={currentData.verification_status}
verified={currentData.verified || false}
size="large" />
)}
</div>
{errors.display_name && (
<div className={styles.errorMessage}>{errors.display_name}</div>
)}
</div>
{(currentData?.userId || currentData?.isOwnProfile) && !isDeletedProfile && (
<div className={styles.onlineStatusSection}>
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
</div>
)}
{/* Admin Actions Section - Hide for deleted users */}
{!currentData.isOwnProfile && user.currentUser?.id === 1 && !isDeletedProfile && (
<div className={styles.adminActionsSection}>
<h3 className={styles.adminActionsHeader}>Admin Actions</h3>
<div className={styles.adminButtons}>
<MaterialButton
variant="filled"
color="error"
icon={currentData.suspended ? "check_circle--filled" : "block--filled"}
onClick={handleSuspend}
>
{currentData.suspended ? "Unsuspend Account" : "Suspend Account"}
</MaterialButton>
<MaterialButton
variant="filled"
color="error"
icon="delete_forever--filled"
onClick={handleDelete}
>
Delete Account
</MaterialButton>
<VerifyButton
userId={currentData.userId!}
verified={currentData.verified || false}
onVerificationChange={(verified) => {
setCurrentData({ ...currentData, verified });
}}
/>
</div>
</div>
)}
{/* Verify button for non-admin owner */}
{!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && !isDeletedProfile && (
<div className={styles.verifySection}>
<VerifyButton
userId={currentData.userId}
verified={currentData.verified || false}
onVerificationChange={(verified) => {
setCurrentData({ ...currentData, verified });
}}
/>
</div>
)}
{/* Hide profile sections for deleted users */}
{!isDeletedProfile && (
<div className={styles.profileSections}>
<Section
type="username"
error={errors.username}
icon="alternate_email--filled"
label="Имя пользователя:"
value={currentData.username}
onChange={handleUsernameChange}
readOnly={!currentData.isOwnProfile}
placeholder="username" />
{currentData.bio !== undefined && (
<Section
type="bio"
icon="info--filled"
label="О себе:"
value={currentData.bio}
onChange={handleBioChange}
readOnly={!currentData.isOwnProfile}
placeholder="Нет информации о себе"
textArea />
)}
{currentData.memberSince && (
<Section
type="member-since"
icon="calendar_month--filled"
label="Участник с:"
value={formatDate(currentData.memberSince)}
readOnly={true} />
)}
{currentData.verified && (
<Section
type="verified"
icon="verified--filled"
label="Верификация:"
value="Этот аккаунт - официальное лицо FromChat."
readOnly={true}
/>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileSelect}
/>
</StyledDialog>
);
}
@@ -0,0 +1,43 @@
import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialIcon } from "@/utils/material";
import styles from "@/pages/chat/css/suspension-dialog.module.scss";
interface SuspensionDialogProps {
reason: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SuspensionDialog({ reason, open, onOpenChange }: SuspensionDialogProps) {
return (
<StyledDialog
open={open}
onOpenChange={onOpenChange}>
<div className={styles.suspensionDialogContent}>
<div className={styles.suspensionIconSection}>
<MaterialIcon name="block--filled" className={styles.suspensionIcon} />
</div>
<div className={styles.suspensionText}>
<h2 className={styles.suspensionHeadline}>Аккаунт заблокирован</h2>
<p className={styles.suspensionBody}>
Ваш аккаунт был заблокирован за нарушение правил сообщества.
Вы не можете отправлять сообщения или взаимодействовать с другими пользователями.
</p>
{reason && reason !== "No reason provided" && (
<div className={styles.suspensionReason}>
<strong>Причина блокировки:</strong>
<div className={styles.suspensionReasonText}>
{reason}
</div>
</div>
)}
<p className={styles.suspensionSecondary}>
Если вы считаете, что блокировка была применена по ошибке,
<a href="https://t.me/denis0001_dev" target="_blank" rel="noopener noreferrer">обратитесь к администратору</a> для рассмотрения вашего случая.
</p>
</div>
</div>
</StyledDialog>
);
}
@@ -0,0 +1,48 @@
import { PRODUCT_NAME } from "@/core/config";
import useProfile from "@/pages/chat/hooks/useProfile";
import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react";
import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile";
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
import styles from "@/pages/chat/css/left-panel.module.scss";
import logoIcon from "@/images/logo.svg";
export function ChatHeader({ headerRef }: { headerRef?: React.RefObject<HTMLElement | null> }) {
const { profileData } = useProfile();
const { user } = useUserStore();
const { setProfileDialog } = useProfileStore();
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
function handleProfileClick() {
setProfileDialog({
userId: user.currentUser?.id,
username: profileData?.username || "Пользователь",
display_name: profileData?.display_name || "Пользователь",
profilePicture: profileData?.profile_picture,
bio: profileData?.description,
memberSince: user.currentUser?.created_at,
online: user.currentUser?.online,
isOwnProfile: true
});
};
return (
<>
<header className={styles.chatHeaderLeft} ref={headerRef}>
<img src={logoIcon} alt="Logo" className={styles.logo} />
<div className={styles.productName}>{PRODUCT_NAME}</div>
<div className={styles.profile}>
<a href="#" id="profile-open" onClick={handleProfileClick}>
<img
src={profilePictureUrl}
alt=""
id="preview1"
onError={() => setProfilePictureUrl(defaultAvatar)} />
</a>
</div>
</header>
<MinimizedCallBar />
</>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { useUserStore } from "@/state/user";
import { useRef, useState } from "react";
import { SettingsDialog } from "./settings/SettingsDialog";
import { UsernameSearch } from "./UsernameSearch";
import { UnifiedChatsList } from "./UnifiedChatsList";
import { ChatHeader } from "./ChatHeader";
import { MaterialBottomAppBar, MaterialFab, MaterialIconButton, type MDUIBottomAppBar } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null> }) {
const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useUserStore();
return (
<>
<MaterialBottomAppBar ref={bottomAppBarRef}>
<MaterialIconButton icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
<div style={{ flexGrow: 1 }} />
<MaterialIconButton
icon="logout--filled"
id="logout-btn"
onClick={logout}
title="Выйти" />
<MaterialFab icon="edit--filled" />
</MaterialBottomAppBar>
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
</>
);
}
export function LeftPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLElement>(null);
const bottomAppBarRef = useRef<MDUIBottomAppBar>(null);
return (
<div className={styles.chatList} ref={containerRef}>
<ChatHeader headerRef={headerRef} />
<div className={styles.searchContainer}>
<UsernameSearch containerRef={containerRef} headerRef={headerRef} bottomAppBarRef={bottomAppBarRef} />
</div>
<UnifiedChatsList />
<BottomAppBar bottomAppBarRef={bottomAppBarRef} />
</div>
);
}
@@ -0,0 +1,290 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat";
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import api from "@/core/api";
import { StatusBadge } from "@/core/components/StatusBadge";
import type { Message, VerificationStatus } from "@/core/types";
import { websocket } from "@/core/websocket";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png";
import { MaterialBadge, MaterialCircularProgress, MaterialIcon, MaterialList, MaterialListItem } from "@/utils/material";
import { displayNameForUser, isDeletedPeer } from "@/core/userDisplay";
import { DeletedUserAvatar } from "@/core/DeletedUserAvatar";
import styles from "@/pages/chat/css/left-panel.module.scss";
interface PublicChat {
id: string;
name: string;
type: "public";
lastMessage?: Message;
}
interface DMConversation {
id: number;
userId: number;
username: string;
display_name: string;
profile_picture?: string;
online?: boolean;
type: "dm";
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
verified?: boolean;
verification_status?: VerificationStatus;
}
type ChatItem = PublicChat | DMConversation;
const PUBLIC_CHAT: PublicChat = {
id: "general",
name: "Общий чат",
type: "public"
};
export function UnifiedChatsList() {
const { user } = useUserStore();
const { switchToPublicChat, switchToDM, activeTab } = useChatStore();
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({});
const loadLastMessages = useCallback(async () => {
if (!user.authToken) return;
try {
const { messages } = await api.chats.general.fetchMessages(user.authToken, 1);
if (messages?.length > 0) {
const lastMessage = messages[messages.length - 1];
setLastMessages({ general: lastMessage });
}
} catch (error) {
console.error("Error loading last messages:", error);
}
}, [user.authToken]);
useEffect(() => {
if (activeTab === "chats") {
loadUsers();
loadLastMessages();
}
}, [activeTab, loadUsers, loadLastMessages]);
const allChats = useMemo<ChatItem[]>(() => {
return [
...dmUsers.map((user: DMUser) => ({
...user,
userId: user.id,
display_name: displayNameForUser({ ...user, id: user.id }),
type: "dm" as const
})),
{
...PUBLIC_CHAT,
lastMessage: lastMessages[PUBLIC_CHAT.id]
}
];
}, [lastMessages, dmUsers]);
useEffect(() => {
if (!websocket) return;
function handleWebSocketMessage(e: MessageEvent) {
try {
const msg = JSON.parse(e.data);
if (msg.type === "newMessage") {
const newMessage = msg.data as Message;
setLastMessages(prev => ({
...prev,
[PUBLIC_CHAT.id]: newMessage
}));
} else if (msg.type === "messageEdited") {
const editedMessage = msg.data as Message;
setLastMessages(prev => {
if (prev[PUBLIC_CHAT.id]?.id === editedMessage.id) {
return {
...prev,
[PUBLIC_CHAT.id]: editedMessage
};
}
return prev;
});
} else if (msg.type === "messageDeleted") {
const deletedMessageId = msg.data?.message_id;
setLastMessages(prev => {
if (prev[PUBLIC_CHAT.id]?.id === deletedMessageId) {
loadLastMessages();
return {
...prev,
[PUBLIC_CHAT.id]: undefined
};
}
return prev;
});
}
} catch (error) {
console.error("Failed to handle WebSocket message in UnifiedChatsList:", error);
}
};
websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [loadLastMessages]);
useEffect(() => {
dmUsers.forEach(dmUser => {
onlineStatusManager.subscribe(dmUser.id);
});
return () => {
dmUsers.forEach(dmUser => {
onlineStatusManager.unsubscribe(dmUser.id);
});
};
}, [dmUsers]);
function formatPublicChatMessage(chatId: string): string {
const lastMessage = lastMessages[chatId];
if (!lastMessage) return "";
const isCurrentUser = lastMessage.user_id === user.currentUser?.id;
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
const maxLength = 50 - prefix.length;
const content = lastMessage.content.length > maxLength
? lastMessage.content.substring(0, maxLength) + "..."
: lastMessage.content;
return prefix + content;
};
async function handleDMClick(dmConversation: DMConversation) {
if (!dmConversation.publicKey) {
const authToken = useUserStore.getState().user.authToken;
if (!authToken) return;
const publicKey = await api.chats.dm.fetchUserPublicKey(dmConversation.id, authToken);
if (!publicKey) {
console.error("Failed to get public key for user:", dmConversation.id);
return;
}
dmConversation.publicKey = publicKey;
}
await switchToDM({
userId: dmConversation.id,
username: dmConversation.username,
publicKey: dmConversation.publicKey,
profilePicture: dmConversation.profile_picture,
online: dmConversation.online || false
});
};
if (isLoadingUsers) {
return <MaterialCircularProgress />;
}
if (user.isSuspended) {
return (
<MaterialList className={styles.unifiedChatsList}>
<MaterialListItem
headline="Аккаунт заблокирован"
style={{ cursor: "pointer" }}
>
<MaterialIcon name="block--filled" slot="icon" />
</MaterialListItem>
</MaterialList>
);
}
return (
<MaterialList className={styles.unifiedChatsList}>
{allChats.map((chat) => {
if (chat.type === "public") {
const formattedMessage = formatPublicChatMessage(chat.id);
return (
<MaterialListItem
key={`public-${chat.id}`}
headline={chat.name}
onClick={() => switchToPublicChat(chat.name)}
style={{ cursor: "pointer" }}
>
{formattedMessage && (
<span slot="description" className={styles.listDescription}>
{formattedMessage}
</span>
)}
<img
src={defaultAvatar}
alt={chat.name}
slot="icon"
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover"
}}
/>
</MaterialListItem>
);
}
const isDeletedDm = isDeletedPeer(chat);
const displayName = displayNameForUser({ ...chat, id: chat.id });
return (
<MaterialListItem
key={`dm-${chat.id}`}
headline={displayName}
onClick={() => handleDMClick(chat)}
style={{ cursor: "pointer" }}
>
<div slot="headline" className="dm-list-headline">
{displayName}
{!isDeletedDm && (
<StatusBadge
verificationStatus={chat.verification_status}
verified={chat.verified || false}
size="small"
/>
)}
</div>
<span slot="description" className={styles.listDescription}>
{chat.lastMessage || "Нет сообщений"}
</span>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
{isDeletedDm ? (
<DeletedUserAvatar
userId={chat.id}
className={styles.deletedUserAvatar}
iconClassName={styles.deletedUserAvatarIcon}
/>
) : (
<img
src={chat.profile_picture || defaultAvatar}
alt={displayName}
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover",
display: "block"
}}
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
)}
{!isDeletedDm && <OnlineIndicator userId={chat.id} />}
</div>
{chat.unreadCount > 0 && (
<MaterialBadge slot="end-icon">
{chat.unreadCount}
</MaterialBadge>
)}
</MaterialListItem>
);
})}
</MaterialList>
);
}
@@ -0,0 +1,242 @@
import { useState, useEffect, useRef } from "react";
import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat";
import api from "@/core/api";
import { StatusBadge } from "@/core/components/StatusBadge";
import type { User } from "@/core/types";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import { OnlineStatus } from "@/pages/chat/ui/right/OnlineStatus";
import defaultAvatar from "@/images/default-avatar.png";
import SearchBar from "@/core/components/SearchBar";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem, type MDUIBottomAppBar } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
interface SearchUser extends User {
publicKey?: string | null;
verified?: boolean;
}
export interface UsernameSearchProps {
containerRef: React.RefObject<HTMLElement | null>;
headerRef?: React.RefObject<HTMLElement | null>;
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
}
export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) {
const { user } = useUserStore();
const { switchToDM, activeDm } = useChatStore();
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchUser[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [debounceTimeout, setDebounceTimeout] = useState<NodeJS.Timeout | null>(null);
const switchingToUserIdRef = useRef<number | null>(null);
const previousSearchResultIdsRef = useRef<Set<number>>(new Set());
// Debounced search
useEffect(() => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
if (searchQuery.length > 1) {
setIsSearching(true);
const newTimeout = setTimeout(async () => {
if (user.authToken) {
try {
const users = await api.user.search.searchUsers(searchQuery, user.authToken);
setSearchResults(users);
} catch (error) {
console.error("Search failed:", error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}
}, 300);
setDebounceTimeout(newTimeout);
} else {
setSearchResults([]);
setIsSearching(false);
}
return () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
};
}, [searchQuery, user.authToken]);
// Subscribe to online status for all search results
useEffect(() => {
const activeDmUserId = activeDm?.userId;
const switchingToUserId = switchingToUserIdRef.current;
const currentSearchResultIds = new Set(searchResults.map(u => u.id));
const previousSearchResultIds = new Set(previousSearchResultIdsRef.current);
// Unsubscribe from users that were in previous results but not in current results
// (unless they're the active DM or we're switching to them)
previousSearchResultIds.forEach(userId => {
if (!currentSearchResultIds.has(userId) &&
userId !== activeDmUserId &&
userId !== switchingToUserId) {
onlineStatusManager.unsubscribe(userId);
}
});
// Subscribe to all current search results
searchResults.forEach(searchUser => {
onlineStatusManager.subscribe(searchUser.id);
});
// Update previous results for next effect run
previousSearchResultIdsRef.current = currentSearchResultIds;
// Cleanup function - don't unsubscribe here as normal transitions are handled in effect body
// This only runs when component unmounts or when transitioning to empty results
return () => {
// Note: Normal search result transitions are handled above in the effect body
// by comparing previous vs current. This cleanup only runs when the component
// unmounts or when the dependency changes, but we've already handled
// unsubscription in the effect body above, so this is mostly a no-op for normal transitions.
// Clear the ref if the user is now the active DM (state has updated)
const finalSwitchingToUserId = switchingToUserIdRef.current;
const finalActiveDmUserId = activeDm?.userId;
if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) {
switchingToUserIdRef.current = null;
}
};
}, [searchResults, activeDm?.userId]);
async function handleUserClick(searchUser: SearchUser) {
if (!user.authToken) return;
try {
let publicKey = searchUser.publicKey;
if (!publicKey) {
const fetchedPublicKey = await api.chats.dm.fetchUserPublicKey(searchUser.id, user.authToken);
publicKey = fetchedPublicKey;
}
if (publicKey) {
// Store the userId we're switching to so cleanup doesn't unsubscribe
switchingToUserIdRef.current = searchUser.id;
switchToDM({
userId: searchUser.id,
username: searchUser.username,
publicKey: publicKey,
profilePicture: searchUser.profile_picture,
online: searchUser.online || false
});
// Collapse search
setIsExpanded(false);
setSearchQuery("");
setSearchResults([]);
}
} catch (error) {
console.error("Failed to start DM conversation:", error);
}
}
function handleQueryChange(query: string) {
setSearchQuery(query);
}
function handleToggleExpanded() {
if (isExpanded) {
// Collapsing
setSearchQuery("");
setSearchResults([]);
}
setIsExpanded(!isExpanded);
}
return (
<SearchBar
placeholder="Поиск"
searchQuery={searchQuery}
onQueryChange={handleQueryChange}
isExpanded={isExpanded}
onToggleExpanded={handleToggleExpanded}
leftIcon={isExpanded ? (
<MaterialIconButton
className="back-button"
onClick={(e) => {
e.stopPropagation();
handleToggleExpanded();
}}
type="button"
icon="arrow_back--outlined"
/>
) : "search--outlined"}
containerRef={containerRef}
headerRef={headerRef}
bottomAppBarRef={bottomAppBarRef}
>
{isSearching && (
<div className={styles.searchLoading}>
<MaterialCircularProgress />
<span>Поиск...</span>
</div>
)}
{!isSearching && searchQuery.length >= 2 && searchResults.length === 0 && (
<div className={styles.searchEmpty}>
<span>Пользователи не найдены</span>
</div>
)}
{!isSearching && searchResults.length > 0 && (
<MaterialList>
{searchResults.map((searchUser) => (
<MaterialListItem
key={searchUser.id}
headline={searchUser.username}
onClick={() => handleUserClick(searchUser)}
style={{ cursor: "pointer" }}
>
<div slot="custom" className={styles.searchResultContainer}>
<div className={styles.searchResult}>
<div className={styles.searchResultIcon}>
<img
src={searchUser.profile_picture || defaultAvatar}
alt={searchUser.username}
className={styles.searchResultIconImg}
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
<OnlineIndicator userId={searchUser.id} />
</div>
<div className={styles.searchResultBody}>
<div className={styles.searchResultHeadline}>
{searchUser.username}
<StatusBadge
verificationStatus={searchUser.verification_status}
verified={searchUser.verified || false}
size="small"
/>
</div>
<div className={styles.searchResultDescription}>
<OnlineStatus userId={searchUser.id} />
</div>
</div>
</div>
</div>
</MaterialListItem>
))}
</MaterialList>
)}
{!isSearching && searchQuery.length < 2 && (
<div className={styles.searchHint}>
<span>Введите минимум 2 символа для поиска</span>
</div>
)}
</SearchBar>
);
}
@@ -0,0 +1,59 @@
import { MaterialList, MaterialListItem } from "@/utils/material";
import { useUserStore } from "@/state/user";
import api from "@/core/api";
import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
interface AccountPanelProps {
onClose: () => void;
}
export function AccountPanel({ onClose }: AccountPanelProps) {
const { user, logout } = useUserStore();
const authToken = user?.authToken;
async function handleDeleteAccount() {
if (!authToken) return;
try {
await confirm({
headline: "Удалить аккаунт?",
description: "Профиль будет удалён без возможности восстановления, логин освободится. Отправленные сообщения могут остаться в чатах.",
confirmText: "Удалить",
cancelText: "Отмена"
});
await api.user.auth.deleteAccount(authToken);
logout();
onClose();
} catch (error) {
if (error !== "cancelled") {
console.error("Failed to delete account:", error);
alert(error instanceof Error ? error.message : "Failed to delete account");
}
}
}
return (
<>
<h3 className={styles.panelTitle}>Account</h3>
<MaterialList>
<MaterialListItem
onClick={logout}
className={styles.clickableItem}
headline="Logout"
description="Sign out of your account"
icon="logout"
/>
<MaterialListItem
onClick={handleDeleteAccount}
className={`${styles.clickableItem} ${styles.dangerItem}`}
headline="Delete Account"
description="Permanently delete your account"
icon="delete_forever"
/>
</MaterialList>
</>
);
}
@@ -0,0 +1,83 @@
import { useState } from "react";
import { StyledDialog } from "@/core/components/StyledDialog";
import type { DialogProps } from "@/core/types";
import { useUserStore } from "@/state/user";
import api from "@/core/api";
import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material";
import styles from "@/pages/chat/css/changePasswordDialog.module.scss";
export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) {
const { user } = useUserStore();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [logoutAll, setLogoutAll] = useState(true);
const [busy, setBusy] = useState(false);
return (
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="change-password-dialog">
<div className={styles.cpdContainer}>
<div className={styles.cpdTitlebar}>
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className={styles.cpdTitle}>Изменить пароль</div>
</div>
<div className={styles.cpdContent}>
<form onSubmit={async (e) => {
e.preventDefault();
if (!user.authToken || !user.currentUser?.username) return;
if (!current || !next || next !== confirm) return;
setBusy(true);
try {
await api.user.auth.changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll);
setCurrent("");
setNext("");
setConfirm("");
onOpenChange(false);
} finally {
setBusy(false);
}
}}>
<MaterialTextField
name="cpd-current-password"
label="Текущий пароль"
type="password"
value={current}
onInput={(e) => setCurrent(e.target.value)}
variant="outlined"
toggle-password
required />
<MaterialTextField
name="cpd-new-password"
label="Новый пароль"
type="password"
value={next}
onInput={(e) => setNext(e.target.value)}
variant="outlined"
toggle-password
required />
<MaterialTextField
name="cpd-confirm-password"
label="Подтвердите пароль"
type="password"
value={confirm}
onInput={(e) => setConfirm(e.target.value)}
variant="outlined"
toggle-password
required />
<div className={styles.cpdLogoutAll}>
<MaterialSwitch
name="cpd-logout-all"
checked={logoutAll}
onInput={(e) => setLogoutAll(e.target.checked)} />
<label htmlFor="cpd-logout-all">Выйти на всех устройствах (кроме текущего)</label>
</div>
<div className={styles.cpdActions}>
<MaterialButton type="submit" disabled={busy}>Сохранить</MaterialButton>
</div>
</form>
</div>
</div>
</StyledDialog>
);
}
@@ -0,0 +1,153 @@
import { useState, useEffect } from "react";
import { useImmer } from "use-immer";
import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material";
import { useUserStore } from "@/state/user";
import api from "@/core/api";
import type { DeviceInfo } from "@/core/api/user/devices";
import { confirm } from "mdui/functions/confirm";
import { parseApiTimestamp } from "@/utils/utils";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function DevicesPanel() {
const { user } = useUserStore();
const authToken = user?.authToken ?? null;
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const [revokingDevices, setRevokingDevices] = useImmer<Set<string>>(new Set());
useEffect(() => {
if (authToken) {
loadDevices();
}
}, [authToken]);
async function loadDevices() {
if (!authToken) return;
setDevicesLoading(true);
try {
const deviceList = await api.user.devices.list(authToken);
updateDevices(deviceList);
} catch (error) {
console.error("Failed to load devices:", error);
} finally {
setDevicesLoading(false);
}
}
async function handleRevokeDevice(sessionId: string) {
if (!authToken) return;
try {
await confirm({
headline: "Revoke Device?",
description: "This will log out this device. You will need to log in again on this device.",
confirmText: "Revoke",
cancelText: "Cancel"
});
setRevokingDevices(draft => {
draft.add(sessionId);
});
await api.user.devices.revoke(authToken, sessionId);
await loadDevices();
} catch (error) {
if (error !== "cancelled") {
console.error("Failed to revoke device:", error);
}
} finally {
setRevokingDevices(draft => {
draft.delete(sessionId);
});
}
}
async function handleLogoutAll() {
if (!authToken) return;
try {
await confirm({
headline: "Logout All Other Devices?",
description: "This will log you out on all other devices. You will remain logged in on this device.",
confirmText: "Logout All",
cancelText: "Cancel"
});
await api.user.devices.revokeAll(authToken);
await loadDevices();
} catch (error) {
if (error !== "cancelled") {
console.error("Failed to logout all devices:", error);
}
}
}
function formatDeviceInfo(device: DeviceInfo): string {
const parts: string[] = [];
if (device.device_name) parts.push(device.device_name);
if (device.os_name) parts.push(device.os_name);
if (device.browser_name) parts.push(device.browser_name);
return parts.length > 0 ? parts.join(" • ") : device.device_type || "Unknown device";
}
function formatLastSeen(dateStr: string | undefined): string {
if (!dateStr) return "Never";
const date = parseApiTimestamp(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
return date.toLocaleDateString();
}
if (devicesLoading) {
return (
<>
<h3 className={styles.panelTitle}>Devices</h3>
<div className={styles.loadingContainer}>
<MaterialCircularProgress />
</div>
</>
);
}
return (
<>
<h3 className={styles.panelTitle}>Devices</h3>
<MaterialList>
{devices.map((device) => (
<MaterialListItem
key={device.session_id}
className={styles.clickableItem}
headline={formatDeviceInfo(device)}
description={device.current ? "Current" : "Last seen: " + formatLastSeen(device.last_seen)}
icon={device.current ? "smartphone" : "phone_android"}
onClick={() => handleRevokeDevice(device.session_id)}
disabled={revokingDevices.has(device.session_id)}
/>
))}
</MaterialList>
{devices.filter(d => !d.current).length > 0 && (
<div className={styles.sectionActions}>
<MaterialButton
onClick={handleLogoutAll}
variant="tonal"
>
Logout All Other Devices
</MaterialButton>
</div>
)}
</>
);
}
@@ -0,0 +1,120 @@
import { useState, useRef } from "react";
import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from "@/utils/material";
import { useUserStore } from "@/state/user";
import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import api from "@/core/api";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function NotificationsPanel() {
const { user } = useUserStore();
const authToken = user?.authToken ?? null;
const [pushEnabled, setPushEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const switchRef = useRef<MDUISwitch>(null);
async function checkPushStatus() {
if (!isSupported()) {
setPushEnabled(false);
setChecking(false);
return;
}
setChecking(true);
try {
let permission: string;
if (isElectron) {
permission = await window.electronInterface.notifications.requestPermission();
} else {
permission = Notification.permission;
}
console.log("checkPushStatus", permission);
setPushEnabled(permission === "granted");
} catch (error) {
console.error("Failed to check push status:", error);
setPushEnabled(false);
} finally {
setChecking(false);
}
}
async function handlePushToggle(enabled: boolean) {
console.log("handlePushToggle", enabled);
if (!authToken || !isSupported() || loading) return;
// Optimistic update
const previousState = pushEnabled;
setPushEnabled(enabled);
setLoading(true);
try {
if (enabled) {
// Initialize push notifications (creates service worker and requests permission)
const initResult = await initialize();
if (!initResult) {
throw new Error("Failed to initialize push notifications");
}
// Subscribe to push notifications (sends subscription to server)
// The subscribe() function will handle creating/getting the subscription if needed
const subscribeResult = await subscribe(authToken);
if (!subscribeResult) {
throw new Error("Failed to subscribe to push notifications");
}
// Verify the state after subscription - check permission to ensure it's actually granted
await checkPushStatus();
} else {
// Unsubscribe locally first
const unsubscribed = await unsubscribe();
if (!unsubscribed) {
throw new Error("Failed to unsubscribe locally");
}
// Then unsubscribe from server
await api.push.subscription.unsubscribe(authToken);
// After unsubscribing, permission is still granted but we're not subscribed
// So we keep the state as disabled (false)
setPushEnabled(false);
}
} catch (error) {
console.error("Failed to toggle push notifications:", error);
// Revert optimistic update
setPushEnabled(previousState);
// Re-check actual status to sync with reality
await checkPushStatus();
} finally {
setLoading(false);
}
}
function handleListItemClick(e: React.MouseEvent) {
if (checking || loading || !isSupported() || e.target === switchRef.current) return;
handlePushToggle(!pushEnabled);
}
return (
<>
<h3 className={styles.panelTitle}>Notifications</h3>
<MaterialList>
<MaterialListItem
className={styles.clickableItem}
headline="Push Notifications"
description="Receive notifications for new messages"
icon="notifications"
onClick={handleListItemClick}>
<MaterialSwitch
checked={pushEnabled}
disabled={!isSupported() || loading || checking}
onChange={(e) => handlePushToggle(e.target.checked)}
slot="end-icon"
ref={switchRef}
/>
</MaterialListItem>
</MaterialList>
</>
);
}
@@ -0,0 +1,25 @@
import { useState } from "react";
import { MaterialList, MaterialListItem } from "@/utils/material";
import ChangePasswordDialog from "./ChangePasswordDialog";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function SecurityPanel() {
const [cpOpen, setCpOpen] = useState(false);
return (
<>
<h3 className={styles.panelTitle}>Security</h3>
<MaterialList>
<MaterialListItem
onClick={() => setCpOpen(true)}
className={styles.clickableItem}
headline="Change Password"
description="Change your account password"
icon="password"
/>
</MaterialList>
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
</>
);
}
@@ -0,0 +1,92 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import type { DialogProps } from "@/core/types";
import { StyledDialog } from "@/core/components/StyledDialog";
import { NotificationsPanel } from "./NotificationsPanel";
import { DevicesPanel } from "./DevicesPanel";
import { SecurityPanel } from "./SecurityPanel";
import { AccountPanel } from "./AccountPanel";
import { MaterialList, MaterialListItem, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
interface SettingsSection {
title: string;
icon: string;
component: React.ReactNode;
}
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const sections: SettingsSection[] = [
{
title: "Notifications",
icon: "notifications",
component: <NotificationsPanel />
},
{
title: "Devices",
icon: "devices",
component: <DevicesPanel />
},
{
title: "Security",
icon: "lock",
component: <SecurityPanel />
},
{
title: "Account",
icon: "account_circle",
component: <AccountPanel onClose={() => onOpenChange(false)} />
}
];
const [activeSection, setActiveSection] = useState<number>(0);
return (
<>
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className={styles.settingsDialog}>
<div className={styles.settingsDialogInner}>
<div className={styles.settingsHeader}>
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)} />
<h2 className={styles.settingsTitle}>Settings</h2>
</div>
<div className={styles.settingsLayout}>
<div className={styles.sidebar}>
<MaterialList>
{sections.map((section, index) => (
<MaterialListItem
key={index}
onClick={() => setActiveSection(index)}
active={activeSection === index}
rounded
headline={section.title}
icon={section.icon}
/>
))}
</MaterialList>
</div>
<div className={styles.contentPanel}>
<AnimatePresence mode="wait">
{sections.map((section, index) => (
activeSection === index && (
<motion.div
key={index}
className={styles.panelContent}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{section.component}
</motion.div>
)
))}
</AnimatePresence>
</div>
</div>
</div>
</StyledDialog>
</>
);
}
@@ -0,0 +1,266 @@
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
import { RichTextArea } from "@/core/components/RichTextArea";
import type { Message } from "@/core/types";
import Quote from "@/core/components/Quote";
import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu";
import { MaterialIcon, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/ChatInput.module.scss";
import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss";
import { alert } from "mdui/functions/alert";
interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void;
onSaveEdit?: (content: string) => void;
replyTo?: Message | null;
replyToVisible: boolean;
onClearReply?: () => void;
onCloseReply?: () => void;
editingMessage?: Message | null;
editVisible?: boolean;
onClearEdit?: () => void;
onCloseEdit?: () => void;
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
onTyping?: () => void;
onStopTyping?: () => void;
}
export function ChatInputWrapper(
{
onSendMessage,
onSaveEdit,
replyTo,
replyToVisible,
onClearReply,
onCloseReply,
editingMessage,
editVisible = false,
onClearEdit,
onCloseEdit,
onProvideFileAdder,
messagePanelRef,
onTyping,
onStopTyping
}: ChatInputWrapperProps
) {
const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
const [emojiMenuOpen, setEmojiMenuOpen] = useState(false);
const [emojiMenuPosition, setEmojiMenuPosition] = useState({ x: 0, y: 0 });
const chatInputWrapperRef = useRef<HTMLDivElement>(null);
// Expose a way for parent to programmatically add files
useEffect(() => {
if (onProvideFileAdder) {
const addFiles = (files: File[]) => {
if (!files || files.length === 0) return;
setSelectedFiles(draft => { draft.push(...files) });
};
onProvideFileAdder(addFiles);
}
}, [onProvideFileAdder]);
// When entering edit mode, preload the message content
useEffect(() => {
setMessage(editingMessage ? editingMessage.content || "" : "");
}, [editingMessage]);
useEffect(() => {
setAttachmentsVisible(selectedFiles.length > 0);
}, [selectedFiles]);
function handleEmojiButtonClick(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation();
if (!emojiMenuOpen) {
if (chatInputWrapperRef.current && messagePanelRef?.current) {
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
const panelRect = messagePanelRef.current.getBoundingClientRect();
// Position menu 10px from message panel edge and 10px above the chat input
// The animation will start 30px below this position
setEmojiMenuPosition({
x: panelRect.left + 10, // 10px from message panel edge
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
});
setEmojiMenuOpen(true);
}
} else {
setEmojiMenuOpen(false);
}
};
function handleEmojiSelect(emoji: string) {
setMessage(prev => prev + emoji);
};
function handleTyping() {
if (onTyping) {
onTyping();
}
};
function handleMessageChange(value: string) {
setMessage(value);
handleTyping();
};
async function handleSubmit(e: React.FormEvent | Event) {
e.preventDefault();
const hasText = Boolean(message.trim());
const hasFiles = selectedFiles.length > 0;
if (hasText || hasFiles) {
const totalSize = selectedFiles.reduce((acc, f) => acc + f.size, 0);
const limit = 4 * 1024 * 1024 * 1024; // 4GB
if (totalSize > limit) {
alert({
headline: "Ошибка",
description: "Общий размер вложений превышает 4 ГБ."
});
return;
}
if (editingMessage && onSaveEdit) {
onSaveEdit(message);
setMessage("");
if (onClearEdit) onClearEdit();
} else {
onSendMessage(message, selectedFiles);
setMessage("");
setAttachmentsVisible(false);
if (onClearReply) onClearReply();
// Stop typing indicator when message is sent
if (onStopTyping) onStopTyping();
}
}
};
function handleAttachClick() {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.addEventListener("change", () => {
setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) });
});
input.click();
}
return (
<div className={styles.chatInputWrapper} ref={chatInputWrapperRef}>
<form className={styles.inputGroup} id="message-form" onSubmit={handleSubmit}>
<AnimatePresence onExitComplete={onCloseEdit}>
{editVisible && editingMessage && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }}
>
<div className={styles.contextualPreview}>
<MaterialIcon name="edit" />
<Quote className={`${replyPreviewStyles.contextualContent}`} background="surfaceContainer">
<span className={replyPreviewStyles.replyUsername}>{editingMessage!.username}</span>
<span className={replyPreviewStyles.replyText}>{editingMessage!.content}</span>
</Quote>
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearEdit}></MaterialIconButton>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence onExitComplete={onCloseReply}>
{replyToVisible && replyTo && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }}
>
<div className={styles.contextualPreview}>
<MaterialIcon name="reply" />
<Quote className={`${replyPreviewStyles.contextualContent}`} background="surfaceContainer">
<span className={replyPreviewStyles.replyUsername}>{replyTo!.username}</span>
<span className={replyPreviewStyles.replyText}>{replyTo!.content}</span>
</Quote>
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearReply}></MaterialIconButton>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence onExitComplete={() => setSelectedFiles([])}>
{attachmentsVisible && selectedFiles.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }}
>
<div className={`${styles.attachmentsPreview} ${styles.contextualPreview}`}>
<MaterialIcon name="attach_file" />
<div className={styles.attachmentsChips}>
{selectedFiles.map((file, i) => (
<mdui-chip
key={i}
variant="input"
end-icon="close"
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
onClick={() => {
if (selectedFiles.length == 1) {
setAttachmentsVisible(false);
} else {
setSelectedFiles(draft => { draft.splice(i) })
}
}}
>
<MaterialIcon slot="icon" name="attach_file"></MaterialIcon>
<span className="name">{file.name}</span>
</mdui-chip>
))}
</div>
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
</div>
</motion.div>
)}
</AnimatePresence>
<div className={styles.chatInput}>
<div className={styles.leftButtons}>
<MaterialIconButton
icon="mood"
onClick={handleEmojiButtonClick}
onMouseDown={e => e.stopPropagation()}
onMouseUp={e => e.stopPropagation()}
className={styles.emojiBtn} />
</div>
<RichTextArea
className={styles.messageInput}
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
text={message}
rows={1}
onTextChange={handleMessageChange}
onEnter={handleSubmit} />
<div className={styles.buttons}>
<MaterialIconButton icon="attach_file" onClick={handleAttachClick}></MaterialIconButton>
<button type="submit" className={styles.sendBtn}>
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button>
</div>
</div>
</form>
<EmojiMenu
isOpen={emojiMenuOpen}
onClose={() => setEmojiMenuOpen(false)}
onEmojiSelect={handleEmojiSelect}
position={emojiMenuPosition}
mode="standalone"
/>
</div>
);
}
@@ -0,0 +1,21 @@
import { useChatStore } from "@/state/chat";
import defaultAvatar from "@/images/default-avatar.png";
export function ChatMainHeader() {
const { currentChat } = useChatStore();
return (
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{currentChat}</h4>
<p>
<span className="online-status"></span>
Онлайн
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
import { Message } from "./Message";
import { useUserStore } from "@/state/user";
import type { Message as MessageType } from "@/core/types";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { useState, type ReactNode } from "react";
import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/right-panel.module.scss";
interface ChatMessagesProps {
messages?: MessageType[];
isDm?: boolean;
children?: ReactNode;
onReplySelect?: (message: MessageType) => void;
onEditSelect?: (message: MessageType) => void;
onDelete?: (id: number) => void;
onRetryMessage?: (messageId: number) => void;
}
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage }: ChatMessagesProps) {
const { user } = useUserStore();
// Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
message: null,
position: { x: 0, y: 0 }
});
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
e.preventDefault();
setContextMenu({
isOpen: true,
message,
position: { x: e.clientX, y: e.clientY }
});
};
function handleContextMenuOpenChange(isOpen: boolean) {
setContextMenu(prev => ({
...prev,
isOpen
}));
};
function handleEdit(message: MessageType) {
if (onEditSelect) onEditSelect(message);
};
function handleReply(message: MessageType) {
if (onReplySelect) onReplySelect(message);
};
async function handleDelete(message: MessageType) {
try {
await confirm({
headline: "Удалить сообщение?",
confirmText: "Удалить",
cancelText: "Отменить",
onConfirm: () => onDelete?.(message.id)
});
} catch (error) {
// User cancelled
}
}
function handleRetry(message: MessageType) {
if (onRetryMessage) {
onRetryMessage(message.id);
}
}
async function handleReactionClick(messageId: number, emoji: string) {
if (!user.authToken) return;
try {
if (isDm) {
// For DM messages, we need to find the dm_envelope_id from the message
const message = messages.find(m => m.id === messageId);
const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id;
if (dmEnvelopeId) {
await request<AddDmReactionRequest["data"]>({
type: "addDmReaction",
credentials: { scheme: "Bearer", credentials: user.authToken },
data: {
dm_envelope_id: dmEnvelopeId,
emoji: emoji
}
});
}
} else {
// For regular chat messages
await request<AddReactionRequest["data"]>({
type: "addReaction",
credentials: { scheme: "Bearer", credentials: user.authToken },
data: {
message_id: messageId,
emoji: emoji
}
});
}
} catch (error) {
console.error("Failed to add reaction:", error);
}
}
return (
<>
<div className={styles.chatMessages} id="chat-messages">
{messages.map((message: MessageType) => (
<Message
key={message.id}
message={message}
isAuthor={isDm ?
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(message.user_id === user.currentUser?.id)
}
onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick}
isDm={isDm} />
))}
{children}
</div>
{/* Context Menu */}
{contextMenu.message && (
<MessageContextMenu
message={contextMenu.message}
isAuthor={isDm ?
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(contextMenu.message.user_id === user.currentUser?.id)
}
onEdit={handleEdit}
onReply={handleReply}
onDelete={handleDelete}
onRetry={handleRetry}
onReactionClick={handleReactionClick}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange}
/>
)}
</>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
import type { Size2D } from "@/core/types";
import styles from "@/pages/chat/css/EmojiMenu.module.scss";
interface BaseEmojiMenuProps {
isOpen: boolean;
onClose: () => void;
onEmojiSelect: (emoji: string) => void;
}
interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps {
position: Size2D;
mode: "standalone";
}
interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps {
mode: "integrated";
}
type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps;
export function EmojiMenu(props: EmojiMenuProps) {
const { isOpen, onClose, onEmojiSelect, mode } = props;
const position = mode === "standalone" ? props.position : undefined;
const [activeCategory, setActiveCategory] = useState("recent");
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
useEffect(() => {
if (isOpen) {
setRecentEmojis(getRecentEmojis());
}
}, [isOpen]);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
// Find which category is currently visible
for (const [categoryName, element] of categoryRefs.current) {
if (element) {
const rect = element.getBoundingClientRect();
const containerRect = scrollRef.current.getBoundingClientRect();
// Check if category header is in view
if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) {
if (activeCategory !== categoryName) {
setActiveCategory(categoryName);
scrollTabIntoView(categoryName);
}
break;
}
}
}
}, [activeCategory]);
function scrollToCategory(categoryName: string) {
const element = categoryRefs.current.get(categoryName);
if (element && scrollRef.current) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
function scrollTabIntoView(categoryName: string) {
const tabElement = tabRefs.current.get(categoryName);
if (tabElement && tabsRef.current) {
const tabsRect = tabsRef.current.getBoundingClientRect();
const tabRect = tabElement.getBoundingClientRect();
// Check if tab is outside the visible area
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
tabElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
}
}
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
function handleEmojiClick(emoji: string) {
addRecentEmoji(emoji);
onEmojiSelect(emoji);
onClose();
};
return (
<div
ref={menuRef}
className={`${styles.emojiMenu} ${isOpen ? styles.open : ""} ${mode === "integrated" ? styles.integrated : ""}`}
style={{
pointerEvents: isOpen ? "auto" : "none",
...(mode === "standalone" && position ? {
position: "fixed",
left: position.x,
bottom: position.y,
zIndex: 1000,
} : {}),
...(mode === "integrated" ? {
position: "relative",
} : {})
}}
>
<div className={styles.emojiMenuHeader}>
<div ref={tabsRef} className={styles.emojiCategoryTabs}>
{EMOJI_CATEGORIES.map((category) => (
<button
key={category.name}
ref={(el) => {
if (el) tabRefs.current.set(category.name, el);
}}
className={`${styles.emojiCategoryTab} ${activeCategory === category.name ? styles.active : ""}`}
onClick={() => scrollToCategory(category.name)}
title={category.name}
>
<span>{category.icon}</span>
</button>
))}
</div>
</div>
<div
ref={scrollRef}
className={styles.emojiGrid}
onScroll={handleScroll}
>
{EMOJI_CATEGORIES.map((category) => {
const emojis = category.name === "recent" ? recentEmojis : category.emojis;
return (
<div
key={category.name}
ref={(el) => {
if (el) categoryRefs.current.set(category.name, el);
}}
className={styles.emojiCategorySection}
>
<h3 className={styles.emojiCategoryTitle}>
{category.name.charAt(0).toUpperCase() + category.name.slice(1)}
</h3>
{emojis.length > 0 ? (
<div className={styles.emojiCategoryGrid}>
{emojis.map((emoji, index) => (
<button
key={`${category.name}-${index}`}
className={styles.emojiItem}
onClick={() => handleEmojiClick(emoji)}
title={emoji}
>
{emoji}
</button>
))}
</div>
) : (
<div className={styles.emojiEmptyState}>
<span>No {category.name} emojis</span>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
+699
View File
@@ -0,0 +1,699 @@
import { formatTime, id, ub64 } from "@/utils/utils";
import type { Attachment, Message as MessageType, Reaction } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png";
import Quote from "@/core/components/Quote";
import { parse } from "marked";
import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react";
import api from "@/core/api";
import { importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile";
import { StatusBadge } from "@/core/components/StatusBadge";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
import { parseProfileLink } from "@/core/profileLinks";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
import { displayNameForUser, isDeletedPeer } from "@/core/userDisplay";
import { DeletedUserAvatar } from "@/core/DeletedUserAvatar";
import styles from "@/pages/chat/css/Message.module.scss";
import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss";
interface MessageReactionsProps {
reactions?: Reaction[];
onReactionClick: (emoji: string) => void;
messageId?: number; // Add messageId to ensure unique keys
}
function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
const { user } = useUserStore();
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
const [isVisible, setIsVisible] = useState(false);
// Handle reactions with animation
useEffect(() => {
if (!reactions || reactions.length === 0) {
// If we have visible reactions, animate them out
if (visibleReactions.length > 0) {
visibleReactions.forEach(reaction => {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
});
// After animation completes, hide the component
setTimeout(() => {
setVisibleReactions([]);
setAnimatingReactions(new Set());
setIsVisible(false);
}, 200);
} else {
// No visible reactions, hide immediately
setIsVisible(false);
}
return;
}
// Show the component when we have reactions
setIsVisible(true);
// Deduplicate reactions by emoji (safety measure)
const uniqueReactions = reactions.reduce((acc, reaction) => {
const existing = acc.find(r => r.emoji === reaction.emoji);
if (existing) {
// Keep the one with the higher count
if (reaction.count > existing.count) {
acc[acc.indexOf(existing)] = reaction;
}
} else {
acc.push(reaction);
}
return acc;
}, [] as Reaction[]);
// Animate out removed reactions
visibleReactions.forEach(reaction => {
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
setTimeout(() => {
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
setAnimatingReactions(prev => {
const newSet = new Set(prev);
newSet.delete(reaction.emoji);
return newSet;
});
}, 200);
}
});
// Update existing reactions and add new ones
setVisibleReactions(prev => {
const updated = [...prev];
// Update existing reactions
uniqueReactions.forEach(reaction => {
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
if (existingIndex !== -1) {
updated[existingIndex] = reaction;
} else {
// Add new reaction only if it doesn't already exist
if (!updated.some(r => r.emoji === reaction.emoji)) {
updated.push(reaction);
}
}
});
return updated;
});
}, [reactions]);
// Don't render if not visible
if (!isVisible) {
return null;
}
return (
<div className={styles.messageReactions}>
{visibleReactions.map((reaction, index) => {
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
const isAnimating = animatingReactions.has(reaction.emoji);
return (
<button
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
className={`${styles.reactionButton} ${hasUserReacted ? styles.reacted : ""} ${isAnimating ? styles.removing : ""}`}
onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")}
>
<span className={styles.reactionEmoji}>{reaction.emoji}</span>
<span className={styles.reactionCount}>{reaction.count}</span>
</button>
);
})}
</div>
);
}
interface MessageProps {
message: MessageType;
isAuthor: boolean;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
onReactionClick?: (messageId: number, emoji: string) => void;
isDm?: boolean;
}
interface Rect {
left: number;
top: number;
width: number;
height: number
}
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false }: MessageProps) {
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(new Set());
const [isDownloadingFullscreen, setIsDownloadingFullscreen] = useState(false);
const [fullscreenImage, setFullscreenImage] = useState<{
src: string;
name: string;
element: HTMLImageElement;
startRect: Rect;
endRect: Rect;
} | null>(null);
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
const { user } = useUserStore();
const { setProfileDialog } = useProfileStore();
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope;
const formattedMessage = useMemo(() => {
// First, temporarily replace existing fromchat.ru links to avoid conflicts
const linkPlaceholders: string[] = [];
let content = escapeHtml(message.content).replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => {
const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`;
linkPlaceholders.push(match);
return placeholder;
});
// Now process @mentions that aren't in existing links
content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => {
return `<a href="https://fromchat.ru/@${username}" class="${styles.mentionLink}">${match}</a>`;
});
// Restore the original links
linkPlaceholders.forEach((link, index) => {
content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link);
});
const rendered = parse(content, { async: false }).trim();
return {
__html: rendered
};
}, [message.content, styles.mentionLink]);
// Auto-decrypt images in DMs
useEffect(() => {
if (isDm && message.files) {
message.files.forEach(async (file) => {
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
const looksEncryptedPath = /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path);
const shouldDecrypt = Boolean(file.encrypted || looksEncryptedPath);
if (isImage && shouldDecrypt && !decryptedFiles.has(file.path)) {
const decryptedUrl = await decryptFile(file);
if (decryptedUrl) {
updateDecryptedFiles(draft => {
draft.set(file.path, decryptedUrl);
});
}
}
});
}
}, [message.files, isDm, decryptedFiles]);
async function decryptFile(file: Attachment): Promise<string | null> {
if (!isDm || !user.authToken || !dmEnvelope) return null;
const userKeys = api.user.auth.getCurrentKeys();
if (!userKeys) return null;
const looksEncryptedPath = /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path);
const shouldDecrypt = Boolean(file.encrypted || looksEncryptedPath);
if (!shouldDecrypt) 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
const response = await fetch(file.path, {
headers: api.user.auth.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 = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Decrypt file using the envelope encryption MEK unwrapping logic
// Use the same logic as message decryption
// Prefer file-specific wrapped MEK (attachments have their own wrapped MEK)
// Get MEK from envelope file data - server provides user-specific MEK
const envelopeFile = dmEnvelope.files?.find(f => f.path === file.path);
const fileWrapped = file.wrapped_mek_b64;
const envelopeWrapped = envelopeFile?.wrapped_mek_b64;
const dmWrapped = dmEnvelope.wrapped_mek_b64;
const wrappedMekB64 = fileWrapped || envelopeWrapped || dmWrapped;
if (!wrappedMekB64) {
console.error("No MEK available for file decryption:", file.path);
return null;
}
// Unwrap the MEK using the same logic as message decryption
const mk = await api.chats.dm.unwrapMek(wrappedMekB64, dmEnvelope, user.currentUser?.id);
// Decrypt the file using the unwrapped MEK
const nonceB64 = file.nonce_b64 || envelopeFile?.nonce_b64;
if (!nonceB64) throw new Error("No nonce available for file decryption");
const iv = ub64(nonceB64);
const ciphertext = new Uint8Array(encryptedData);
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
// Create blob URL for download
const ext = (file.name || "").toLowerCase().split(".").pop();
const mime =
ext === "png" ? "image/png" :
ext === "jpg" || ext === "jpeg" ? "image/jpeg" :
ext === "gif" ? "image/gif" :
ext === "webp" ? "image/webp" :
"application/octet-stream";
const decryptedBuf = (decrypted.buffer as ArrayBuffer).slice(decrypted.byteOffset, decrypted.byteOffset + decrypted.byteLength);
const blob = new Blob([decryptedBuf], { type: mime });
const url = URL.createObjectURL(blob);
updateDecryptedFiles(draft => {
draft.set(file.path, url);
});
return url;
} catch (error) {
console.error("Failed to decrypt file:", error);
return null;
} finally {
// no-op decrypt indicator removed from UI
}
};
async function handleImageClick(file: Attachment, imageElement: HTMLImageElement) {
// Use decrypted URL if available, otherwise decrypt first
const decryptedUrl = decryptedFiles.get(file.path);
if (decryptedUrl) {
openFullscreenFromThumb(imageElement, decryptedUrl, file.name || "image");
} else if (isDm && (file.encrypted || /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path))) {
const newDecryptedUrl = await decryptFile(file);
if (newDecryptedUrl) {
openFullscreenFromThumb(imageElement, newDecryptedUrl, file.name || "image");
}
} else {
openFullscreenFromThumb(imageElement, file.path, file.name || "image");
}
};
function computeEndRect(naturalWidth: number, naturalHeight: number): Rect {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const maxWidth = Math.floor(viewportWidth * 0.9);
const maxHeight = Math.floor(viewportHeight * 0.9);
const widthRatio = maxWidth / naturalWidth;
const heightRatio = maxHeight / naturalHeight;
const scale = Math.min(widthRatio, heightRatio, 1);
const width = Math.round(naturalWidth * scale);
const height = Math.round(naturalHeight * scale);
const left = Math.round((viewportWidth - width) / 2);
const top = Math.round((viewportHeight - height) / 2);
return { left, top, width, height };
};
function openFullscreenFromThumb(imgEl: HTMLImageElement, src: string, name: string) {
const rect = imgEl.getBoundingClientRect();
const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
const tempImg = new Image();
tempImg.src = src;
// Hide original while animating
imgEl.style.visibility = "hidden";
tempImg.onload = () => {
const endRect = computeEndRect(tempImg.naturalWidth, tempImg.naturalHeight);
setFullscreenImage({
src,
name,
element: imgEl,
startRect,
endRect
});
// Start animation on next frame to ensure DOM has overlay mounted
requestAnimationFrame(() => setIsAnimatingOpen(true));
};
};
function closeFullscreen() {
// Reverse animation
setIsAnimatingOpen(false);
// Wait for transition to finish
setTimeout(() => {
if (fullscreenImage?.element) {
fullscreenImage.element.style.visibility = "visible";
}
setFullscreenImage(null);
}, 300);
};
async function downloadImage() {
if (!fullscreenImage) return;
const { src, name } = fullscreenImage;
try {
setIsDownloadingFullscreen(true);
if (src.startsWith("blob:")) {
const link = document.createElement("a");
link.href = src;
link.download = name;
link.click();
setIsDownloadingFullscreen(false);
return;
}
// Fetch with credentials/headers when not a blob URL
const response = await fetch(src, {
headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined,
credentials: "include"
});
if (!response.ok) throw new Error("Failed to download image");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = name;
link.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
} finally {
setIsDownloadingFullscreen(false);
}
};
async function downloadFile(file: Attachment) {
try {
updateDownloadingPaths(draft => {
draft.add(file.path);
});
// Prefer decrypted URL if present (DM encrypted case)
const decrypted = decryptedFiles.get(file.path);
if (decrypted) {
const link = document.createElement("a");
link.href = decrypted;
link.download = file.name || "file";
link.click();
updateDownloadingPaths(draft => {
draft.delete(file.path);
});
return;
}
// If this is an encrypted DM attachment, decrypt before downloading
const looksEncryptedPath = /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path);
if (isDm && (file.encrypted || looksEncryptedPath)) {
const decryptedUrl = await decryptFile(file);
if (decryptedUrl) {
const link = document.createElement("a");
link.href = decryptedUrl;
link.download = file.name || "file";
link.click();
updateDownloadingPaths(draft => {
draft.delete(file.path);
});
return;
}
}
// If not decrypted or public file, fetch with credentials/headers
const response = await fetch(file.path, {
headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined,
credentials: "include"
});
if (!response.ok) throw new Error("Failed to download file");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name || "file";
link.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
} finally {
updateDownloadingPaths(draft => {
draft.delete(file.path);
});
}
};
async function handleProfileClick() {
if (!user.authToken || !message.user_id) return;
try {
const userProfile = await api.user.profile.fetchById(user.authToken, message.user_id);
if (userProfile) {
setProfileDialog({
...userProfile,
userId: userProfile.id,
memberSince: userProfile.created_at,
isOwnProfile: false
});
}
} catch (error) {
console.error("Failed to fetch user profile:", error);
}
}
async function handleLinkClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target.tagName === 'A') {
const link = (e.target as unknown as HTMLAnchorElement).href;
const profileLink = parseProfileLink(link);
if (profileLink) {
e.preventDefault();
e.stopPropagation();
if (!user.authToken) return;
try {
let userProfile;
if (profileLink.userId) {
userProfile = await api.user.profile.fetchById(user.authToken, profileLink.userId);
} else if (profileLink.username) {
userProfile = await api.user.profile.fetchByUsername(user.authToken, profileLink.username);
}
if (userProfile) {
setProfileDialog({
...userProfile,
userId: userProfile.id,
memberSince: userProfile.created_at,
isOwnProfile: userProfile.id === user.currentUser?.id
});
} else {
throw new Error(`Invalid link: ${link}`);
}
} catch (error) {
console.error("Failed to fetch user profile from link:", error);
}
}
}
}
function handleContextMenu(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, message);
}
const messageText = message.content.trim();
const isEmojiMessage = useMemo(() => {
const emojiRegex = /^[\s\p{Emoji}]*$/u;
return messageText.length > 0 && emojiRegex.test(messageText);
}, [messageText]);
// Check if message has only one emoji
const isSingleEmojiMessage = useMemo(() => {
const emojiRegex = /^[\p{Emoji}]+$/u;
return messageText.length > 0 && emojiRegex.test(messageText) && messageText.length <= 4; // Most emojis are 1-4 characters
}, [messageText]);
const isDeletedSender = isDeletedPeer({ id: message.user_id, username: message.username });
return (
<>
<div
className={`${styles.message} ${isAuthor ? styles.sent : styles.received} ${isEmojiMessage ? styles.emojiMessage : ""} ${isSingleEmojiMessage ? "" : ""}`}
data-id={message.id}
onContextMenu={handleContextMenu}
>
{!isAuthor && !isDm && (
<div className={styles.messageProfilePic} onClick={handleProfileClick}>
{isDeletedSender ? (
<DeletedUserAvatar
userId={message.user_id}
className={styles.deletedUserAvatar}
iconClassName={styles.deletedUserAvatarIcon}
/>
) : (
<img
src={message.profile_picture || defaultAvatar}
alt={message.username}
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
)}
</div>
)}
<div className={styles.messageInner}>
{!isAuthor && !isDm && !isSingleEmojiMessage && (
<div
className={styles.messageUsername}
onClick={handleProfileClick}>
{displayNameForUser({ id: message.user_id, username: message.username })}
{!isDeletedSender && (
<StatusBadge
verificationStatus={message.verification_status}
verified={message.verified || false}
size="small"
/>
)}
</div>
)}
{message.reply_to && (
<Quote className={`${styles.replyPreview} ${replyPreviewStyles.contextualContent}`} background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className={replyPreviewStyles.replyUsername}>{message.reply_to.username}</span>
<span className={replyPreviewStyles.replyText}>{message.reply_to.content}</span>
</Quote>
)}
{messageText.length > 0 && (
<div
className={`${styles.messageContent} ${isEmojiMessage ? styles.emojiContent : ""} ${isSingleEmojiMessage ? styles.singleEmojiContent : ""}`}
dangerouslySetInnerHTML={formattedMessage}
onClick={handleLinkClick} />
)}
{message.files && message.files.length > 0 && (
<MaterialList className={styles.messageAttachments}>
{message.files.map((file, idx) => {
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
const looksEncryptedPath = /\/uploads\/files\/encrypted\//.test(file.path) || /\/api\/uploads\/files\/encrypted\//.test(file.path);
const isEncryptedDm = Boolean(isDm && (file.encrypted || looksEncryptedPath));
const decryptedUrl = decryptedFiles.get(file.path);
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
const isDownloading = downloadingPaths.has(file.path);
const isSending = message.runtimeData?.sendingState?.status === 'sending';
return (
<div className={styles.attachment} key={idx}>
{isImage ? (
<div className={styles.imageWrapper}>
{isEncryptedDm && !decryptedUrl ? null : (
<img
ref={(el) => {
if (el) imageRefs.current.set(file.path, el);
}}
src={imageSrc}
alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)}
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
className={`${styles.attachementImage} ${loadedImages.has(file.path) ? "" : styles.loading}`}
/>
)}
{((isEncryptedDm && !decryptedUrl) || !loadedImages.has(file.path) || isSending) && (
<div className={styles.loadingOverlay}>
<MaterialCircularProgress />
</div>
)}
</div>
) : (
<a
href="#"
onClick={async (e) => {
e.preventDefault();
await downloadFile(file);
}}
>
<MaterialListItem>
<span className={styles.withIconGap}>
{isDownloading ? <MaterialCircularProgress /> : null}
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</span>
</MaterialListItem>
</a>
)}
</div>
);
})}
</MaterialList>
)}
<Reactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
/>
<div className={styles.messageTime}>
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
)}
{isAuthor && message.runtimeData?.sendingState && (
<span className={styles.messageStatusIndicator}>
{message.runtimeData.sendingState.status === 'sending' && (
<MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
)}
{message.runtimeData.sendingState.status === 'failed' && (
<span className={`material-symbols ${styles.errorIcon}`}>error</span>
)}
{message.runtimeData.sendingState.status === 'sent' && (
<span className={`material-symbols ${styles.successIcon}`}>check</span>
)}
</span>
)}
</div>
</div>
</div>
{/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && createPortal(
<div
className={`${styles.fullscreenImageOverlay} ${isAnimatingOpen ? "" : styles.closing}`}
onClick={closeFullscreen}>
<img
src={fullscreenImage.src}
alt={fullscreenImage.name}
className={styles.fullscreenAnimatedImage}
style={{
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
width: `${isAnimatingOpen ? fullscreenImage.endRect.width : fullscreenImage.startRect.width}px`,
height: `${isAnimatingOpen ? fullscreenImage.endRect.height : fullscreenImage.startRect.height}px`
}}
onClick={e => e.stopPropagation()}
/>
<div className={`${styles.fullscreenControls} ${styles.topRight}`} onClick={e => e.stopPropagation()}>
<MaterialIconButton icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? (
<div className={styles.progressWrapper}>
<MaterialCircularProgress />
</div>
) : (
<MaterialIconButton icon="download" onClick={downloadImage} />
)}
</div>
</div>,
id("root")
)}
</>
);
}
@@ -0,0 +1,381 @@
import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "@/core/types";
import { EmojiMenu } from "./EmojiMenu";
import { useUserStore } from "@/state/user";
import styles from "@/pages/chat/css/MessageContextMenu.module.scss";
interface MessageContextMenuProps {
message: Message;
isAuthor: boolean;
onEdit: (message: Message) => void;
onReply: (message: Message) => void;
onDelete: (message: Message) => void;
onRetry?: (message: Message) => void;
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
position: Size2D;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export interface ContextMenuState {
isOpen: boolean;
message: Message | null;
position: Size2D;
}
export function MessageContextMenu({
message,
isAuthor,
onEdit,
onReply,
onDelete,
onRetry,
onReactionClick,
position,
isOpen,
onOpenChange
}: MessageContextMenuProps) {
const { user } = useUserStore();
// Internal state for closing animation
const [isClosing, setIsClosing] = useState(false);
const [reactionBarPosition, setReactionBarPosition] = useState<Size2D>({ x: 0, y: 0 });
const [contextMenuPosition, setContextMenuPosition] = useState<Size2D>(position);
const [animationClass, setAnimationClass] = useState<keyof typeof styles>(styles.entering);
const [reactionBarAnimationClass, setReactionBarAnimationClass] = useState<keyof typeof styles>(styles.entering);
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
const [expandUpward, setExpandUpward] = useState(false);
// Refs for measuring actual dimensions
const reactionBarRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const emojiMenuRef = useRef<HTMLDivElement>(null);
// Calculate smart positioning when component opens
useEffect(() => {
if (isOpen) {
// Use a small delay to ensure elements are rendered before measuring
const frameId = requestAnimationFrame(() => {
if (reactionBarRef.current && contextMenuRef.current) {
// Get actual dimensions from DOM elements
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
const contextMenuRect = contextMenuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate shared/combined rect dimensions
const sharedRect = {
width: Math.max(reactionBarRect.width, contextMenuRect.width),
height: reactionBarRect.height + contextMenuRect.height + 10 // 10px margin
};
let menuX = position.x;
let menuY = position.y;
let reactionX = position.x;
let reactionY = position.y - reactionBarRect.height - 10; // Position above menu
let animation: keyof typeof styles = styles.entering;
let reactionPositionedRight = false;
// Check if reaction bar would overflow at the top
if (reactionY < 0) {
// Position reaction bar to the right side of the context menu instead
reactionX = menuX + contextMenuRect.width + 10;
reactionY = menuY; // Align with menu top
reactionPositionedRight = true;
animation = styles.enteringRight; // Use right-side animation
} else {
// Try positioning above menu first
// Check if shared rect would overflow horizontally
if (menuX + sharedRect.width > viewportWidth) {
menuX = position.x - contextMenuRect.width;
reactionX = menuX;
animation = styles.enteringLeft;
}
}
// Ensure menu doesn't go off the left edge
if (menuX < 0) {
menuX = 0;
if (!reactionPositionedRight) {
reactionX = menuX;
}
}
// Check if reaction bar positioned to the right would overflow
if (reactionPositionedRight && reactionX + reactionBarRect.width > viewportWidth) {
// Position to the left side instead
reactionX = menuX - reactionBarRect.width - 10;
}
// Check if shared rect would overflow bottom edge (only if reaction bar is above)
if (!reactionPositionedRight && menuY + sharedRect.height > viewportHeight) {
menuY = viewportHeight - sharedRect.height;
reactionY = menuY - reactionBarRect.height - 10;
animation = styles.enteringUp;
}
// Ensure menu doesn't go off the right edge
if (menuX + contextMenuRect.width > viewportWidth) {
menuX = viewportWidth - contextMenuRect.width;
if (!reactionPositionedRight) {
reactionX = menuX;
}
}
setContextMenuPosition({ x: menuX, y: menuY });
setReactionBarPosition({ x: reactionX, y: reactionY });
setAnimationClass(animation);
setReactionBarAnimationClass(animation);
}
});
return () => cancelAnimationFrame(frameId);
}
}, [isOpen, position, isAuthor]);
// Effect to handle clicks outside the context menu
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (isOpen && !isClosing) {
// Check if the click is on a context menu element or reaction bar
const target = event.target as Element;
// Use refs instead of class selectors for CSS modules
if ((!contextMenuRef.current || !contextMenuRef.current.contains(target)) &&
(!reactionBarRef.current || !reactionBarRef.current.contains(target))) {
handleClose();
}
}
};
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen && !isClosing) {
handleClose();
}
};
function handleWindowBlur() {
// Close context menu when browser window loses focus
if (isOpen && !isClosing) {
handleClose();
}
};
// Add event listeners
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('blur', handleWindowBlur);
// Cleanup
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleWindowBlur);
};
}, [isOpen, isClosing]);
function handleClose() {
setIsClosing(true);
setAnimationClass(styles.closing);
setReactionBarAnimationClass(styles.closing);
// Wait for animation to complete before calling onOpenChange
setTimeout(() => {
onOpenChange(false);
setIsClosing(false);
setAnimationClass(styles.entering); // Reset for next opening
setReactionBarAnimationClass(styles.entering); // Reset for next opening
// Reset emoji menu state after context menu animation completes
setIsEmojiMenuExpanded(false);
setInitialDimensions(null);
setExpandUpward(false);
}, 200); // Match the animation duration from _animations.scss
}
interface Action {
label: string;
icon: string;
onClick: () => void;
show: boolean;
}
// Check if message is sending or failed
const isSending = message.runtimeData?.sendingState?.status === 'sending';
const isFailed = message.runtimeData?.sendingState?.status === 'failed';
const isSendingOrFailed = isSending || isFailed;
const actions: Action[] = [
{
label: "Reply",
icon: "reply",
onClick: () => {
onReply(message);
handleClose();
},
show: !isSendingOrFailed
},
{
label: "Edit",
icon: "edit",
onClick: () => {
onEdit(message);
handleClose();
},
show: isAuthor && !isSendingOrFailed
},
{
label: "Retry",
icon: "refresh",
onClick: () => {
if (onRetry) {
onRetry(message);
}
handleClose();
},
show: isAuthor && isFailed && !!onRetry
},
{
label: "Delete",
icon: "delete",
onClick: () => {
onDelete(message);
handleClose();
},
show: isAuthor || user.currentUser?.id === 1
},
{
label: "Copy",
icon: "content_copy",
onClick: () => {
navigator.clipboard.writeText(message.content);
handleClose();
},
show: true
}
];
// Quick reactions for the reaction bar
const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
async function handleReactionClick(emoji: string) {
if (onReactionClick) {
await onReactionClick(message.id, emoji);
}
handleClose();
}
function handleExpandClick() {
if (!reactionBarRef.current || !contextMenuRef.current) return;
// Measure the actual dimensions of the reaction bar content
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
// Check if expanding downward would cause overflow
// Calculate space from the reaction bar's bottom edge downward
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - reactionBarRect.bottom;
const emojiMenuHeight = 400;
// Only expand upward if there's not enough space below for the emoji menu
const shouldExpandUpward = spaceBelow < emojiMenuHeight;
setExpandUpward(shouldExpandUpward);
// Use requestAnimationFrame to ensure the dimensions are applied before expansion
requestAnimationFrame(() => {
setIsEmojiMenuExpanded(true);
});
}
function handleEmojiSelect(emoji: string) {
if (onReactionClick) {
onReactionClick(message.id, emoji);
}
handleClose();
}
return isOpen && (
<>
{/* Reaction Bar */}
<div
ref={reactionBarRef}
className={`${styles.contextMenuReactionBar} ${reactionBarAnimationClass} ${isEmojiMenuExpanded ? styles.expanded : ""} ${expandUpward ? styles.expandUpward : ""}`}
style={{
position: 'fixed',
...(isEmojiMenuExpanded && expandUpward
? {
bottom: `${window.innerHeight - reactionBarPosition.y - (initialDimensions?.height || 0)}px`,
left: `${reactionBarPosition.x}px`,
}
: {
top: `${reactionBarPosition.y}px`,
left: `${reactionBarPosition.x}px`,
}
),
width: isEmojiMenuExpanded ? '320px' : initialDimensions?.width || 'auto',
height: isEmojiMenuExpanded ? '400px' : initialDimensions?.height || 'auto',
zIndex: 1001
}}
onClick={(e) => e.stopPropagation()}>
{!isEmojiMenuExpanded ? (
<div className={styles.reactionBarContent}>
{QUICK_REACTIONS.map((emoji, index) => (
<button
key={index}
className={styles.reactionEmojiButton}
onClick={async () => await handleReactionClick(emoji)}
title={emoji}
>
{emoji}
</button>
))}
<button
className={styles.reactionExpandButton}
onClick={handleExpandClick}
title="More emojis"
>
<span className="material-symbols">add</span>
</button>
</div>
) : (
<div
ref={emojiMenuRef}
className={styles.emojiMenuWrapper}>
<EmojiMenu
isOpen={true}
onClose={handleClose}
onEmojiSelect={handleEmojiSelect}
mode="integrated"
/>
</div>
)}
</div>
{/* Context Menu */}
<div
ref={contextMenuRef}
className={`${styles.contextMenu} ${animationClass} ${isEmojiMenuExpanded ? styles.faded : ""}`}
style={{
position: 'fixed',
top: `${contextMenuPosition.y}px`,
left: `${contextMenuPosition.x}px`,
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}>
{actions.map((action, i) => (
action.show && (
<div
className={styles.contextMenuItem}
onClick={action.onClick}
key={i}>
<span className="material-symbols">{action.icon}</span>
{action.label}
</div>
)
))}
</div>
</>
)
}
@@ -0,0 +1,506 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { motion, AnimatePresence } from "motion/react";
import { useChatStore } from "@/state/chat";
import { useUserStore } from "@/state/user";
import { usePresenceStore } from "@/state/presence";
import { useProfileStore } from "@/state/profile";
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
import { ProfileDialog } from "@/pages/chat/ui/ProfileDialog";
import { setGlobalMessageHandler } from "@/core/websocket";
import type { Message, WebSocketMessage } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png";
import { DMPanel } from "./panels/DMPanel";
import useCall from "@/pages/chat/hooks/useCall";
import { TypingIndicator } from "./TypingIndicator";
import { OnlineStatus } from "./OnlineStatus";
import { typingManager } from "@/core/typingManager";
import { PublicChatPanel } from "./panels/PublicChatPanel";
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/layout.module.scss";
import rightPanelStyles from "@/pages/chat/css/right-panel.module.scss";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
}
function ChatHeaderText({ panel }: { panel: MessagePanel | null }) {
const { typingUsers, dmTypingUsers } = usePresenceStore();
const { user } = useUserStore();
const otherTypingUsers = useMemo(() => {
return Array
.from(typingUsers.entries())
.filter(([userId, username]) => userId !== user.currentUser?.id && username)
.map(([, username]) => username!);
}, [typingUsers, user.currentUser?.id]);
let content: ReactNode;
if (panel instanceof DMPanel) {
const recipientId = panel.getRecipientId()!;
const isTyping = dmTypingUsers.get(recipientId);
content = isTyping ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
} else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) {
content = <TypingIndicator typingUsers={otherTypingUsers} />;
} else {
return null;
}
return <div>{content}</div>;
}
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching, setActivePanel } = useChatStore();
const { setProfileDialog } = useProfileStore();
const messagePanelRef = useRef<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const previousMessageCountRef = useRef(0);
const messagesContainerRef = useRef<HTMLElement | null>(null);
const isLoadingMoreRef = useRef(false);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
const [editMessage, setEditMessage] = useState<Message | null>(null);
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
const { initiateCall } = useCall();
// Drag & drop
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const [peerDeleted, setPeerDeleted] = useState(false);
const addFilesRef = useRef<null | ((files: File[]) => void)>(null);
useEffect(() => {
let cancelled = false;
setPeerDeleted(false);
if (!panel?.isDm()) return;
const dmPanel = panel as DMPanel;
dmPanel.getProfile().then((profile) => {
if (!cancelled) {
setPeerDeleted(Boolean(profile?.deleted));
}
});
return () => {
cancelled = true;
};
}, [panel]);
async function handleDeleteDeletedPeerChat() {
if (!panel?.isDm()) return;
const dmPanel = panel as DMPanel;
const messages = [...dmPanel.getMessages()].filter((message) => message.id > 0);
for (const message of messages) {
await dmPanel.handleDeleteMessage(message.id);
}
dmPanel.clearMessages();
setActivePanel(null);
}
useEffect(() => {
if (!panel || !panelState) return;
return () => {
dragCounterRef.current = 0;
setIsDragging(false);
};
}, [panel, panelState]);
useEffect(() => {
if (replyTo) {
setReplyToVisible(true);
}
}, [replyTo]);
useEffect(() => {
if (editMessage) {
setEditVisible(true);
}
}, [editMessage]);
// Handle scroll detection for infinite loading
useEffect(() => {
if (!panel || !panelState) return;
const messagesContainer = document.getElementById("chat-messages");
if (!messagesContainer) return;
messagesContainerRef.current = messagesContainer;
const handleScroll = async () => {
if (!panel || !panelState || isLoadingMoreRef.current) return;
const container = messagesContainerRef.current;
if (!container) return;
// Check if scrolled to top (within 100px threshold)
if (container.scrollTop <= 100 && panelState.hasMoreMessages && !panelState.isLoadingMore) {
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
try {
await panel.loadMoreMessages();
// Preserve scroll position after loading
requestAnimationFrame(() => {
if (container) {
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - previousScrollHeight;
}
isLoadingMoreRef.current = false;
});
} catch (error) {
console.error("Error loading more messages:", error);
isLoadingMoreRef.current = false;
}
}
};
messagesContainer.addEventListener("scroll", handleScroll);
return () => {
messagesContainer.removeEventListener("scroll", handleScroll);
};
}, [panel, panelState]);
// Handle panel state changes
useEffect(() => {
if (panel) {
setPanelState(panel.getState());
// Store the handler for cleanup
panel.onStateChange = (newState: MessagePanelState) => {
setPanelState(newState);
};
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler((message: WebSocketMessage<any>) => panel.handleWebSocketMessage(message));
}
} else {
setPanelState(null);
setGlobalMessageHandler(null);
}
return () => {
if (panel) {
if (panel.onStateChange) {
panel.onStateChange = null;
}
if (typeof panel.destroy === 'function') {
panel.destroy();
}
}
};
}, [panel]);
// Handle chat switching animation
useEffect(() => {
if (isSwitching && pendingPanel) {
// Apply pending panel when animation starts
applyPendingPanel();
// End switching state after a brief delay to allow animation
setTimeout(() => {
setIsSwitching(false);
}, 200);
}
}, [isSwitching, pendingPanel, applyPendingPanel, setIsSwitching]);
// Load messages when panel changes and animation is not running
useEffect(() => {
if (!activePanel || isSwitching) return;
const panelState = activePanel.getState();
if (panelState.messages.length === 0 && !panelState.isLoading) {
activePanel.loadMessages();
}
}, [activePanel, isSwitching]);
// Scroll to bottom only when new messages are added
useEffect(() => {
if (!panelState || isSwitching) return;
const currentMessageCount = panelState.messages.length;
const previousMessageCount = previousMessageCountRef.current;
const el = messagesEndRef.current;
if (!el) return;
// Scroll without animation when messages are initially loaded
if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) {
el.scrollIntoView({ behavior: "instant", block: "end" });
}
// Scroll with animation when a new message is added
else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
// Defer to next frame to ensure layout is stable
const id = requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "end" });
});
return () => cancelAnimationFrame(id);
}
// Update the previous message count
previousMessageCountRef.current = currentMessageCount;
}, [panelState?.messages, panelState?.isLoading, isSwitching]);
function handleCallClick() {
if (panel && panelState && panel.isDm()) {
const dmPanel = panel as DMPanel;
const userId = dmPanel.getDMUserId();
const username = dmPanel.getDMUsername();
if (userId && username) {
initiateCall(userId, username);
}
}
};
async function handleProfileClick() {
if (!panel) return;
try {
const profileData = await panel.getProfile();
if (profileData) {
setProfileDialog(profileData);
}
} catch (error) {
console.error("Failed to get profile:", error);
}
}
const panelKey = activePanel?.getState().title || "empty";
return (
<div className={styles.chatContainer}>
<AnimatePresence mode="wait">
<motion.div
key={panelKey}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className={rightPanelStyles.chatWrapper}
>
<div
ref={messagePanelRef}
className={rightPanelStyles.chatMain}
onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
dragCounterRef.current += 1;
// Only show overlay when actual files are dragged
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
if (hasFiles) setIsDragging(true);
} : undefined}
onDragOver={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
} : undefined}
onDragLeave={panel ? (e) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) setIsDragging(false);
} : undefined}
onDrop={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files || []);
if (files.length > 0 && addFilesRef.current) {
addFilesRef.current(files);
}
setIsDragging(false);
dragCounterRef.current = 0;
} : undefined}>
<div className={rightPanelStyles.chatHeader}>
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
className={rightPanelStyles.chatHeaderAvatar}
onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }}
/>
<div className={rightPanelStyles.chatHeaderInfo}>
<div className={rightPanelStyles.infoChat}>
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<ChatHeaderText panel={panel} />
</div>
{panel?.isDm() && !peerDeleted && (
<MaterialIconButton onClick={handleCallClick} icon="call--filled" />
)}
</div>
</div>
{panelState?.isLoading ? (
<div className={rightPanelStyles.chatMessages} id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка сообщений...
</div>
</div>
) : panelState && panel ? (
<>
{panelState.isLoadingMore && (
<div style={{
display: "flex",
justifyContent: "center",
padding: "8px",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка...
</div>
)}
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
</>
) : (
<div className={rightPanelStyles.chatMessages} id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите чат на боковой панели, чтобы начать переписку
</div>
</div>
)}
{panel && (peerDeleted && panel.isDm() ? (
<div className={rightPanelStyles.deleteChatBar}>
<MaterialButton
variant="filled"
color="error"
onClick={handleDeleteDeletedPeerChat}
>
Удалить чат
</MaterialButton>
</div>
) : (
<ChatInputWrapper
onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null);
}}
onSaveEdit={(content) => {
if (editMessage) {
panel.handleEditMessage(editMessage.id, content);
setEditMessage(null);
}
}}
replyTo={replyTo}
replyToVisible={replyToVisible}
onClearReply={() => {
setPendingAction(null);
setReplyToVisible(false);
}}
onCloseReply={() => {
setReplyTo(null);
if (pendingAction && pendingAction.type === "edit") {
setEditMessage(pendingAction.message);
setPendingAction(null);
}
}}
editingMessage={editMessage}
editVisible={editVisible}
onClearEdit={() => {
setPendingAction(null);
setEditVisible(false);
}}
onCloseEdit={() => {
setEditMessage(null);
if (pendingAction && pendingAction.type === "reply") {
setReplyTo(pendingAction.message);
setPendingAction(null);
}
}}
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
messagePanelRef={messagePanelRef}
onTyping={() => {
if (panel.isDm()) {
const dmPanel = panel as DMPanel;
dmPanel.handleTyping();
} else {
typingManager.sendTyping();
}
}}
onStopTyping={() => {
if (panel.isDm()) {
const dmPanel = panel as DMPanel;
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
} else {
typingManager.stopTypingOnMessage();
}
}}
/>
))}
</div>
{panel && (
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className={rightPanelStyles.fileOverlay}
>
<div className={rightPanelStyles.fileOverlayWrapper}>
<div className={rightPanelStyles.fileOverlayInner}>
<MaterialIcon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)}
</motion.div>
</AnimatePresence>
{/* Profile Dialog */}
<ProfileDialog />
</div>
);
}
@@ -0,0 +1,30 @@
/**
* @fileoverview Online indicator component for profile pictures
* @description Shows a small dot at the bottom right of profile pictures to indicate online status
* @author Cursor
* @version 1.0.0
*/
import { usePresenceStore } from "@/state/presence";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineIndicatorProps {
userId: number;
className?: string;
}
export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) {
const { onlineStatuses } = usePresenceStore();
const status = onlineStatuses.get(userId);
// Only show indicator when user is online
if (!status || !status.online) {
return null;
}
return (
<div className={`${styles.onlineIndicator} ${className}`}>
<div className={`${styles.indicatorDot} ${styles.online}`}></div>
</div>
);
}
@@ -0,0 +1,61 @@
/**
* @fileoverview Online status component for showing user online status
* @description Displays online/offline status with last seen timestamp
* @author Cursor
* @version 1.0.0
*/
import { usePresenceStore } from "@/state/presence";
import { useUserStore } from "@/state/user";
import { formatDeletedUserLastSeen, isEpochLastSeen } from "@/core/userDisplay";
import { parseApiTimestamp } from "@/utils/utils";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineStatusProps {
userId: number;
showLastSeen?: boolean;
}
export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) {
const { onlineStatuses } = usePresenceStore();
const { user } = useUserStore();
const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : onlineStatuses.get(userId);
function formatLastSeen(lastSeen: string): string {
if (isEpochLastSeen(lastSeen)) {
return formatDeletedUserLastSeen();
}
const date = parseApiTimestamp(lastSeen);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) {
return "только что";
} else if (diffMins < 60) {
return `${diffMins} мин. назад`;
} else if (diffHours < 24) {
return `${diffHours} ч. назад`;
} else if (diffDays < 7) {
return `${diffDays} дн. назад`;
} else {
return date.toLocaleDateString();
}
}
return (
<div className={styles.onlineStatus}>
<div className={`${styles.statusDot} ${status?.online ? styles.online : styles.offline}`}></div>
<span className={styles.statusText}>
{!status ? "Загрузка..." : status?.online ? "В сети" : "Не в сети"}
</span>
{showLastSeen && status && !status.online && (
<span className="last-seen">
{formatLastSeen(status.lastSeen)}
</span>
)}
</div>
);
}
@@ -0,0 +1,8 @@
import { useChatStore } from "@/state/chat";
import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() {
const { activePanel } = useChatStore();
return <MessagePanelRenderer panel={activePanel} />
}
@@ -0,0 +1,37 @@
/**
* @fileoverview Typing indicator component for showing who is typing
* @description Displays a list of users who are currently typing
* @author Cursor
* @version 1.0.0
*/
import { useMemo } from "react";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface TypingIndicatorProps {
typingUsers: string[]; // Array of usernames who are typing
}
export function TypingIndicator({ typingUsers }: TypingIndicatorProps) {
// Format the typing text based on number of users
const typingText = useMemo(() => {
switch (typingUsers.length) {
case 0: return "печатает...";
case 1: return `${typingUsers[0]} печатает...`;
case 2: return `${typingUsers[0]} и ${typingUsers[1]} печатают...`;
default: return `${typingUsers[0]}, ${typingUsers[1]} и еще ${typingUsers.length - 2} печатают...`;
}
}, [typingUsers]);
return (
<div className={styles.typingIndicator}>
<div className={styles.typingDots}>
<span />
<span />
<span />
</div>
<span className={styles.typingText}>{typingText}</span>
</div>
);
}
@@ -0,0 +1,273 @@
import { useState, useEffect } from "react";
import { useCallStore } from "@/state/call";
import { useUserStore } from "@/state/user";
import useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png";
import { createPortal } from "react-dom";
import { id } from "@/utils/utils";
import { MaterialIconButton } from "@/utils/material";
import { motion, AnimatePresence } from "motion/react";
import styles from "@/pages/chat/css/callWindow.module.scss";
export function CallWindow() {
const { call, toggleCallMinimized } = useCallStore();
const { user } = useUserStore();
const {
acceptCall,
rejectCall,
remoteAudioRef,
endCall,
toggleMute,
toggleVideo,
toggleScreenShare,
localVideoRef,
remoteVideoRef,
localScreenShareRef,
remoteScreenShareRef
} = useCall();
const [pipPosition, setPipPosition] = useState({ x: window.innerWidth - 420, y: window.innerHeight - 320 });
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [callDuration, setCallDuration] = useState(0);
const status = call.status;
const remoteUsername = call.remoteUsername;
const isInitiator = call.isInitiator;
const isMuted = call.isMuted;
useEffect(() => {
let interval: NodeJS.Timeout;
if (call.status === "active" && call.startTime) {
interval = setInterval(() => {
setCallDuration(Math.floor((Date.now() - call.startTime!) / 1000));
}, 1000);
} else {
setCallDuration(0);
}
return () => {
if (interval) clearInterval(interval);
};
}, [call.status, call.startTime]);
// Handle dragging for PiP mode
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging && call.isMinimized) {
setPipPosition({
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y
});
}
};
const handleMouseUp = () => {
if (isDragging) {
setIsDragging(false);
}
};
if (isDragging) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, call.isMinimized, dragOffset]);
function formatDuration(seconds: number) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function getStatusText() {
switch (status) {
case "calling":
return "Calling...";
case "connecting":
return "Connecting...";
case "active":
return formatDuration(callDuration);
default:
return "";
}
}
function getGradientClass() {
switch (status) {
case "calling":
return styles.gradientCalling;
case "connecting":
return styles.gradientConnecting;
case "active":
return styles.gradientActive;
default:
return styles.gradientDefault;
}
}
const isMinimized = call.isMinimized;
return (
createPortal(
<>
<audio
ref={remoteAudioRef}
className={styles.remoteAudio}
autoPlay
playsInline
controls />
<AnimatePresence>
{call.isActive && (
<motion.div
className={`${styles.callWindow} ${isMinimized ? styles.minimized : styles.maximized} ${isDragging ? styles.dragging : ""} ${getGradientClass()}`}
style={isMinimized ? {
left: pipPosition.x,
top: pipPosition.y
} : {}}
initial={false}
exit={isMinimized ?
{ opacity: 0, scale: 0.7 } :
{ opacity: 0, y: -100 }
}
transition={isDragging ? { duration: 0 } : {
opacity: { duration: 0.4 },
scale: { duration: 0.4 },
y: { duration: 0.4 }
}}
onMouseDown={(e) => {
if (isMinimized) {
if (!e.target.closest("mdui-button-icon")) {
setIsDragging(true);
setDragOffset({
x: e.clientX - pipPosition.x,
y: e.clientY - pipPosition.y
});
}
}
}}
>
<div className={styles.callHeader}>
<div className={styles.windowControls}>
<MaterialIconButton
onClick={toggleCallMinimized}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className={styles.windowControlBtn}
/>
</div>
<div className={styles.callHeaderInfo}>
<h3 className={styles.username}>{remoteUsername}</h3>
<p className={styles.status}>{getStatusText()}</p>
{!call.isMinimized && call.encryptionEmojis.length > 0 && (
<div className={styles.encryptionEmojis}>
{call.encryptionEmojis.map((emoji, index) => (
<span key={index} className={styles.encryptionEmoji}>
{emoji}
</span>
))}
</div>
)}
</div>
</div>
<div className={`${styles.callContent} ${(call.isSharingScreen || call.isRemoteScreenSharing) ? styles.withScreenShare : ""}`}>
{/* Main screen share area - takes most space when active */}
<div className={styles.screenShareArea}>
{/* Local screen share */}
<div
className={`${styles.videoTile} ${styles.screenShareTile} ${styles.localScreenShare}`}
style={{ display: call.isSharingScreen ? "flex" : "none" }}>
<video
ref={localScreenShareRef}
className={`${styles.videoElement} ${styles.screenShareVideo}`}
autoPlay
playsInline
muted />
<div className={styles.tileLabel}>Your Screen</div>
</div>
{/* Remote screen share */}
<div
className={`${styles.videoTile} ${styles.screenShareTile} ${styles.remoteScreenShare}`}
style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}>
<video
ref={remoteScreenShareRef}
className={`${styles.videoElement} ${styles.screenShareVideo}`}
autoPlay
playsInline />
<div className={styles.tileLabel}>{remoteUsername}&apos;s Screen</div>
</div>
</div>
{/* Video tiles sidebar - appears on right when screen share is active */}
<div className={styles.videoTilesSidebar}>
{/* Local video tile */}
<div className={`${styles.videoTile} ${styles.localVideo}`}>
<video
ref={localVideoRef}
className={styles.videoElement}
autoPlay
playsInline
muted
style={{ display: call.isVideoEnabled ? "block" : "none" }} />
{!call.isVideoEnabled && (
<div className={styles.videoPlaceholder}>
<img src={defaultAvatar} alt="Avatar" className={styles.placeholderAvatar} />
<span className={styles.placeholderUsername}>{user.currentUser?.username || "You"}</span>
</div>
)}
<div className={styles.tileLabel}>You</div>
</div>
{/* Remote video tile */}
<div className={`${styles.videoTile} ${styles.remoteVideo}`}>
<video
ref={remoteVideoRef}
className={styles.videoElement}
autoPlay
playsInline
style={{ display: call.isRemoteVideoEnabled ? "block" : "none" }} />
{!call.isRemoteVideoEnabled && (
<div className={styles.videoPlaceholder}>
<img src={defaultAvatar} alt="Avatar" className={styles.placeholderAvatar} />
<span className={styles.placeholderUsername}>{remoteUsername}</span>
</div>
)}
<div className={styles.tileLabel}>{remoteUsername}</div>
</div>
</div>
</div>
<div className={styles.callControls}>
{status === "calling" && !isInitiator ? (
<>
<MaterialIconButton onClick={acceptCall} icon="call" />
<MaterialIconButton onClick={rejectCall} icon="call_end" />
</>
) : (
<>
<MaterialIconButton onClick={toggleMute} icon={isMuted ? "mic_off" : "mic"} />
<MaterialIconButton onClick={toggleVideo} icon={call.isVideoEnabled ? "videocam" : "videocam_off"} />
<MaterialIconButton onClick={toggleScreenShare} icon={call.isSharingScreen ? "stop_screen_share" : "screen_share"} />
<MaterialIconButton onClick={endCall} icon="call_end" />
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</>,
id("root")
)
);
}
@@ -0,0 +1,62 @@
import { useCallStore } from "@/state/call";
import useCall from "@/pages/chat/hooks/useCall";
import defaultAvatar from "@/images/default-avatar.png";
import { MaterialIconButton } from "@/utils/material";
export function MinimizedCallBar() {
const { call, toggleCallMinimized } = useCallStore();
const { endCall, toggleMute } = useCall();
function getGradientClass() {
switch (call.status) {
case "calling":
return "gradient-calling";
case "connecting":
return "gradient-connecting";
case "active":
return "gradient-active";
default:
return "gradient-default";
}
}
function getStatusText() {
switch (call.status) {
case "calling":
return "Calling...";
case "connecting":
return "Connecting...";
case "active":
return "Active";
default:
return "";
}
}
if (!call.isActive || !call.isMinimized) {
return null;
}
return (
<div className={`minimized-call-bar ${getGradientClass()}`} onClick={toggleCallMinimized}>
<div className="call-info">
<img src={defaultAvatar} alt="Avatar" className="avatar" />
<div className="user-details">
<span className="username">{call.remoteUsername}</span>
<span className="status">{getStatusText()}</span>
</div>
</div>
<div className="call-actions" onClick={(e) => e.stopPropagation()}>
{call.status === "calling" && !call.isInitiator ? (
<MaterialIconButton onClick={endCall} icon="call_end" />
) : (
<>
<MaterialIconButton onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
<MaterialIconButton onClick={endCall} icon="call_end" />
</>
)}
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
export interface EmojiCategory {
name: string;
icon: string;
emojis: string[];
}
export const EMOJI_CATEGORIES: EmojiCategory[] = [
{
name: "recent",
icon: "🕒",
emojis: []
},
{
name: "smileys",
icon: "😀",
emojis: [
"😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖", "😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾"
]
},
{
name: "people",
icon: "👋",
emojis: [
"👋", "🤚", "🖐", "✋", "🖖", "👌", "🤏", "✌️", "🤞", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "👍", "👎", "👊", "✊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦿", "🦵", "🦶", "👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁", "👅", "👄", "💋", "🩸", "👶", "🧒", "👦", "👧", "🧑", "👨", "👩", "🧓", "👴", "👵", "👱", "🧔", "👲", "🧕", "👳", "👮", "👷", "💂", "🕵️", "👩‍⚕️", "👨‍⚕️", "👩‍🌾", "👨‍🌾", "👩‍🍳", "👨‍🍳", "👩‍🎓", "👨‍🎓", "👩‍🎤", "👨‍🎤", "👩‍🏫", "👨‍🏫", "👩‍🏭", "👨‍🏭", "👩‍💻", "👨‍💻", "👩‍💼", "👨‍💼", "👩‍🔧", "👨‍🔧", "👩‍🔬", "👨‍🔬", "👩‍🎨", "👨‍🎨", "👩‍🚒", "👨‍🚒", "👩‍✈️", "👨‍✈️", "👩‍🚀", "👨‍🚀", "👩‍⚖️", "👨‍⚖️", "👰", "🤵", "👸", "🤴", "🦸", "🦹", "🤶", "🎅", "🧙", "🧚", "🧛", "🧜", "🧝", "🧞", "🧟", "💆", "💇", "🚶", "🏃", "💃", "🕺", "🕴", "👯", "🧘", "🛀", "🛌", "👭", "👫", "👬", "💏", "💑", "👪"
]
},
{
name: "animals",
icon: "🐶",
emojis: [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐽", "🐸", "🐵", "🙈", "🙉", "🙊", "🐒", "🐔", "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐜", "🦟", "🦗", "🕷", "🕸", "🦂", "🐢", "🐍", "🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍", "🦧", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌", "🐕", "🐩", "🦮", "🐕‍🦺", "🐈", "🐓", "🦃", "🦚", "🦜", "🦢", "🦩", "🕊", "🐇", "🦝", "🦨", "🦡", "🦦", "🦥", "🐁", "🐀", "🐿", "🦔"
]
},
{
name: "food",
icon: "🍎",
emojis: [
"🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔", "🍟", "🍕", "🫓", "🥙", "🌮", "🌯", "🫔", "🥗", "🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟", "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡", "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬", "🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "☕", "🫖", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹", "🧉", "🍾"
]
},
{
name: "travel",
icon: "🚗",
emojis: [
"🚗", "🚕", "🚙", "🚌", "🚎", "🏎", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🏍", "🛵", "🚲", "🛴", "🛹", "🛼", "🚁", "✈️", "🛩", "🛫", "🛬", "🪂", "💺", "🚀", "🛸", "🚉", "🚊", "🚝", "🚞", "🚋", "🚃", "🚋", "🚋", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🎢", "🎡", "🎠", "⛵", "🛥", "🚤", "⛴", "🛳", "🚢", "⚓", "🚧", "⛽", "🚨", "🚥", "🚦", "🛑", "🚏", "🗺", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟", "🎡", "🎢", "🎠", "⛲", "⛱", "🏖", "🏝", "🏔", "⛰", "🌋", "🗻", "🏕", "⛺", "🏠", "🏡", "🏘", "🏚", "🏗", "🏭", "🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩", "💒", "🏛", "⛪", "🕌", "🛕", "🕍", "🕋", "⛩", "🛤", "🛣", "🗾", "🎑", "🏞", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆", "🏙", "🌃", "🌌", "🌉", "🌁"
]
},
{
name: "activities",
icon: "⚽",
emojis: [
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛷", "⛸", "🥌", "🎿", "⛷", "🏂", "🪂", "🏋️‍♀️", "🏋️‍♂️", "🤼‍♀️", "🤼‍♂️", "🤸‍♀️", "🤸‍♂️", "⛹️‍♀️", "⛹️‍♂️", "🤺", "🤾‍♀️", "🤾‍♂️", "🏌️‍♀️", "🏌️‍♂️", "🏇", "🧘‍♀️", "🧘‍♂️", "🏄‍♀️", "🏄‍♂️", "🏊‍♀️", "🏊‍♂️", "🤽‍♀️", "🤽‍♂️", "🚣‍♀️", "🚣‍♂️", "🧗‍♀️", "🧗‍♂️", "🚵‍♀️", "🚵‍♂️", "🚴‍♀️", "🚴‍♂️", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖", "🏵", "🎗", "🎫", "🎟", "🎪", "🤹", "🤹‍♀️", "🤹‍♂️", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧", "🎼", "🎹", "🥁", "🎷", "🎺", "🎸", "🪕", "🎻", "🎲", "♠️", "♥️", "♦️", "♣️", "♟", "🃏", "🀄", "🎴", "🎯", "🎳", "🎮", "🎰", "🧩"
]
},
{
name: "objects",
icon: "📱",
emojis: [
"📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌", "💻", "🖥", "🖨", "⌨️", "🖱", "🖲", "💽", "💾", "💿", "📀", "🧮", "🎥", "📽", "📸", "📹", "📷", "🔍", "🔎", "🕯", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰", "🗞", "📑", "🔖", "🏷", "💰", "💴", "💵", "💶", "💷", "💸", "💳", "🧾", "💹", "💱", "💲", "✉️", "📧", "📨", "📩", "📤", "📥", "📦", "📫", "📪", "📬", "📭", "📮", "🗳", "✏️", "✒️", "🖋", "🖊", "🖌", "🖍", "📝", "💼", "📁", "📂", "🗂", "📅", "📆", "🗒", "🗓", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇", "📏", "📐", "✂️", "🗃", "🗄", "🗑", "🔒", "🔓", "🔏", "🔐", "🔑", "🗝", "🔨", "⛏", "⚒", "🛠", "🗡", "⚔️", "🔫", "🪃", "🏹", "🛡", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜", "⚖️", "🦯", "🔗", "⛓", "🧰", "🧲", "⚗️", "🧪", "🧫", "🧬", "🔬", "🔭", "📡", "💉", "💊", "🩹", "🩺", "🚪", "🛏", "🛋", "🚽", "🚿", "🛁", "🛀", "🧴", "🧷", "🧹", "🧺", "🧻", "🚰", "🚰", "🪒", "🧽", "🧯", "🛒"
]
},
{
name: "symbols",
icon: "❤️",
emojis: [
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "☮️", "✝️", "☪️", "🕉", "☸️", "✡️", "🔯", "🕎", "☯️", "☦️", "🛐", "⛎", "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", "🆔", "⚛️", "🉑", "☢️", "☣️", "📴", "📳", "🈶", "🈚", "🈸", "🈺", "🈷️", "✴️", "🆚", "💮", "🉐", "㊙️", "㊗️", "🈴", "🈵", "🈹", "🈲", "🅰️", "🅱️", "🆎", "🅾️", "🆘", "❌", "⭕", "🛑", "⛔", "📛", "🚫", "💯", "💢", "♨️", "🚷", "🚯", "🚳", "🚱", "🔞", "📵", "🚭", "❗", "❕", "❓", "❔", "‼️", "⁉️", "🔅", "🔆", "〽️", "⚠️", "🚸", "🔱", "⚜️", "🔰", "♻️", "✅", "🈯", "💹", "❇️", "✳️", "❎", "🌐", "💠", "Ⓜ️", "🌀", "💤", "🏧", "🚾", "♿", "🅿️", "🛗", "🈳", "🈂️", "🛂", "🛃", "🛄", "🛅", "🚹", "🚺", "🚼", "⚧", "🚻", "🚮", "🎦", "📶", "🈁", "🔣", "️", "🔤", "🔡", "🔠", "🆖", "🆗", "🆙", "🆒", "🆕", "🆓", "0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"
]
},
{
name: "flags",
icon: "🏳️",
emojis: [
"🏳️", "🏴", "🏁", "🚩", "🏳️‍🌈", "🏳️‍⚧️", "🏴‍☠️", "🇦🇨", "🇦🇩", "🇦🇪", "🇦🇫", "🇦🇬", "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼", "🇧🇾", "🇧🇿", "🇨🇦", "🇨🇨", "🇨🇩", "🇨🇫", "🇨🇬", "🇨🇭", "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", "🇨🇿", "🇩🇪", "🇩🇬", "🇩🇯", "🇩🇰", "🇩🇲", "🇩🇴", "🇩🇿", "🇪🇦", "🇪🇨", "🇪🇪", "🇪🇬", "🇪🇭", "🇪🇷", "🇪🇸", "🇪🇹", "🇪🇺", "🇫🇮", "🇫🇯", "🇫🇰", "🇫🇲", "🇫🇴", "🇫🇷", "🇬🇦", "🇬🇧", "🇬🇩", "🇬🇪", "🇬🇫", "🇬🇬", "🇬🇭", "🇬🇮", "🇬🇱", "🇬🇲", "🇬🇳", "🇬🇵", "🇬🇶", "🇬🇷", "🇬🇸", "🇬🇹", "🇬🇺", "🇬🇼", "🇬🇾", "🇭🇰", "🇭🇲", "🇭🇳", "🇭🇷", "🇭🇹", "🇭🇺", "🇮🇨", "🇮🇩", "🇮🇪", "🇮🇱", "🇮🇲", "🇮🇳", "🇮🇴", "🇮🇶", "🇮🇷", "🇮🇸", "🇮🇹", "🇯🇪", "🇯🇲", "🇯🇴", "🇯🇵", "🇰🇪", "🇰🇬", "🇰🇭", "🇰🇮", "🇰🇲", "🇰🇳", "🇰🇵", "🇰🇷", "🇰🇼", "🇰🇾", "🇰🇿", "🇱🇦", "🇱🇧", "🇱🇨", "🇱🇮", "🇱🇰", "🇱🇷", "🇱🇸", "🇱🇹", "🇱🇺", "🇱🇻", "🇱🇾", "🇲🇦", "🇲🇨", "🇲🇩", "🇲🇪", "🇲🇫", "🇲🇬", "🇲🇭", "🇲🇰", "🇲🇱", "🇲🇲", "🇲🇳", "🇲🇴", "🇲🇵", "🇲🇶", "🇲🇷", "🇲🇸", "🇲🇹", "🇲🇺", "🇲🇻", "🇲🇼", "🇲🇽", "🇲🇾", "🇲🇿", "🇳🇦", "🇳🇨", "🇳🇪", "🇳🇫", "🇳🇬", "🇳🇮", "🇳🇱", "🇳🇴", "🇳🇵", "🇳🇷", "🇳🇺", "🇳🇿", "🇴🇲", "🇵🇦", "🇵🇪", "🇵🇫", "🇵🇬", "🇵🇭", "🇵🇰", "🇵🇱", "🇵🇲", "🇵🇳", "🇵🇷", "🇵🇸", "🇵🇹", "🇵🇼", "🇵🇾", "🇶🇦", "🇷🇪", "🇷🇴", "🇷🇸", "🇷🇺", "🇷🇼", "🇸🇦", "🇸🇧", "🇸🇨", "🇸🇩", "🇸🇪", "🇸🇬", "🇸🇭", "🇸🇮", "🇸🇯", "🇸🇰", "🇸🇱", "🇸🇲", "🇸🇳", "🇸🇴", "🇸🇷", "🇸🇸", "🇸🇹", "🇸🇻", "🇸🇽", "🇸🇾", "🇸🇿", "🇹🇦", "🇹🇨", "🇹🇩", "🇹🇫", "🇹🇬", "🇹🇭", "🇹🇯", "🇹🇰", "🇹🇱", "🇹🇲", "🇹🇳", "🇹🇴", "🇹🇷", "🇹🇹", "🇹🇻", "🇹🇼", "🇹🇿", "🇺🇦", "🇺🇬", "🇺🇲", "🇺🇸", "🇺🇾", "🇺🇿", "🇻🇦", "🇻🇨", "🇻🇪", "🇻🇬", "🇻🇮", "🇻🇳", "🇻🇺", "🇼🇫", "🇼🇸", "🇾🇪", "🇾🇹", "🇿🇦", "🇿🇲", "🇿🇼"
]
}
];
export const RECENT_EMOJIS_KEY = "recentEmojis";
export function getRecentEmojis(): string[] {
try {
const stored = localStorage.getItem(RECENT_EMOJIS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function addRecentEmoji(emoji: string): void {
try {
let recentEmojis = getRecentEmojis();
recentEmojis = recentEmojis.filter(e => e !== emoji);
recentEmojis.unshift(emoji);
recentEmojis = recentEmojis.slice(0, 50);
localStorage.setItem(RECENT_EMOJIS_KEY, JSON.stringify(recentEmojis));
} catch {
// Ignore localStorage errors
}
}
@@ -0,0 +1,424 @@
import { MessagePanel } from "./MessagePanel";
import api from "@/core/api";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types";
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager";
export interface DMPanelData {
userId: number;
username: string;
publicKey: string;
profilePicture?: string;
online: boolean;
}
export class DMPanel extends MessagePanel {
public dmData: DMPanelData | null = null;
private messagesLoaded: boolean = false;
constructor(
user: UserState
) {
super("dm", user);
}
isDm(): boolean {
return true;
}
getRecipientId(): number | null {
return this.dmData?.userId || null;
}
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);
}
}
deactivate(): void {
// Unsubscribe from recipient's online status
if (this.dmData?.userId) {
onlineStatusManager.unsubscribe(this.dmData.userId);
}
}
clearMessages(): void {
super.clearMessages();
this.messagesLoaded = false;
}
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await api.chats.dm.decrypt(env, this.currentUser.currentUser?.id);
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
let content = plaintext;
let reply_to_id: number | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as DmEncryptedJSON;
if (obj && obj.type === "text" && obj.data) {
content = obj.data.content;
reply_to_id = Number(obj.data.reply_to_id) || undefined;
}
} catch {}
const dmMsg: Message = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
reactions: env.reactions || [],
runtimeData: {
dmEnvelope: env
}
};
if (reply_to_id) {
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
if (referenced) dmMsg.reply_to = referenced;
}
return dmMsg;
}
async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
this.setLoading(true);
try {
const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
decryptedMessages.push(dmMsg);
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (error) {
console.error("Error decrypting message:", error);
}
}
this.clearMessages();
decryptedMessages.forEach(msg => this.addMessage(msg));
this.setHasMoreMessages(has_more);
// Update last read ID
if (maxIncomingId > 0) {
this.setLastReadId(this.dmData.userId, maxIncomingId);
}
this.messagesLoaded = true;
} catch (error) {
console.error("Failed to load DM history:", error);
} finally {
this.setLoading(false);
}
}
async loadMoreMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !this.state.hasMoreMessages || this.state.isLoadingMore) return;
const messages = this.getMessages();
if (messages.length === 0) return;
const oldestMessage = messages[0];
const oldestEnvelope = oldestMessage.runtimeData?.dmEnvelope;
if (!oldestEnvelope) return;
this.setLoadingMore(true);
try {
const limit = this.calculateMessageLimit();
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
this.dmData.userId,
this.currentUser.authToken,
limit,
oldestEnvelope.id
);
if (newEnvelopes && newEnvelopes.length > 0) {
const decryptedMessages: Message[] = [];
for (const env of newEnvelopes) {
try {
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
decryptedMessages.push(dmMsg);
} catch (error) {
console.error("Error decrypting message:", error);
}
}
// Prepend older messages (they come in reverse chronological order)
this.updateState({
messages: [...decryptedMessages.reverse(), ...messages]
});
}
this.setHasMoreMessages(has_more);
} catch (error) {
console.error("Failed to load more DM messages:", error);
} finally {
this.setLoadingMore(false);
}
}
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || (!content.trim() && files.length === 0)) return;
if (files.length === 0) {
await api.chats.dm.send(
this.dmData.userId,
this.dmData.publicKey,
content.trim(),
this.currentUser.authToken,
replyToId
);
} else {
await api.chats.dm.sendWithFiles(
this.dmData.userId,
this.dmData.publicKey,
files,
content.trim(),
this.currentUser.authToken,
replyToId
);
}
}
// Set DM conversation data
setDMData(dmData: DMPanelData): void {
this.dmData = dmData;
this.messagesLoaded = false;
this.updateState({
id: `dm-${dmData.userId}`,
title: dmData.username,
profilePicture: dmData.profilePicture,
online: dmData.online
});
}
// Handle incoming WebSocket DM messages
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) {
// This is our message being confirmed, find the temp message and replace it
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
for (const tempMsg of tempMessages) {
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) {
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg);
return;
}
}
}
this.addMessage(dmMsg);
// Update last read if it's from the other user
if (envelope.senderId === this.dmData.userId) {
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
}
} catch (error) {
console.error("Failed to decrypt incoming DM:", error);
}
}
}
if (response.type === "dmEdited" && this.dmData) {
const { id, senderId, recipientId, iv_b64, ciphertext_b64, wrapped_mek_b64, timestamp } = response.data;
if (!wrapped_mek_b64) {
this.updateMessage(id, { is_edited: true });
} else {
try {
const plaintext = await api.chats.dm.decrypt(
{
id,
senderId: senderId ?? 0,
recipientId: recipientId ?? 0,
iv_b64: iv_b64 ?? "",
ciphertext_b64: ciphertext_b64 ?? "",
wrapped_mek_b64,
timestamp: timestamp ?? new Date().toISOString()
},
this.currentUser.currentUser?.id
);
let content = plaintext;
let files: Message["files"] | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
if (obj.type === "text" && obj.data) {
content = obj.data.content;
files = obj.data.files;
}
} catch {}
const updates: Partial<Message> = { content, is_edited: true, files };
this.updateMessage(id, updates);
} catch (e) {
this.updateMessage(id, { is_edited: true });
}
}
}
if (response.type === "dmDeleted" && this.dmData) {
const { id } = response.data;
this.removeMessage(id);
}
if (response.type === "dmReactionUpdate" && this.dmData) {
const { dm_envelope_id, reactions } = response.data;
this.updateMessageReactions(dm_envelope_id, reactions);
}
};
// Reset for DM switching
reset(): void {
// Unsubscribe from current recipient's status before switching
if (this.dmData?.userId) {
onlineStatusManager.unsubscribe(this.dmData.userId);
}
this.dmData = null;
this.messagesLoaded = false;
this.clearMessages();
this.updateState({
id: "dm",
title: "Select a user",
profilePicture: undefined,
online: false
});
}
// Update auth token
setAuthToken(authToken: string): void {
this.currentUser.authToken = authToken;
}
// Get DM user ID for call functionality
getDMUserId(): number | null {
return this.dmData?.userId || null;
}
// Get DM username for call functionality
getDMUsername(): string | null {
return this.dmData?.username || null;
}
// Handle typing in DM
handleTyping(): void {
if (this.dmData?.userId) {
typingManager.sendDmTyping(this.dmData.userId);
}
}
// Helper functions for localStorage
private getLastReadId(userId: number): number {
try {
const v = localStorage.getItem(`dmLastRead:${userId}`);
return v ? Number(v) : 0;
} catch {
return 0;
}
}
private setLastReadId(userId: number, id: number): void {
try {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {}
}
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 api.chats.dm.deleteMessage(messageId, this.dmData.userId, this.currentUser.authToken);
}
async handleEditMessage(messageId: number, content: string): Promise<void> {
if (!this.currentUser.authToken || !this.dmData) return;
try {
await api.chats.dm.editMessage(
messageId,
this.dmData.publicKey,
content.trim(),
this.currentUser.authToken
);
// Update the message in the UI
this.updateMessage(messageId, {
content: content.trim(),
is_edited: true
});
// Send WebSocket updates will be handled by the server
} catch (error) {
console.error("Failed to edit DM:", error);
throw error;
}
}
async getProfile(): Promise<ProfileDialogData | null> {
if (!this.dmData || !this.currentUser.authToken) return null;
try {
const userProfile = await api.user.profile.fetchById(this.currentUser.authToken, this.dmData.userId);
if (!userProfile) return null;
return {
userId: userProfile.id,
username: userProfile.username,
display_name: userProfile.display_name,
profilePicture: userProfile.profile_picture,
bio: userProfile.bio,
memberSince: userProfile.created_at,
online: userProfile.online,
deleted: userProfile.deleted,
isOwnProfile: false
};
} catch (error) {
console.error("Failed to fetch user profile:", error);
return null;
}
}
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
const messages = this.getMessages();
const messageIndex = messages.findIndex(msg =>
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
);
if (messageIndex !== -1) {
const updatedMessage = { ...messages[messageIndex] };
updatedMessage.reactions = reactions;
this.updateMessage(updatedMessage.id, { reactions: reactions });
}
}
}
@@ -0,0 +1,403 @@
import type { Message, WebSocketMessage } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types";
import { alert } from "@/core/components/AlertDialog";
interface HttpError extends Error {
status?: number;
detail?: string;
}
export interface MessagePanelState {
id: string;
title: string;
profilePicture?: string;
online: boolean;
messages: Message[];
isLoading: boolean;
isTyping: boolean;
hasMoreMessages: boolean;
isLoadingMore: boolean;
}
export interface MessagePanelCallbacks {
onSendMessage: (content: string, files: File[]) => void;
onEditMessage: (messageId: number, content: string) => void;
onDeleteMessage: (messageId: number) => void;
onReplyToMessage: (messageId: number, content: string) => void;
onProfileClick: () => void;
}
export abstract class MessagePanel {
protected state: MessagePanelState;
public onStateChange: ((state: MessagePanelState) => void) | null = () => {};
protected readonly currentUser: UserState;
private pendingMessages: Map<string, { timeoutId: NodeJS.Timeout; message: Message }> = new Map();
constructor(
id: string,
currentUser: UserState,
) {
this.state = {
id,
title: "",
online: false,
messages: [],
isLoading: false,
isTyping: false,
hasMoreMessages: false,
isLoadingMore: false
};
this.currentUser = currentUser;
}
// Abstract methods that must be implemented by subclasses
abstract activate(): Promise<void>;
abstract deactivate(): void;
abstract loadMessages(): Promise<void>;
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
abstract isDm(): boolean;
abstract handleWebSocketMessage(response: WebSocketMessage<unknown>): Promise<void>;
abstract getProfile(): Promise<ProfileDialogData | null>;
// Common methods
protected updateState(updates: Partial<MessagePanelState>): void {
this.state = { ...this.state, ...updates };
if (this.onStateChange) {
this.onStateChange(this.state);
}
}
protected addMessage(message: Message): void {
const messageExists = this.state.messages.some(msg => msg.id === message.id);
if (!messageExists) {
this.updateState({
messages: [...this.state.messages, message]
});
}
}
protected updateMessage(messageId: number, updates: Partial<Message>): void {
this.updateState({
messages: this.state.messages.map(msg => {
// Handle temporary messages (negative IDs) by matching temp ID
if (messageId === -1 && msg.runtimeData?.sendingState?.tempId) {
const pending = this.pendingMessages.get(msg.runtimeData.sendingState.tempId);
if (pending) {
return { ...pending.message, ...updates };
}
}
return msg.id === messageId ? { ...msg, ...updates } : msg;
})
});
}
protected removeMessage(messageId: number): void {
this.updateState({
messages: this.state.messages.filter(msg => msg.id !== messageId)
});
}
protected updateMessageReactions(messageId: number, reactions: Message["reactions"]): void {
this.updateState({
messages: this.state.messages.map(msg =>
msg.id === messageId ? { ...msg, reactions } : msg
)
});
}
protected clearMessages(): void {
this.updateState({ messages: [] });
}
protected setLoading(loading: boolean): void {
this.updateState({ isLoading: loading });
}
protected setTyping(typing: boolean): void {
this.updateState({ isTyping: typing });
}
protected setLoadingMore(loading: boolean): void {
this.updateState({ isLoadingMore: loading });
}
protected setHasMoreMessages(hasMore: boolean): void {
this.updateState({ hasMoreMessages: hasMore });
}
/**
* Calculate message limit based on viewport height (5x screen height)
*/
protected calculateMessageLimit(): number {
const viewportHeight = window.innerHeight;
return Math.ceil((viewportHeight * 5) / 100);
}
/**
* Load more messages (to be implemented by subclasses)
*/
abstract loadMoreMessages(): Promise<void>;
// Getters
getState(): MessagePanelState {
return { ...this.state };
}
getId(): string {
return this.state.id;
}
getTitle(): string {
return this.state.title;
}
getMessages(): Message[] {
return [...this.state.messages];
}
// ========== PUBLIC API ==========
// Event handlers
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
this.sendMessageWithImmediateDisplay(content, replyToId, files);
}
async retryMessage(messageId: number): Promise<void> {
const message = this.getMessages().find(m => m.id === messageId);
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,
id: -1, // Temporary ID
// Preserve existing files (which may have blob URLs for display)
files: message.files,
runtimeData: {
...message.runtimeData,
sendingState: {
status: 'sending',
tempId,
retryData: {
content,
replyToId,
files: files || []
}
}
}
};
// Update the existing message to sending state
this.updateState({
messages: this.state.messages.map(msg => {
if (msg.id === messageId) {
return retryMessage;
}
return msg;
})
});
const timeoutId = setTimeout(() => {
this.handleMessageTimeout(tempId);
}, 10000);
this.pendingMessages.set(tempId, { timeoutId, message: retryMessage });
try {
await this.sendMessage(content, replyToId, files || []);
// Note: Success will be handled by WebSocket confirmation
} catch (error) {
console.error("Failed to retry message:", error);
// 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 => {
if (msg.runtimeData?.sendingState?.tempId === tempId) {
return {
...msg,
runtimeData: {
...msg.runtimeData,
sendingState: {
...msg.runtimeData.sendingState,
status: 'failed'
}
}
};
}
return msg;
})
});
}
}
handleMessageConfirmed(tempId: string, confirmedMessage: Message): void {
const pending = this.pendingMessages.get(tempId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingMessages.delete(tempId);
// Replace temporary message with confirmed one
this.updateState({
messages: this.state.messages.map(msg => {
if (msg.runtimeData?.sendingState?.tempId === tempId) {
return {
...confirmedMessage,
// Preserve files from the temporary message (which have blob URLs for immediate display)
files: msg.files,
runtimeData: {
...confirmedMessage.runtimeData,
sendingState: {
status: 'sent'
}
}
};
}
return msg;
})
});
}
}
protected deleteMessageImmediately(messageId: number): void {
this.updateState({
messages: this.state.messages.filter(msg => msg.id !== messageId)
});
}
destroy(): void {
// Clear all pending timeouts
this.pendingMessages.forEach(({ timeoutId }) => {
clearTimeout(timeoutId);
});
this.pendingMessages.clear();
}
// ========== 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;
// Create temporary message for immediate display
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const tempMessage: Message = {
id: -1, // Temporary negative ID
user_id: this.currentUser.currentUser?.id ?? -1,
username: this.currentUser.currentUser?.username ?? "You",
content: content.trim(),
is_read: false,
is_edited: false,
timestamp: new Date().toISOString(),
files: files.map(file => ({
name: file.name,
path: URL.createObjectURL(file),
encrypted: false
})),
runtimeData: {
sendingState: {
status: 'sending',
tempId,
retryData: {
content: content.trim(),
replyToId,
files: [...files]
}
}
}
};
// Add reply reference if present
if (replyToId) {
const referencedMessage = this.getMessages().find(m => m.id === replyToId);
if (referencedMessage) {
tempMessage.reply_to = referencedMessage;
}
}
// Add message immediately
this.addMessage(tempMessage);
// Set up timeout for failure
const timeoutId = setTimeout(() => {
this.handleMessageTimeout(tempId);
}, 10000); // 10 seconds timeout
// Store pending message
this.pendingMessages.set(tempId, { timeoutId, message: tempMessage });
// Actually send the message
try {
await this.sendMessage(content, replyToId, files);
// Message sent successfully - will be updated when WebSocket confirms
} catch (error) {
console.error("Failed to send message:", error);
// Remove the temporary message from display
this.updateState({
messages: this.state.messages.filter(msg =>
msg.runtimeData?.sendingState?.tempId !== tempId
)
});
this.pendingMessages.delete(tempId);
clearTimeout(timeoutId);
// Check if error has HTTP status code
const httpError = error as HttpError;
const httpStatus = httpError.status;
const errorMessage = error instanceof Error ? error.message : String(error);
console.log("Error details:", { httpStatus, errorMessage, error });
// Check for profanity error: HTTP 422 status (Unprocessable Entity)
// Also check error message as fallback for WebSocket errors
if (httpStatus === 422 || errorMessage.includes("inappropriate content")) {
console.log("Showing profanity error dialog");
void alert("Your message contains inappropriate content and cannot be sent.");
} else {
console.log("Error does not match profanity condition:", { httpStatus, errorMessage });
}
}
}
// Handle message timeout (10 seconds)
private handleMessageTimeout(tempId: string): void {
this.updateMessageToFailed(tempId);
}
// Helper method to update message to failed state
private updateMessageToFailed(tempId: string): void {
const pending = this.pendingMessages.get(tempId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingMessages.delete(tempId);
// Update message to failed state
this.updateState({
messages: this.state.messages.map(msg => {
if (msg.runtimeData?.sendingState?.tempId === tempId) {
return {
...msg,
runtimeData: {
...msg.runtimeData,
sendingState: {
...msg.runtimeData.sendingState,
status: 'failed'
}
}
};
}
return msg;
})
});
}
}
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
abstract handleDeleteMessage(messageId: number): Promise<void>;
}
@@ -0,0 +1,202 @@
import { MessagePanel } from "./MessagePanel";
import { request } from "@/core/websocket";
import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types";
import api from "@/core/api";
export class PublicChatPanel extends MessagePanel {
private messagesLoaded: boolean = false;
constructor(
chatName: string,
currentUser: UserState
) {
super(`public-${chatName}`, currentUser);
this.updateState({
title: chatName,
online: true // Public chats are always "online"
});
}
isDm(): boolean {
return false;
}
async activate(): Promise<void> {
// Don't load messages immediately during activation to prevent animation freeze
// Messages will be loaded after the animation completes
}
deactivate(): void {
// Public chat doesn't need special cleanup
}
clearMessages(): void {
super.clearMessages();
this.messagesLoaded = false;
}
async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || this.messagesLoaded) return;
this.setLoading(true);
try {
const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.general.fetchMessages(this.currentUser.authToken, limit);
if (messages && messages.length > 0) {
this.clearMessages();
messages.forEach((msg: Message) => {
this.addMessage(msg);
});
}
this.setHasMoreMessages(has_more);
this.messagesLoaded = true;
} catch (error) {
console.error("Error loading public chat messages:", error);
} finally {
this.setLoading(false);
}
}
async loadMoreMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.state.hasMoreMessages || this.state.isLoadingMore) return;
const messages = this.getMessages();
if (messages.length === 0) return;
const oldestMessage = messages[0];
this.setLoadingMore(true);
try {
const limit = this.calculateMessageLimit();
const { messages: newMessages, has_more } = await api.chats.general.fetchMessages(
this.currentUser.authToken,
limit,
oldestMessage.id
);
if (newMessages && newMessages.length > 0) {
// Prepend older messages (they come in reverse chronological order)
this.updateState({
messages: [...newMessages.reverse(), ...messages]
});
}
this.setHasMoreMessages(has_more);
} catch (error) {
console.error("Error loading more public chat messages:", error);
} finally {
this.setLoadingMore(false);
}
}
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || (!content.trim() && files.length === 0)) return;
if (files.length === 0) {
await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken);
} else {
await api.chats.general.sendWithFiles(content, replyToId ?? null, files, this.currentUser.authToken);
}
}
// Handle incoming WebSocket messages
async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise<void> {
switch (response.type) {
case 'messageEdited':
if (response.data) {
this.updateMessage(response.data.id, response.data);
}
break;
case 'messageDeleted':
if (response.data && response.data.message_id) {
this.removeMessage(response.data.message_id);
}
break;
case 'newMessage':
if (response.data) {
const newMsg = response.data;
// Check if this is a confirmation of a message we sent
const isOurMessage = newMsg.user_id === this.currentUser.currentUser?.id;
if (isOurMessage) {
// This is our message being confirmed, find the temp message and replace it
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
for (const tempMsg of tempMessages) {
if (tempMsg.runtimeData?.sendingState?.retryData?.content === newMsg.content) {
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, newMsg);
return;
}
}
}
this.addMessage(newMsg);
}
break;
case 'reactionUpdate':
if (response.data) {
this.updateMessageReactions(response.data.message_id, response.data.reactions);
}
break;
}
};
// Reset for chat switching
reset(): void {
this.messagesLoaded = false;
this.clearMessages();
}
// Update chat name
setChatName(chatName: string): void {
this.updateState({
id: `public-${chatName}`,
title: chatName
});
}
// Update auth token
setAuthToken(authToken: string): void {
this.currentUser.authToken = authToken;
}
async handleEditMessage(messageId: number, content: string): Promise<void> {
if (!this.currentUser.authToken) return;
try {
await request({
type: "editMessage",
data: {
message_id: messageId,
content: content
},
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken
}
});
} catch (error) {
console.error("Failed to edit message:", error);
}
}
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!
}
});
}
async getProfile(): Promise<ProfileDialogData | null> {
return {
username: "general",
display_name: "Общий чат",
bio: "Общаемся со всеми пользователями FromChat!",
isOwnProfile: false
};
}
}
@@ -0,0 +1,35 @@
import { MaterialIcon } from "@/utils/material";
import { Link } from "react-router-dom";
import styles from "./download-app.module.scss";
export default function DownloadAppPage() {
return (
<div className={styles.downloadAppScreen}>
<div className={styles.downloadAppCard}>
<h1>Скачайте приложение</h1>
<p>
Этот сайт не предназначен для работы на маленьких экранах.
Выберите вашу платформу:
</p>
<div className={styles.downloadAppButtons}>
<a href="/download?os=android" className={styles.downloadAppBtn}>
<MaterialIcon name="android" />
Android
</a>
<a href="/download?os=ios" className={styles.downloadAppBtn}>
<MaterialIcon name="phone_iphone" />
iOS
</a>
</div>
<p>
<Link to="/privacy">Политика конфиденциальности</Link>
{" · "}
<Link to="/terms">Пользовательское соглашение</Link>
</p>
<p>
<a href="https://t.me/denis0001-dev">Написать в поддержку</a>
</p>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
@use "../../css/material" as *;
.downloadAppScreen {
display: flex;
justify-content: center;
align-items: center;
min-width: 100vw;
min-height: 100vh;
padding: 24px;
background-color: $color-dark-surface;
}
.downloadAppCard {
max-width: 400px;
padding: 40px 32px;
background: rgba($color-dark-surface-container, 0.6);
border: 1px solid rgba($color-dark-outline, 0.3);
border-radius: 24px;
text-align: center;
h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 16px;
color: $color-dark-on-surface;
}
p {
color: $color-dark-on-surface-variant;
margin: 0 0 24px;
font-size: 16px;
a {
color: $color-dark-primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
.downloadAppButtons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 24px;
}
.downloadAppBtn {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
background: rgba($color-dark-primary, 0.2);
border: 1px solid rgba($color-dark-primary, 0.5);
border-radius: 12px;
color: $color-dark-on-surface;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
&:hover {
background: rgba($color-dark-primary, 0.3);
border-color: $color-dark-primary;
}
}
+112
View File
@@ -0,0 +1,112 @@
import type { ReactNode } from "react";
import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialButton, MaterialIcon } from "@/utils/material";
import { OS_CONFIG, type DownloadOs } from "@/pages/home/os";
import styles from "@/pages/home/download-dialog.module.scss";
interface DownloadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
os: DownloadOs;
}
function AndroidInstructions(): ReactNode {
return (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Установка на Android</h3>
<p className={styles.text}>
Вы скачали APK-файл FromChat. Чтобы установить приложение:
</p>
<ul className={styles.list}>
<li>Откройте загруженный APK-файл из шторки уведомлений или файлового менеджера.</li>
<li>
Если появится запрос &quot;Разрешить установку из неизвестных источников&quot; дайте
разрешение для браузера, из которого вы скачивали APK.
</li>
<li>
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
нажмите &quot;Подробнее&quot; &quot;Всё равно установить&quot; (или аналогичную кнопку).
</li>
<li>Дождитесь завершения установки и откройте FromChat из списка приложений.</li>
</ul>
</div>
);
}
function IosInstructions(): ReactNode {
return (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Установка на iOS</h3>
<p className={styles.text}>
Эта сборка не распространяется через App Store или TestFlight. Чтобы установить FromChat на iPhone
или iPad, потребуется один из вариантов сторонней установки:
</p>
<ul className={styles.list}>
<li>
<strong>TrollStore</strong>: постоянная установка приложений из IPA-файлов. Требуется поддерживаемая
версия iOS и настройка TrollStore на устройстве.
</li>
<li>
<strong>Джейлбрейк</strong>: установка через менеджер пакетов (Sileo, Cydia и т.п.) или напрямую
из файлового менеджера, если у вас уже есть джейлбрейк.
</li>
<li>
<strong>Другие сервисы сайдлоада</strong>: сторонние инструменты, которые подписывают IPA-файл
вашим сертификатом разработчика или временным сертификатом.
</li>
</ul>
<p className={styles.text}>
К сожалению, простого и официально поддерживаемого пути установки для iOS здесь нет именно поэтому я
бы сам iPhone не покупал 😄
</p>
</div>
);
}
function renderInstructions(os: DownloadOs): ReactNode {
if (os === "android") {
return <AndroidInstructions />;
}
if (os === "ios") {
return <IosInstructions />;
}
return null;
}
export function DownloadDialog({ open, onOpenChange, os }: DownloadDialogProps) {
const osInfo = OS_CONFIG[os];
return (
<StyledDialog
open={open}
onOpenChange={onOpenChange}
className={styles.downloadDialog}
contentClassName={styles.downloadDialogContent}
afterChildren={
<div className={styles.actions}>
<MaterialButton variant="filled" onClick={() => onOpenChange(false)}>
Закрыть
</MaterialButton>
</div>
}
>
<div className={styles.body}>
<div className={styles.header}>
<div className={styles.iconWrapper}>
<MaterialIcon name="download" className={styles.icon} />
</div>
<div className={styles.titleBlock}>
<h2 className={styles.title}>Спасибо за скачивание!</h2>
<p className={styles.subtitle}>
FromChat для&nbsp;
<span className={styles.osName}>{osInfo.label}</span>
</p>
</div>
</div>
{renderInstructions(os)}
</div>
</StyledDialog>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { Link } from "react-router-dom";
import { MaterialIcon } from "@/utils/material";
import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "@/pages/home/homeLinks";
import styles from "@/pages/home/home-footer.module.scss";
interface HomeFooterProps {
onScrollToDownload?: () => void;
}
export function HomeFooter({ onScrollToDownload }: HomeFooterProps) {
return (
<footer className={styles.homepageFooter}>
<div className={styles.footerBrand}>
<div className={styles.footerLogoRow}>
<div className={styles.footerLogo} />
<span className={styles.footerBrandName}>FromChat</span>
</div>
<p className={styles.footerCopyright}>FromChat © 2026</p>
</div>
<div className={styles.footerLinks}>
<div className={styles.footerSection}>
<button
type="button"
onClick={onScrollToDownload}
className={styles.footerLink}
>
<MaterialIcon name="download" className={styles.footerLinkIcon} />
Скачать приложение
</button>
<Link to="/login" className={styles.footerLink}>
<MaterialIcon name="language" className={styles.footerLinkIcon} />
Веб-версия
</Link>
<a
href={`${GITHUB_WEB}/actions/workflows/build.yml`}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="computer" className={styles.footerLinkIcon} />
ПК-клиент
</a>
</div>
<div className={styles.footerSection}>
<a
href={`${GITHUB_APP}/tree/main`}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="android" className={styles.footerLinkIcon} />
Исходный код приложения
</a>
<GitHubLink className={styles.footerLink}>
<MaterialIcon name="code" className={styles.footerLinkIcon} />
Исходный код веб-версии
</GitHubLink>
<a
href={GITHUB_LICENSE}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="description" className={styles.footerLinkIcon} />
Лицензия
</a>
</div>
<div className={styles.footerSection}>
<Link to="/privacy" className={styles.footerLink}>
<MaterialIcon name="shield" className={styles.footerLinkIcon} />
Политика конфиденциальности
</Link>
<Link to="/terms" className={styles.footerLink}>
<MaterialIcon name="description" className={styles.footerLinkIcon} />
Пользовательское соглашение
</Link>
</div>
<div className={styles.footerSection}>
<a
href="https://t.me/fromchat_ch"
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<span
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgTelegram}`}
/>
Telegram
</a>
<a
href="https://max.ru/join/c5t6LfnCCPetQSAOshmouEvq9vsjHZT_Lt63kw8YCg0"
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<span
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgMax}`}
/>
MAX
</a>
</div>
</div>
</footer>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import { GitHubLink, SupportLink } from "@/pages/home/homeLinks";
import styles from "@/pages/home/home-header.module.scss";
interface HomeHeaderProps {
onScrollToDownload?: () => void;
}
export function HomeHeader({ onScrollToDownload }: HomeHeaderProps) {
const navigate = useNavigate();
const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen();
const isLoggedIn = user.authToken && user.currentUser;
const handleMobileDownload = () => {
onScrollToDownload?.();
};
function handleGetStarted() {
if (isMobile) {
handleMobileDownload();
} else if (isLoggedIn) {
navigate("/chat");
} else {
navigate("/login");
}
}
const openBtn = (
<MaterialButton
variant="filled"
onClick={handleGetStarted}
icon={
isMobile ? "download" : isLoggedIn ? "open_in_new" : "login"
}
className={styles.headerDownloadButton}
>
{isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
</MaterialButton>
);
return (
<header className={styles.homepageHeader} data-home-header>
<div className={styles.headerInner}>
<div className={styles.headerContent}>
<div className={styles.logo}>
<div className={styles.logoIcon} />
<h1>FromChat</h1>
</div>
<div className={styles.headerCenterLinks}>
<GitHubLink>
<MaterialButton variant="text" icon="code">GitHub</MaterialButton>
</GitHubLink>
<SupportLink>
<MaterialButton variant="text" icon="support">Поддержка</MaterialButton>
</SupportLink>
</div>
<div className={styles.headerButton}>
{openBtn}
{isMobile ? (
<MaterialIconButton
variant="filled"
onClick={handleMobileDownload}
icon="download"
className={styles.headerSmallButton}
/>
) : null}
</div>
</div>
</div>
</header>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useNavigate } from "react-router-dom";
import { useRef, useState, type ReactNode } from "react";
import styles from "@/pages/home/home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
import generalChatScreenshot from "@/images/screenshots/general-chat.png";
import dmScreenshot from "@/images/screenshots/dm.png";
import windowsIcon from "@/images/windows.svg";
import linuxIcon from "@/images/linux.svg";
import macIcon from "@/images/mac.svg";
import { HomeHeader } from "@/pages/home/HomeHeader";
import { HomeFooter } from "@/pages/home/HomeFooter";
import { SplitButton } from "@/core/components/SplitButton";
import { DownloadDialog } from "@/pages/home/DownloadDialog";
import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/pages/home/os";
import { API_BASE_URL } from "@/core/config";
interface FeatureSectionProps {
title: ReactNode;
children: ReactNode;
screenshot: string;
right?: boolean;
}
function FeatureSection({
title,
children,
screenshot,
right = false,
}: FeatureSectionProps) {
const featureText = (
<div className={styles.featureText}>
<div className={styles.featureTitle}>{title}</div>
<div className={styles.featureDesc}>{children}</div>
</div>
);
const featureScreenshot = (
<div className={styles.featureScreenshotOuter}>
<div className={styles.featureScreenshotGlow} />
<img src={screenshot} className={styles.featureScreenshot} draggable={false} />
</div>
)
return (
<div className={`${styles.featureContainer}`}>
{right ? <>{featureText}{featureScreenshot}</> : <>{featureScreenshot}{featureText}</>}
</div>
)
}
export default function HomePage() {
const navigate = useNavigate();
const { isMobile } = useDownloadAppScreen();
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs());
const [menuOpen, setMenuOpen] = useState(false);
const downloadSectionRef = useRef<HTMLElement>(null);
const scrollToDownload = () => {
const section = downloadSectionRef.current;
const header = document.querySelector<HTMLElement>("[data-home-header]");
if (!section) return;
const headerHeight = header?.getBoundingClientRect().height ?? 0;
const targetY = section.getBoundingClientRect().top + window.scrollY - headerHeight;
window.scrollTo({ top: targetY, behavior: "smooth" });
};
const triggerDownload = (os: DownloadOs): boolean => {
if (typeof document === "undefined") {
return false;
}
setDialogOs(os);
setDialogOpen(true);
const link = document.createElement("a");
link.href = `${API_BASE_URL}/download/${os}`;
link.download = "";
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return true;
};
const getButtonVariant = (os: DownloadOs): "filled" | "tonal" | "outlined" => {
const detectedOs = detectOs();
if (os === detectedOs) return "filled";
if (!isMobile && ["windows", "linux", "macos"].includes(os)) return "tonal";
if (isMobile && (os === "android" || os === "ios") && os !== detectedOs) return "tonal";
return "outlined";
};
return (
<div className={styles.homepage}>
<HomeHeader onScrollToDownload={scrollToDownload} />
<main>
<section className={styles.title}>
<div className={styles.titleLogoWrapper}>
<div className={styles.titleLogo} />
</div>
<div className={styles.titleContent}>FromChat</div>
<div className={styles.titleDesc}>
100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
</div>
<div className={styles.titleButtons}>
{isMobile ? null : (
<MaterialButton
variant="filled"
onClick={() => navigate("/auth?mode=login")}
icon="devices"
>
Открыть веб-версию
</MaterialButton>
)}
<SplitButton
variant={isMobile ? "filled" : "tonal"}
text="Скачать приложение"
icon="download"
onPrimaryClick={() => triggerDownload(detectOs())}
menuOpen={menuOpen}
onMenuOpen={setMenuOpen}
menu={(
<MaterialList>
{ALL_OS.map((os) => (
<MaterialListItem
key={os}
icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon}
headline={OS_CONFIG[os].label}
rounded
onClick={() => {
if (triggerDownload(os)) setMenuOpen(false);
}}
>
{["windows", "linux", "macos"].includes(os) && (
<span
slot="icon"
className={styles.menuCustomIcon}
style={{
"--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
} as React.CSSProperties}
/>
)}
</MaterialListItem>
))}
</MaterialList>
)}
/>
</div>
</section>
<section className={styles.features}>
<FeatureSection
title={<>Общий чат</>}
screenshot={generalChatScreenshot}
right
>
Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени.
</FeatureSection>
<FeatureSection
title={<>Личные сообщения</>}
screenshot={dmScreenshot}>
Общайтесь с одним человеком в личной переписке.
</FeatureSection>
</section>
<section ref={downloadSectionRef} className={styles.download}>
<div className={styles.container}>
<div className={styles.downloadContent}>
<h3>Скачайте приложение</h3>
<p>
Настольное приложение с уведомлениями и автономной работой
или мобильное приложение для Android и iOS.
</p>
<table className={styles.downloadTable}>
<thead>
<tr>
<th>Платформа</th>
<th>Описание</th>
<th />
</tr>
</thead>
<tbody>
{ALL_OS.map((os) => (
<tr key={os}>
<td className={styles.downloadTableOs}>
<span className={styles.downloadTableOsContent}>
{["windows", "linux", "macos"].includes(os) ? (
<span
className={styles.tableOsIcon}
style={{
"--table-os-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
} as React.CSSProperties}
/>
) : (
<MaterialIcon
name={OS_CONFIG[os].icon}
className={styles.downloadTableIcon}
/>
)}
<span>{OS_CONFIG[os].label}</span>
</span>
</td>
<td className={styles.downloadTableDesc}>
<span className={styles.downloadTableDescInner}>
{OS_CONFIG[os].description}
</span>
</td>
<td className={styles.downloadTableAction}>
<span className={styles.downloadTableActionInner}>
<MaterialButton
variant={getButtonVariant(os)}
icon="download"
className={styles.downloadButton}
onClick={() => triggerDownload(os)}
>
Скачать
</MaterialButton>
<MaterialIconButton
variant={getButtonVariant(os)}
icon="download"
className={styles.downloadButtonIcon}
onClick={() => triggerDownload(os)}
title={`Скачать ${OS_CONFIG[os].label}`}
/>
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
<section className={styles.cta}>
<div className={styles.container}>
<div className={styles.ctaContent}>
<h3>Готовы начать общение?</h3>
<p>
Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку
или звоните всё бесплатно и с открытым кодом.
</p>
<div className={styles.ctaActions}>
{isMobile ? (
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
Скачать приложение
</MaterialButton>
) : (
<>
<MaterialButton
variant="filled"
onClick={() => navigate("/register")}>
Создать аккаунт
</MaterialButton>
<MaterialButton
variant="outlined"
onClick={() => navigate("/login")}>
Войти
</MaterialButton>
</>
)}
</div>
</div>
</div>
</section>
</main>
<DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} />
<HomeFooter onScrollToDownload={scrollToDownload} />
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
@use "@/css/material" as *;
$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
$glow-purple: rgba(147, 51, 234, 0.5);
@mixin gradient-text {
background: $gradient-rainbow;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 20px $glow-purple;
}
@@ -0,0 +1,113 @@
@use "@/css/material" as *;
@use "@/css/colors" as *;
@use "sass:color";
.downloadDialog {
color: $color-dark-on-surface;
}
.downloadDialogContent {
padding: 24px 24px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: flex-start;
gap: 16px;
}
.iconWrapper {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background-color: $color-dark-primary-container;
color: $color-dark-on-primary-container;
}
.icon {
font-size: 22px;
}
.titleBlock {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.subtitle {
margin: 0;
font-size: 14px;
color: $color-dark-on-surface-variant;
}
.osName {
font-weight: 600;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.sectionTitle {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.text {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: $color-dark-on-surface-variant;
}
.list {
margin: 0;
padding-left: 20px;
font-size: 14px;
line-height: 1.6;
color: $color-dark-on-surface-variant;
li + li {
margin-top: 4px;
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 20px;
border-top: 1px solid rgba($color-dark-outline-variant, 0.4);
background-color: color.mix($color-dark-surface-container, $color-dark-surface-container-low, 60%);
}
@media (max-width: 480px) {
.downloadDialogContent {
padding: 20px 16px 12px;
}
.actions {
padding-inline: 16px;
}
}
+157
View File
@@ -0,0 +1,157 @@
@use "@/css/material" as *;
@use "home-shared" as shared;
.homepageFooter {
padding: 0 16px;
background: $color-dark-surface;
display: flex;
flex-direction: row;
gap: 32px;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
align-items: center;
justify-content: center;
padding-bottom: 48px;
@media (max-width: 635px) {
flex-direction: column-reverse;
align-items: flex-start;
padding: 32px;
}
}
.footerBrand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.footerLogoRow {
display: flex;
align-items: center;
gap: 10px;
}
.footerLogo {
width: 40px;
height: 40px;
background-image: url('@/images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
}
.footerBrandName {
font-size: 32px;
font-weight: 700;
@include shared.gradient-text;
user-select: none;
}
.footerCopyright {
font-size: 14px;
color: $color-dark-on-surface-variant;
opacity: 0.8;
margin: 0;
}
.footerLinks {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.footerSection {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.footerLink {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: $color-dark-on-surface-variant;
text-decoration: none;
font-size: 15px;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
transition: color 0.2s ease, transform 0.15s ease;
-webkit-user-drag: none;
user-select: none;
&:hover {
color: $color-dark-on-surface;
}
}
button.footerLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
}
button.footerLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
}
button.footerLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
&:is(button) {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
}
&:active {
transform: scale(0.92);
}
}
.footerLinkIcon {
width: 24px;
height: 24px;
min-width: 24px;
opacity: 0.9;
}
.footerLinkIconSvg {
display: inline-block;
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.footerLinkIconSvgTelegram {
mask-image: url('@/images/telegram.svg');
-webkit-mask-image: url('@/images/telegram.svg');
}
.footerLinkIconSvgMax {
mask-image: url('@/images/max.svg');
-webkit-mask-image: url('@/images/max.svg');
}
@@ -0,0 +1,90 @@
@use "@/css/material" as *;
@use "home-shared" as shared;
.homepageHeader {
display: flex;
justify-content: center;
padding: 16px;
position: sticky;
top: 0;
z-index: 1000;
user-select: none;
}
.headerInner {
max-width: 960px;
width: 100%;
padding: 16px 24px;
background: rgba($color-dark-surface, 0.8);
backdrop-filter: blur(10px);
border-radius: 30px;
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logoIcon {
width: 40px;
height: 40px;
background-image: url('@/images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
}
.logo h1 {
font-size: 32px;
font-weight: 700;
margin: 0;
@include shared.gradient-text;
}
.headerCenterLinks {
display: flex;
align-items: center;
gap: 10px;
@media (max-width: 730px) {
display: none;
}
a {
display: flex;
align-items: center;
gap: 10px;
}
}
.headerButton {
display: flex;
align-items: center;
justify-content: center;
}
.headerDownloadButton {
display: block;
}
.headerSmallButton {
display: none;
}
@media (max-width: 450px) {
.headerSmallButton {
display: block;
}
.headerDownloadButton {
display: none;
}
}
+502
View File
@@ -0,0 +1,502 @@
@use "@/css/material" as *;
@use "home-shared" as shared;
// Shared variables (home-specific)
$gradient-conic: conic-gradient(
from 0deg,
rgba(147, 51, 234, 0.5) 0%,
rgba(99, 102, 241, 0.6) 12.5%,
rgba(59, 130, 246, 0.55) 25%,
rgba(168, 85, 247, 0.5) 37.5%,
rgba(217, 70, 239, 0.6) 50%,
rgba(236, 72, 153, 0.55) 62.5%,
rgba(192, 132, 252, 0.5) 75%,
rgba(126, 34, 206, 0.6) 87.5%,
rgba(147, 51, 234, 0.5) 100%
);
$radius-card: 20px;
$radius-pill: 25px;
// Download table variables
$download-cell-padding: 14px 20px;
$download-cell-gap: 12px;
$download-icon-size: 24px;
$download-col1-w: 35%;
$download-col2-w: 30%;
$download-col3-w: 35%;
$download-row-bg-hover: rgba($color-dark-surface-container-high, 0.6);
$download-header-bg: rgba($color-dark-surface-container-high, 0.4);
$download-text-color: $color-dark-on-surface-variant;
// Mixins
@mixin section-heading {
font-size: 40px;
font-weight: 700;
margin-bottom: 24px;
@include shared.gradient-text;
}
@mixin section-content {
text-align: center;
max-width: 600px;
margin: 0 auto;
p {
font-size: 20px;
line-height: 1.6;
margin-bottom: 40px;
opacity: 0.9;
}
}
@mixin container {
max-width: 960px;
margin: 0 auto;
padding: 0 32px;
}
@mixin download-cell-inner($justify) {
display: flex;
justify-content: $justify;
align-items: center;
width: 100%;
min-width: 0;
}
@mixin masked-icon($url-var) {
display: inline-block;
width: $download-icon-size;
height: $download-icon-size;
flex-shrink: 0;
background-color: $download-text-color;
mask-image: var(#{$url-var});
-webkit-mask-image: var(#{$url-var});
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.homepage {
min-height: 100vh;
display: flex;
flex-direction: column;
color: $color-dark-on-background;
font-family: 'Montserrat', sans-serif;
position: relative;
background-color: $color-dark-surface;
-webkit-tap-highlight-color: transparent;
-webkit-user-drag: none;
img {
-webkit-user-drag: none;
user-select: none;
}
main {
flex: 1;
.title {
padding: 60px 0;
display: flex;
align-items: center;
margin-top: 0;
flex-direction: column;
.titleLogoWrapper {
position: relative;
width: 100%;
overflow-x: clip;
display: flex;
justify-content: center;
&::before {
$size: 1200px;
content: '';
background: $gradient-conic;
position: absolute;
width: $size;
height: $size;
bottom: 0;
opacity: 0.3;
left: calc(50% - ($size / 2));
border-radius: 50%;
filter: blur(80px);
}
}
.titleLogo {
width: 120px;
height: 120px;
background-image: url('@/images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 30px;
position: relative;
z-index: 1;
}
.titleContent {
font-size: 30pt;
font-weight: 700;
margin-top: 10px;
padding: 0 80px;
@include shared.gradient-text;
}
.titleDesc {
font-size: 16pt;
opacity: 0.8;
margin-top: 10px;
text-align: center;
padding: 0 80px;
}
.titleButtons {
display: flex;
gap: 10px;
margin-top: 20px;
padding: 0 80px;
}
}
.features {
display: flex;
flex-direction: column;
gap: 50px;
overflow-x: clip;
.featureContainer {
display: flex;
flex-direction: row;
gap: 20px;
padding: 0 16px;
align-items: center;
margin-left: auto;
margin-right: auto;
max-width: 700px;
.featureText {
display: flex;
flex-direction: column;
gap: 10px;
flex-grow: 1;
flex-shrink: 1;
.featureTitle {
font-size: 30pt;
font-weight: 700;
line-height: normal;
@include shared.gradient-text;
}
.featureDesc {
text-align: left;
font-size: 15pt;
}
}
.featureScreenshotOuter {
position: relative;
.featureScreenshotGlow {
$size: 400px;
@keyframes rotateGradient {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
background: $gradient-conic;
position: absolute;
width: $size;
height: $size;
top: calc(50% - ($size / 2));
opacity: 0.6;
left: calc(50% - ($size / 2));
border-radius: 50%;
filter: blur(80px);
z-index: 0;
will-change: transform;
animation: rotateGradient 8s linear infinite;
}
.featureScreenshot {
max-width: 300px;
z-index: 50;
position: relative;
}
}
}
}
.download {
.container {
@include container;
}
.downloadContent {
@include section-content;
h3 {
@include section-heading;
}
.downloadTable {
display: table;
user-select: none;
width: 100%;
max-width: 720px;
margin: 0 auto;
border-radius: 16px;
border-collapse: separate;
border-spacing: 0 6px;
background: transparent;
border: none;
table-layout: auto;
th:first-child,
td:first-child {
width: 1%;
white-space: nowrap;
}
th:nth-child(2),
td:nth-child(2) {
width: 1%;
white-space: nowrap;
}
th:last-child,
td:last-child {
width: 100%;
}
thead {
display: table-header-group;
tr th {
padding: $download-cell-padding;
display: table-cell;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $download-text-color;
background: $download-header-bg;
vertical-align: middle;
transition: background-color 0.2s ease;
text-align: left;
&:first-child {
border-radius: 12px 0 0 12px;
}
&:last-child {
border-radius: 0 12px 12px 0;
}
}
}
tbody {
display: table-row-group;
tr {
display: table-row;
background: $color-dark-surface-container-low;
td {
padding: $download-cell-padding;
display: table-cell;
text-align: center;
vertical-align: middle;
transition: background-color 0.2s ease;
background: transparent;
&:first-child {
text-align: left;
border-radius: $radius-pill 0 0 $radius-pill;
}
&:last-child {
text-align: right;
border-radius: 0 $radius-pill $radius-pill 0;
}
}
&:hover td {
background: $download-row-bg-hover;
}
}
}
.downloadTableOs {
text-align: left;
.downloadTableOsContent {
@include download-cell-inner(flex-start);
gap: $download-cell-gap;
min-height: 100%;
height: 100%;
}
}
.downloadTableIcon {
font-size: $download-icon-size;
width: $download-icon-size;
height: $download-icon-size;
color: $download-text-color;
}
.tableOsIcon {
@include masked-icon("--table-os-icon-url");
}
.downloadTableDesc {
font-size: 14px;
color: $download-text-color;
.downloadTableDescInner {
@include download-cell-inner(flex-start);
}
}
.downloadTableAction {
text-align: right;
.downloadTableActionInner {
@include download-cell-inner(flex-end);
}
}
.downloadButton {
display: block;
}
.downloadButtonIcon {
display: none;
}
}
}
@media (max-width: 490px) {
.downloadContent .downloadTable {
.downloadButton {
display: none;
}
.downloadButtonIcon {
display: block;
}
}
}
@media (max-width: 405px) {
.downloadContent .downloadTable {
th:nth-child(2),
td:nth-child(2) {
display: none !important;
}
.downloadButton {
display: block !important;
}
.downloadButtonIcon {
display: none !important;
}
}
}
}
.cta {
padding-bottom: 128px;
.container {
@include container;
}
.ctaContent {
@include section-content;
h3 {
@include section-heading;
}
.ctaActions {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
}
}
}
@media (max-width: 635px) {
main {
.title {
.titleContent,
.titleDesc,
.titleButtons {
padding: 0 16px;
}
}
.features {
padding: 0 16px;
.featureContainer {
flex-direction: column;
align-items: center;
padding: 0 16px;
.featureText {
order: 1;
text-align: center;
.featureDesc {
text-align: center;
}
}
.featureScreenshotOuter {
order: 2;
}
}
}
.download .container,
.cta .container {
padding: 0 16px;
}
}
}
}
.menuCustomIcon {
@include masked-icon("--menu-custom-icon-url");
}
// Keyframes for animations
@keyframes neonGlow {
0% {
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5), 0 0 40px rgba($color-dark-primary, 0.3);
}
100% {
text-shadow: 0 0 30px rgba($color-dark-primary, 0.8), 0 0 60px rgba($color-dark-primary, 0.5);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
+43
View File
@@ -0,0 +1,43 @@
const GITHUB_WEB = "https://github.com/fromchat-messenger/web";
const GITHUB_APP = "https://github.com/fromchat-messenger/app";
export const GITHUB_LICENSE = `${GITHUB_WEB}/blob/main/LICENSE`;
export function GitHubLink({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<a
href={`${GITHUB_WEB}/tree/main`}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
export function SupportLink({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<a
href="https://t.me/denis0001-dev"
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
export { GITHUB_WEB, GITHUB_APP };
+56
View File
@@ -0,0 +1,56 @@
export type DownloadOs = "windows" | "linux" | "macos" | "android" | "ios";
export const ALL_OS: DownloadOs[] = [
"windows",
"linux",
"macos",
"android",
"ios",
] as const;
export interface OsInfo {
id: DownloadOs;
label: string;
description: string;
icon: string;
}
export const OS_CONFIG: Record<DownloadOs, OsInfo> = {
windows: { id: "windows", label: "Windows", description: "ПК", icon: "computer" },
linux: { id: "linux", label: "Linux", description: "ПК", icon: "computer" },
macos: { id: "macos", label: "macOS", description: "Apple", icon: "computer" },
android: { id: "android", label: "Android", description: "APK", icon: "android" },
ios: { id: "ios", label: "iOS", description: "iPhone, iPad", icon: "phone_iphone" },
};
export function detectOs(): DownloadOs {
if (typeof navigator === "undefined") {
return "android";
}
const ua = (navigator.userAgent || navigator.platform || "").toLowerCase();
const platform = (navigator as any).userAgentData?.platform?.toLowerCase?.() ?? "";
const haystack = `${ua} ${platform}`;
if (haystack.includes("android")) {
return "android";
}
if (haystack.includes("iphone") || haystack.includes("ipad") || haystack.includes("ipod")) {
return "ios";
}
if (haystack.includes("win")) {
return "windows";
}
if (haystack.includes("mac")) {
return "macos";
}
if (haystack.includes("linux")) {
return "linux";
}
return "android";
}
+6
View File
@@ -0,0 +1,6 @@
import { LegalMarkdownPage } from "@/core/legal/LegalMarkdownPage";
const PrivacyPage = () => <LegalMarkdownPage kind="privacy" />;
const TermsPage = () => <LegalMarkdownPage kind="terms" />;
export { PrivacyPage, TermsPage };
+38
View File
@@ -0,0 +1,38 @@
import { useNavigate } from "react-router-dom";
import styles from "./not-found.module.scss";
import { MaterialButton, MaterialIcon } from "@/utils/material";
export default function NotFoundPage() {
const navigate = useNavigate();
return (
<div className={styles.notFoundPage}>
<div className={styles.notFoundContainer}>
<div className={styles.notFoundContent}>
<div className={styles.errorCode}>404</div>
<h1>Страница не найдена</h1>
<p>
К сожалению, запрашиваемая страница не существует или была перемещена.
</p>
<div className={styles.notFoundActions}>
<MaterialButton
variant="filled"
onClick={() => navigate("/")}
>
На главную
</MaterialButton>
<MaterialButton
variant="outlined"
onClick={() => navigate(-1)}
>
Назад
</MaterialButton>
</div>
</div>
<div className={styles.notFoundIllustration}>
<MaterialIcon name="search_off" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
.notFoundPage {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #9333EA 0%, #6366F1 100%);
padding: 2rem;
}
.notFoundContainer {
display: flex;
align-items: center;
gap: 3rem;
max-width: 800px;
width: 100%;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 3rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.notFoundContent {
flex: 1;
h1 {
font-size: 2.5rem;
font-weight: 700;
color: #2d3748;
margin-bottom: 1rem;
line-height: 1.2;
}
p {
font-size: 1.1rem;
color: #718096;
margin-bottom: 2rem;
line-height: 1.6;
}
}
.errorCode {
font-size: 6rem;
font-weight: 900;
color: #9333EA;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.notFoundActions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.notFoundIllustration {
display: flex;
align-items: center;
justify-content: center;
color: #9333EA;
opacity: 0.7;
}
@media (max-width: 768px) {
.notFoundContainer {
flex-direction: column;
text-align: center;
gap: 2rem;
padding: 2rem;
}
.errorCode {
font-size: 4rem;
}
.notFoundContent {
h1 {
font-size: 2rem;
}
}
.notFoundActions {
justify-content: center;
}
}