mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-24 12:05:05 +03:00
Backend moved to a separate repository
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Если появится запрос "Разрешить установку из неизвестных источников" — дайте
|
||||
разрешение для браузера, из которого вы скачивали APK.
|
||||
</li>
|
||||
<li>
|
||||
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
|
||||
нажмите "Подробнее" → "Всё равно установить" (или аналогичную кнопку).
|
||||
</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 для
|
||||
<span className={styles.osName}>{osInfo.label}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderInstructions(os)}
|
||||
</div>
|
||||
</StyledDialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { LegalMarkdownPage } from "@/core/legal/LegalMarkdownPage";
|
||||
|
||||
const PrivacyPage = () => <LegalMarkdownPage kind="privacy" />;
|
||||
const TermsPage = () => <LegalMarkdownPage kind="terms" />;
|
||||
|
||||
export { PrivacyPage, TermsPage };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user