Migrate to SCSS modules

This commit is contained in:
2025-11-01 19:29:36 +03:00
Unverified
parent 4c4d809e9b
commit 0921ec875c
60 changed files with 1802 additions and 1889 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
import { LeftPanel } from "./left/LeftPanel";
import { RightPanel } from "./right/RightPanel";
import "@/pages/chat/css/chat.scss";
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 { useAppState } from "@/pages/chat/state";
import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi";
import styles from "@/pages/chat/css/layout.module.scss";
export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
@@ -69,8 +69,8 @@ export default function ChatPage() {
if (navigateDownloadApp) return navigateDownloadApp;
return (
<div id="chat-interface">
<div className="all-container">
<div className={styles.chatInterface}>
<div className={styles.allContainer}>
<LeftPanel />
<RightPanel />
</div>
+23 -22
View File
@@ -13,6 +13,7 @@ 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 styles from "@/pages/chat/css/profile-dialog.module.scss";
interface SectionProps {
type: string;
@@ -36,14 +37,14 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
text={value || ""}
onTextChange={onChange}
placeholder={placeholder}
className="value"
className={styles.value}
rows={1}
readOnly={readOnly} />
);
} else {
valueComponent = (
<input
className="value"
className={styles.value}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
@@ -51,17 +52,17 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
);
}
} else {
valueComponent = <span className="value">{value}</span>
valueComponent = <span className={styles.value}>{value}</span>
}
return (
<div className={`section ${type} ${error ? 'error' : ''}`}>
<div className={`${styles.section} ${type} ${error ? styles.error : ''}`}>
<MaterialIcon name={icon} />
<div className="content-container">
<label className="label">{label}</label>
<div className={styles.contentContainer}>
<label className={styles.label}>{label}</label>
{valueComponent}
{error && (
<div className="error-message">{error}</div>
<div className={styles.errorMessage}>{error}</div>
)}
</div>
</div>
@@ -440,20 +441,20 @@ export function ProfileDialog() {
}
}}
onBackdropClick={handleClose}
className="profile-dialog"
contentClassName={styles.profileDialogContent}
afterChildren={
currentData.isOwnProfile && (
<MaterialFab
icon="check"
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
className={`${styles.profileDialogFab} ${fabVisible ? styles.visible : ""}`}
onClick={handleSave}
disabled={isSaving} />
)
}
>
<div className="profile-picture-section">
<div className={styles.profilePictureSection}>
<img
className="profile-picture"
className={styles.profilePicture}
src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture"
onError={(e) => {
@@ -463,7 +464,7 @@ export function ProfileDialog() {
{currentData.isOwnProfile && (
<div
className="profile-picture-edit-overlay"
className={styles.profilePictureEditOverlay}
onClick={handleProfilePictureClick}
>
<MaterialIcon name="camera_alt--filled" />
@@ -471,11 +472,11 @@ export function ProfileDialog() {
)}
</div>
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
<div className="username-with-badge">
<div className={`${styles.usernameSection} ${errors.display_name ? styles.error : ''}`}>
<div className={styles.usernameWithBadge}>
<Input
autoresizing={true}
className="username-input"
className={styles.usernameInput}
type="text"
value={currentData.display_name}
onChange={handleDisplayNameChange}
@@ -488,21 +489,21 @@ export function ProfileDialog() {
size="large" />
</div>
{errors.display_name && (
<div className="error-message">{errors.display_name}</div>
<div className={styles.errorMessage}>{errors.display_name}</div>
)}
</div>
{(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && (
<div className="online-status-section">
<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 && !currentData.deleted && (
<div className="admin-actions-section">
<h3 className="admin-actions-header">Admin Actions</h3>
<div className="admin-buttons">
<div className={styles.adminActionsSection}>
<h3 className={styles.adminActionsHeader}>Admin Actions</h3>
<div className={styles.adminButtons}>
<MaterialButton
variant="filled"
color="error"
@@ -532,7 +533,7 @@ export function ProfileDialog() {
{/* Verify button for non-admin owner */}
{!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && (
<div className="verify-section">
<div className={styles.verifySection}>
<VerifyButton
userId={currentData.userId}
verified={currentData.verified || false}
@@ -545,7 +546,7 @@ export function ProfileDialog() {
{/* Hide profile sections for deleted users */}
{!currentData.deleted && (
<div className="profile-sections">
<div className={styles.profileSections}>
<Section
type="username"
error={errors.username}
@@ -1,5 +1,6 @@
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;
@@ -12,26 +13,26 @@ export function SuspensionDialog({ reason, open, onOpenChange }: SuspensionDialo
<StyledDialog
open={open}
onOpenChange={onOpenChange}>
<div className="suspension-dialog-content">
<div className="suspension-icon-section">
<MaterialIcon name="block--filled" className="suspension-icon" />
<div className={styles.suspensionDialogContent}>
<div className={styles.suspensionIconSection}>
<MaterialIcon name="block--filled" className={styles.suspensionIcon} />
</div>
<div className="suspension-text">
<h2 className="suspension-headline">Аккаунт заблокирован</h2>
<p className="suspension-body">
<div className={styles.suspensionText}>
<h2 className={styles.suspensionHeadline}>Аккаунт заблокирован</h2>
<p className={styles.suspensionBody}>
Ваш аккаунт был заблокирован за нарушение правил сообщества.
Вы не можете отправлять сообщения или взаимодействовать с другими пользователями.
</p>
{reason && reason !== "No reason provided" && (
<div className="suspension-reason">
<div className={styles.suspensionReason}>
<strong>Причина блокировки:</strong>
<div className="suspension-reason-text">
<div className={styles.suspensionReasonText}>
{reason}
</div>
</div>
)}
<p className="suspension-secondary">
<p className={styles.suspensionSecondary}>
Если вы считаете, что блокировка была применена по ошибке,
<a href="https://t.me/denis0001_dev" target="_blank" rel="noopener noreferrer">обратитесь к администратору</a> для рассмотрения вашего случая.
</p>
@@ -4,6 +4,7 @@ import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react";
import { useAppState } from "@/pages/chat/state";
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
import styles from "@/pages/chat/css/left-panel.module.scss";
export function ChatHeader() {
const { profileData } = useProfile();
@@ -25,9 +26,9 @@ export function ChatHeader() {
return (
<>
<header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div>
<div className="profile">
<header className={styles.chatHeaderLeft}>
<div className={styles.productName}>{PRODUCT_NAME}</div>
<div className={styles.profile}>
<a href="#" id="profile-open" onClick={handleProfileClick}>
<img
src={profilePictureUrl}
+2 -1
View File
@@ -1,12 +1,13 @@
import { useAppState, type ChatTabs } from "@/pages/chat/state";
import { UnifiedChatsList } from "./UnifiedChatsList";
import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
export function ChatTabs() {
const { chat, setActiveTab } = useAppState();
return (
<div className="chat-tabs">
<div className={styles.chatTabs}>
<MaterialTabs
value={chat.activeTab}
full-width
@@ -5,6 +5,7 @@ import { UsernameSearch } from "./UsernameSearch";
import { ChatTabs } from "./ChatTabs";
import { ChatHeader } from "./ChatHeader";
import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false);
@@ -30,9 +31,9 @@ function BottomAppBar() {
export function LeftPanel() {
return (
<div className="chat-list" id="chat-list">
<div className={styles.chatList} id="chat-list">
<ChatHeader />
<div className="search-container">
<div className={styles.searchContainer}>
<UsernameSearch />
</div>
<ChatTabs />
@@ -11,6 +11,7 @@ import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png";
import { MaterialBadge, MaterialCircularProgress, MaterialList, MaterialListItem } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
interface PublicChat {
id: string;
@@ -239,7 +240,7 @@ export function UnifiedChatsList() {
style={{ cursor: "pointer" }}
>
{formatPublicChatMessage(chat.id) && (
<span slot="description" className="list-description">
<span slot="description" className={styles.listDescription}>
{formatPublicChatMessage(chat.id)}
</span>
)}
@@ -272,7 +273,7 @@ export function UnifiedChatsList() {
size="small"
/>
</div>
<span slot="description" className="list-description">
<span slot="description" className={styles.listDescription}>
{chat.lastMessage || "Нет сообщений"}
</span>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
@@ -1,13 +1,15 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useAppState } from "@/pages/chat/state";
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
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 } from "@/utils/material";
import styles from "@/pages/chat/css/left-panel.module.scss";
interface SearchUser extends User {
publicKey?: string | null;
@@ -15,12 +17,14 @@ interface SearchUser extends User {
}
export function UsernameSearch() {
const { user, switchToDM } = useAppState();
const { user, switchToDM, chat } = useAppState();
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(() => {
@@ -58,18 +62,45 @@ export function UsernameSearch() {
// Subscribe to online status for all search results
useEffect(() => {
// Subscribe to all search results
const activeDmUserId = chat.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);
});
// Cleanup function to unsubscribe from all users
// 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 () => {
searchResults.forEach(searchUser => {
onlineStatusManager.unsubscribe(searchUser.id);
});
// 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 = chat.activeDm?.userId;
if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) {
switchingToUserIdRef.current = null;
}
};
}, [searchResults]);
}, [searchResults, chat.activeDm?.userId]);
async function handleUserClick(searchUser: SearchUser) {
@@ -84,6 +115,8 @@ export function UsernameSearch() {
}
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,
@@ -134,14 +167,14 @@ export function UsernameSearch() {
) : "search--outlined"}
>
{isSearching && (
<div className="search-loading">
<div className={styles.searchLoading}>
<MaterialCircularProgress />
<span>Поиск...</span>
</div>
)}
{!isSearching && searchQuery.length >= 2 && searchResults.length === 0 && (
<div className="search-empty">
<div className={styles.searchEmpty}>
<span>Пользователи не найдены</span>
</div>
)}
@@ -155,30 +188,33 @@ export function UsernameSearch() {
onClick={() => handleUserClick(searchUser)}
style={{ cursor: "pointer" }}
>
<div slot="headline" className="search-result-headline">
{searchUser.username}
<StatusBadge
verified={searchUser.verified || false}
userId={searchUser.id}
size="small"
/>
</div>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img
src={searchUser.profile_picture || defaultAvatar}
alt={searchUser.username}
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover",
display: "block"
}}
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
<OnlineIndicator userId={searchUser.id} />
<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
verified={searchUser.verified || false}
userId={searchUser.id}
size="small"
/>
</div>
<div className={styles.searchResultDescription}>
<OnlineStatus userId={searchUser.id} />
</div>
</div>
</div>
</div>
</MaterialListItem>
))}
@@ -186,7 +222,7 @@ export function UsernameSearch() {
)}
{!isSearching && searchQuery.length < 2 && (
<div className="search-hint">
<div className={styles.searchHint}>
<span>Введите минимум 2 символа для поиска</span>
</div>
)}
@@ -1,22 +0,0 @@
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import "./css/cropper-dialog.scss";
export function CropperDialog() {
return (
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<MaterialIconButton icon="close" id="cropper-close" />
</div>
<div className="cropper-container">
<div id="cropper-area"></div>
</div>
<div className="cropper-actions">
<MaterialButton id="crop-cancel" variant="outlined">Отмена</MaterialButton>
<MaterialButton id="crop-save">Сохранить</MaterialButton>
</div>
</div>
</mdui-dialog>
);
}
@@ -1,193 +0,0 @@
import { useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "@/core/types";
import { MaterialButton } from "@/utils/material";
interface ImageCropperProps {
onCrop: (croppedImageData: string) => void;
onCancel: () => void;
imageFile: File | null;
}
export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [src, setSrc] = useState<string | undefined>(undefined);
const [isLoaded, setIsLoaded] = useState(false);
const [cropArea, setCropArea] = useState<Rect>({ x: 0, y: 0, width: 200, height: 200 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
useEffect(() => {
if (imageFile) {
const reader = new FileReader();
function handleImageLoad() {
setIsLoaded(true);
// Initialize crop area to center of image
const img = imageRef.current;
if (img) {
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
setCropArea({
x: (img.naturalWidth - size) / 2,
y: (img.naturalHeight - size) / 2,
width: size,
height: size
});
}
}
function handleReaderLoad() {
if (imageRef.current) {
setSrc(reader.result as string);
imageRef.current.addEventListener("load", handleImageLoad);
}
}
reader.addEventListener("load", handleReaderLoad);
reader.readAsDataURL(imageFile);
return () => {
reader.abort();
reader.removeEventListener("load", handleReaderLoad);
imageRef.current?.removeEventListener("load", handleImageLoad);
}
}
}, [imageFile]);
function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if click is within crop area
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
setIsDragging(true);
setDragStart({ x: x - cropArea.x, y: y - cropArea.y });
}
};
function handleMouseMove(e: React.MouseEvent) {
const rect = canvasRef.current?.getBoundingClientRect();
if (isDragging && isLoaded && rect && imageRef.current) {
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const newX = Math.max(
0,
Math.min(
x - dragStart.x,
imageRef.current.naturalWidth - cropArea.width
)
);
const newY = Math.max(
0,
Math.min(
y - dragStart.y,
imageRef.current.naturalHeight - cropArea.height
)
);
setCropArea(prev => ({ ...prev, x: newX, y: newY }));
}
};
function handleMouseUp() {
setIsDragging(false);
};
function handleCrop() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to crop area
canvas.width = cropArea.width;
canvas.height = cropArea.height;
// Draw cropped portion
ctx.drawImage(
imageRef.current,
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, cropArea.width, cropArea.height
);
// Convert to data URL
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
onCrop(croppedImageData);
};
function drawCropArea() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw image
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
// Draw crop overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
// Draw crop border
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
};
useEffect(() => {
drawCropArea();
}, [cropArea, isLoaded]);
if (!imageFile) return null;
return (
<div className="cropper-container">
<canvas
ref={canvasRef}
width={400}
height={400}
style={{
cursor: isDragging ? 'grabbing' : 'grab',
border: '1px solid #ccc',
maxWidth: '100%',
height: 'auto'
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
<img
ref={imageRef}
src={src}
style={{ display: 'none' }}
alt="Crop source"
/>
<div className="cropper-actions">
<MaterialButton onClick={handleCrop} disabled={!isLoaded}>
Обрезать
</MaterialButton>
<MaterialButton variant="outlined" onClick={onCancel}>
Отмена
</MaterialButton>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import type { DialogProps } from "@/core/types";
import { useAppState } from "@/pages/chat/state";
import { changePassword } from "@/core/api/securityApi";
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 } = useAppState();
@@ -16,12 +17,12 @@ export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogPro
return (
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="change-password-dialog">
<div className="cpd-container">
<div className="cpd-titlebar">
<div className={styles.cpdContainer}>
<div className={styles.cpdTitlebar}>
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className="cpd-title">Изменить пароль</div>
<div className={styles.cpdTitle}>Изменить пароль</div>
</div>
<div className="cpd-content">
<div className={styles.cpdContent}>
<form onSubmit={async (e) => {
e.preventDefault();
if (!user.authToken || !user.currentUser?.username) return;
@@ -64,14 +65,14 @@ export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogPro
variant="outlined"
toggle-password
required />
<div className="cpd-logout-all">
<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="cpd-actions">
<div className={styles.cpdActions}>
<MaterialButton type="submit" disabled={busy}>Сохранить</MaterialButton>
</div>
</form>
@@ -10,6 +10,7 @@ import ChangePasswordDialog from "./ChangePasswordDialog";
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
import { useImmer } from "use-immer";
import { MaterialButton, MaterialIconButton, MaterialList, MaterialListItem, MaterialSwitch } from "@/utils/material";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings");
@@ -77,13 +78,13 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
return (
<>
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className="settings-dialog">
<div id="settings-dialog-inner">
<div className="header">
<MaterialIconButton icon="close" id="settings-close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className="title">Настройки</div>
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className={styles.settingsDialog}>
<div className={styles.settingsDialogInner}>
<div className={styles.header}>
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className={styles.title}>Настройки</div>
</div>
<div id="settings-menu">
<div className={styles.settingsMenu}>
<MaterialList>
<MaterialListItem
icon="notifications--filled"
@@ -122,8 +123,8 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
О приложении
</MaterialListItem>
</MaterialList>
<div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<div className={styles.screen}>
<div className={`${styles.settingsPanel} ${activePanel === "notifications-settings" ? styles.active : ""}`}>
<h3>Уведомления</h3>
{pushSupported && (
<MaterialSwitch
@@ -134,12 +135,12 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
)}
</div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<div className={`${styles.settingsPanel} ${activePanel === "security-settings" ? styles.active : ""}`}>
<h3>Безопасность</h3>
<MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton>
</div>
<div id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}>
<div className={`${styles.settingsPanel} ${activePanel === "devices-settings" ? styles.active : ""}`}>
<h3>Устройства</h3>
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton>
@@ -166,10 +167,10 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
</MaterialList>
</div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
<div className={`${styles.settingsPanel} ${activePanel === "about-settings" ? styles.active : ""}`}>
<h3>О приложении</h3>
<p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
<p><span className="product-name">{PRODUCT_NAME}</span></p>
<p><span className={styles.productName}>{PRODUCT_NAME}</span></p>
</div>
</div>
</div>
@@ -7,6 +7,7 @@ import Quote from "@/core/components/Quote";
import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu";
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/ChatInput.module.scss";
interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void;
@@ -144,8 +145,8 @@ export function ChatInputWrapper(
}
return (
<div className="chat-input-wrapper" ref={chatInputWrapperRef}>
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
<div className={styles.chatInputWrapper} ref={chatInputWrapperRef}>
<form className={styles.inputGroup} id="message-form" onSubmit={handleSubmit}>
<AnimatePresence onExitComplete={onCloseEdit}>
{editVisible && editingMessage && (
<motion.div
@@ -155,13 +156,13 @@ export function ChatInputWrapper(
transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }}
>
<div className="reply-preview contextual-preview">
<div className={styles.contextualPreview}>
<MaterialIcon name="edit" />
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{editingMessage!.username}</span>
<span className="reply-text">{editingMessage!.content}</span>
<Quote className={`${styles.quote} ${styles.contextualContent}`} background="surfaceContainer">
<span className={styles.replyUsername}>{editingMessage!.username}</span>
<span className={styles.replyText}>{editingMessage!.content}</span>
</Quote>
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearEdit}></MaterialIconButton>
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearEdit}></MaterialIconButton>
</div>
</motion.div>
)}
@@ -175,13 +176,13 @@ export function ChatInputWrapper(
transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }}
>
<div className="reply-preview contextual-preview">
<div className={styles.contextualPreview}>
<MaterialIcon name="reply" />
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span>
<Quote className={`${styles.quote} ${styles.contextualContent}`} background="surfaceContainer">
<span className={styles.replyUsername}>{replyTo!.username}</span>
<span className={styles.replyText}>{replyTo!.content}</span>
</Quote>
<MaterialIconButton icon="close" className="reply-cancel" onClick={onClearReply}></MaterialIconButton>
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearReply}></MaterialIconButton>
</div>
</motion.div>
)}
@@ -195,9 +196,9 @@ export function ChatInputWrapper(
transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }}
>
<div className="attachments-preview contextual-preview">
<div className={`${styles.attachmentsPreview} ${styles.contextualPreview}`}>
<MaterialIcon name="attach_file" />
<div className="attachments-chips">
<div className={styles.attachmentsChips}>
{selectedFiles.map((file, i) => (
<mdui-chip
key={i}
@@ -217,22 +218,22 @@ export function ChatInputWrapper(
</mdui-chip>
))}
</div>
<MaterialIconButton icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={() => setAttachmentsVisible(false)}></MaterialIconButton>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="chat-input">
<div className="left-buttons">
<div className={styles.chatInput}>
<div className={styles.leftButtons}>
<MaterialIconButton
icon="mood"
onClick={handleEmojiButtonClick}
onMouseDown={e => e.stopPropagation()}
onMouseUp={e => e.stopPropagation()}
className="emoji-btn" />
className={styles.emojiBtn} />
</div>
<RichTextArea
className="message-input"
className={styles.messageInput}
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
@@ -240,9 +241,9 @@ export function ChatInputWrapper(
rows={1}
onTextChange={handleMessageChange}
onEnter={handleSubmit} />
<div className="buttons">
<MaterialIconButton icon="attach_file" onClick={handleAttachClick} className="attach-btn"></MaterialIconButton>
<button type="submit" className="send-btn">
<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>
@@ -2,11 +2,11 @@ import { Message } from "./Message";
import { useAppState } from "@/pages/chat/state";
import type { Message as MessageType } from "@/core/types";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { useEffect, useState, type ReactNode } from "react";
import { MaterialDialog } from "@/core/components/Dialog";
import { useState, type ReactNode } from "react";
import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
import { MaterialButton } from "@/utils/material";
import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/right-panel.module.scss";
interface ChatMessagesProps {
messages?: MessageType[];
@@ -22,8 +22,6 @@ interface ChatMessagesProps {
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { user } = useAppState();
// Use prop messages (panels provide their own messages)
// Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
@@ -31,18 +29,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
position: { x: 0, y: 0 }
});
// Delete dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
useEffect(() => {
if (!deleteDialogOpen) {
setToBeDeleted(null);
}
}, [deleteDialogOpen]);
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
e.preventDefault();
setContextMenu({
@@ -67,19 +53,17 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
if (onReplySelect) onReplySelect(message);
};
async function confirmDelete() {
if (!toBeDeleted || !user.authToken) return;
try {
onDelete?.(toBeDeleted.id);
} catch (error) {
console.error("Failed to delete message:", error);
}
setDeleteDialogOpen(false);
}
async function handleDelete(message: MessageType) {
setToBeDeleted({ id: message.id, isDm });
setDeleteDialogOpen(true);
try {
await confirm({
headline: "Удалить сообщение?",
confirmText: "Удалить",
cancelText: "Отменить",
onConfirm: () => onDelete?.(message.id)
});
} catch (error) {
// User cancelled
}
}
function handleRetry(message: MessageType) {
@@ -127,7 +111,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
return (
<>
<div className="chat-messages" id="chat-messages">
<div className={styles.chatMessages} id="chat-messages">
{messages.map((message: MessageType) => (
<Message
key={message.id}
@@ -144,14 +128,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
{children}
</div>
<MaterialDialog
headline="Удалить сообщение?"
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}>
<MaterialButton slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</MaterialButton>
<MaterialButton slot="action" variant="filled" onClick={confirmDelete}>Удалить</MaterialButton>
</MaterialDialog>
{/* Context Menu */}
{contextMenu.message && (
<MessageContextMenu
@@ -170,8 +146,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
onOpenChange={handleContextMenuOpenChange}
/>
)}
</>
);
}
+23 -19
View File
@@ -1,6 +1,7 @@
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;
@@ -118,26 +119,29 @@ export function EmojiMenu(props: EmojiMenuProps) {
return (
<div
ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
style={mode === "standalone" && position ? {
position: "fixed",
left: position.x,
bottom: position.y,
zIndex: 1000,
pointerEvents: isOpen ? "auto" : "none"
} : {
pointerEvents: isOpen ? "auto" : "none"
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="emoji-menu-header">
<div ref={tabsRef} className="emoji-category-tabs">
>
<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={`emoji-category-tab ${activeCategory === category.name ? "active" : ""}`}
className={`${styles.emojiCategoryTab} ${activeCategory === category.name ? styles.active : ""}`}
onClick={() => scrollToCategory(category.name)}
title={category.name}
>
@@ -149,7 +153,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
<div
ref={scrollRef}
className="emoji-grid"
className={styles.emojiGrid}
onScroll={handleScroll}
>
{EMOJI_CATEGORIES.map((category) => {
@@ -161,17 +165,17 @@ export function EmojiMenu(props: EmojiMenuProps) {
ref={(el) => {
if (el) categoryRefs.current.set(category.name, el);
}}
className="emoji-category-section"
className={styles.emojiCategorySection}
>
<h3 className="emoji-category-title">
<h3 className={styles.emojiCategoryTitle}>
{category.name.charAt(0).toUpperCase() + category.name.slice(1)}
</h3>
{emojis.length > 0 ? (
<div className="emoji-category-grid">
<div className={styles.emojiCategoryGrid}>
{emojis.map((emoji, index) => (
<button
key={`${category.name}-${index}`}
className="emoji-item"
className={styles.emojiItem}
onClick={() => handleEmojiClick(emoji)}
title={emoji}
>
@@ -180,7 +184,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
))}
</div>
) : (
<div className="emoji-empty-state">
<div className={styles.emojiEmptyState}>
<span>No {category.name} emojis</span>
</div>
)}
+29 -28
View File
@@ -17,6 +17,7 @@ import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
import { parseProfileLink } from "@/core/profileLinks";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
import styles from "@/pages/chat/css/Message.module.scss";
interface MessageReactionsProps {
reactions?: Reaction[];
@@ -111,7 +112,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
}
return (
<div className="message-reactions">
<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);
@@ -119,12 +120,12 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
return (
<button
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
className={`${styles.reactionButton} ${hasUserReacted ? styles.reacted : ""} ${isAnimating ? styles.removing : ""}`}
onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")}
>
<span className="reaction-emoji">{reaction.emoji}</span>
<span className="reaction-count">{reaction.count}</span>
<span className={styles.reactionEmoji}>{reaction.emoji}</span>
<span className={styles.reactionCount}>{reaction.count}</span>
</button>
);
})}
@@ -177,7 +178,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
// 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="mention-link">${match}</a>`;
return `<a href="https://fromchat.ru/@${username}" class="${styles.mentionLink}">${match}</a>`;
});
// Restore the original links
@@ -188,7 +189,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
return {
__html: DOMPurify.sanitize(parse(content, { async: false })).trim()
};
}, [message.content]);
}, [message.content, styles.mentionLink]);
// Auto-decrypt images in DMs
useEffect(() => {
@@ -473,12 +474,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
return (
<>
<div
className={`message ${isAuthor ? "sent" : "received"} ${isEmojiMessage ? "emoji-message" : ""} ${isSingleEmojiMessage ? "single-emoji" : ""}`}
className={`${styles.message} ${isAuthor ? styles.sent : styles.received} ${isEmojiMessage ? styles.emojiMessage : ""} ${isSingleEmojiMessage ? "" : ""}`}
data-id={message.id}
onContextMenu={handleContextMenu}
>
{!isAuthor && !isDm && (
<div className="message-profile-pic" onClick={handleProfileClick}>
<div className={styles.messageProfilePic} onClick={handleProfileClick}>
<img
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
alt={message.username}
@@ -489,10 +490,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
</div>
)}
<div className="message-inner">
<div className={styles.messageInner}>
{!isAuthor && !isDm && !isSingleEmojiMessage && (
<div
className="message-username"
className={styles.messageUsername}
onClick={handleProfileClick}>
{message.username}
<StatusBadge
@@ -504,19 +505,19 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
)}
{message.reply_to && (
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className="reply-username">{message.reply_to.username}</span>
<span className="reply-text">{message.reply_to.content}</span>
<Quote className={`${styles.replyPreview} ${styles.contextualContent}`} background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className={styles.replyUsername}>{message.reply_to.username}</span>
<span className={styles.replyText}>{message.reply_to.content}</span>
</Quote>
)}
<div
className={`message-content ${isEmojiMessage ? "emoji-content" : ""} ${isSingleEmojiMessage ? "single-emoji-content" : ""}`}
className={`${styles.messageContent} ${isEmojiMessage ? styles.emojiContent : ""} ${isSingleEmojiMessage ? styles.singleEmojiContent : ""}`}
dangerouslySetInnerHTML={formattedMessage}
onClick={handleLinkClick} />
{message.files && message.files.length > 0 && (
<MaterialList className="message-attachments">
<MaterialList className={styles.messageAttachments}>
{message.files.map((file, idx) => {
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
const isEncryptedDm = Boolean(isDm && file.encrypted);
@@ -526,9 +527,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const isSending = message.runtimeData?.sendingState?.status === 'sending';
return (
<div className="attachment" key={idx}>
<div className={styles.attachment} key={idx}>
{isImage ? (
<div className="image-wrapper">
<div className={styles.imageWrapper}>
<img
ref={(el) => {
if (el) imageRefs.current.set(file.path, el);
@@ -537,10 +538,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)}
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
className={`${styles.attachementImage} ${loadedImages.has(file.path) ? "" : styles.loading}`}
/>
{(!loadedImages.has(file.path) || isSending) && (
<div className="loading-overlay">
<div className={styles.loadingOverlay}>
<MaterialCircularProgress />
</div>
)}
@@ -554,7 +555,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}}
>
<MaterialListItem>
<span className="with-icon-gap">
<span className={styles.withIconGap}>
{isDownloading ? <MaterialCircularProgress /> : null}
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</span>
@@ -573,7 +574,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
messageId={message.id}
/>
<div className="message-time">
<div className={styles.messageTime}>
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
@@ -582,15 +583,15 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
)}
{isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator">
<span className={styles.messageStatusIndicator}>
{message.runtimeData.sendingState.status === 'sending' && (
<MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
)}
{message.runtimeData.sendingState.status === 'failed' && (
<span className="material-symbols error-icon">error</span>
<span className={`material-symbols ${styles.errorIcon}`}>error</span>
)}
{message.runtimeData.sendingState.status === 'sent' && (
<span className="material-symbols success-icon">check</span>
<span className={`material-symbols ${styles.successIcon}`}>check</span>
)}
</span>
)}
@@ -601,12 +602,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && createPortal(
<div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
className={`${styles.fullscreenImageOverlay} ${isAnimatingOpen ? "" : styles.closing}`}
onClick={closeFullscreen}>
<img
src={fullscreenImage.src}
alt={fullscreenImage.name}
className={`fullscreen-animated-image ${isAnimatingOpen ? "to-end" : "to-start"}`}
className={styles.fullscreenAnimatedImage}
style={{
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
@@ -615,10 +616,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}}
onClick={e => e.stopPropagation()}
/>
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
<div className={`${styles.fullscreenControls} ${styles.topRight}`} onClick={e => e.stopPropagation()}>
<MaterialIconButton icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? (
<div className="progress-wrapper">
<div className={styles.progressWrapper}>
<MaterialCircularProgress />
</div>
) : (
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "@/core/types";
import { EmojiMenu } from "./EmojiMenu";
import { useAppState } from "@/pages/chat/state";
import styles from "@/pages/chat/css/MessageContextMenu.module.scss";
interface MessageContextMenuProps {
message: Message;
@@ -39,9 +40,8 @@ export function MessageContextMenu({
const [isClosing, setIsClosing] = useState(false);
const [reactionBarPosition, setReactionBarPosition] = useState<Size2D>({ x: 0, y: 0 });
const [contextMenuPosition, setContextMenuPosition] = useState<Size2D>(position);
const [animationClass, setAnimationClass] = useState('entering');
const [reactionBarAnimationClass, setReactionBarAnimationClass] = useState('entering');
const [reactionBarSide, setReactionBarSide] = useState<'left' | 'right'>('left');
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);
@@ -74,8 +74,7 @@ export function MessageContextMenu({
let menuY = position.y;
let reactionX = position.x;
let reactionY = position.y - reactionBarRect.height - 10; // Position above menu
let animation = 'entering';
let reactionSide: 'left' | 'right' = 'left';
let animation: keyof typeof styles = styles.entering;
let reactionPositionedRight = false;
// Check if reaction bar would overflow at the top
@@ -83,19 +82,16 @@ export function MessageContextMenu({
// Position reaction bar to the right side of the context menu instead
reactionX = menuX + contextMenuRect.width + 10;
reactionY = menuY; // Align with menu top
reactionSide = 'right';
reactionPositionedRight = true;
animation = 'entering-right'; // Use right-side animation
animation = styles.enteringRight; // Use right-side animation
} else {
// Try positioning above menu first
reactionSide = 'left';
// Check if shared rect would overflow horizontally
if (menuX + sharedRect.width > viewportWidth) {
menuX = position.x - contextMenuRect.width;
reactionX = menuX;
animation = 'entering-left';
reactionSide = 'right';
animation = styles.enteringLeft;
}
}
@@ -111,14 +107,13 @@ export function MessageContextMenu({
if (reactionPositionedRight && reactionX + reactionBarRect.width > viewportWidth) {
// Position to the left side instead
reactionX = menuX - reactionBarRect.width - 10;
reactionSide = 'left';
}
// 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 = 'entering-up';
animation = styles.enteringUp;
}
// Ensure menu doesn't go off the right edge
@@ -133,7 +128,6 @@ export function MessageContextMenu({
setReactionBarPosition({ x: reactionX, y: reactionY });
setAnimationClass(animation);
setReactionBarAnimationClass(animation);
setReactionBarSide(reactionSide);
}
});
@@ -147,7 +141,9 @@ export function MessageContextMenu({
if (isOpen && !isClosing) {
// Check if the click is on a context menu element or reaction bar
const target = event.target as Element;
if (!target.closest('.context-menu') && !target.closest('.context-menu-reaction-bar')) {
// Use refs instead of class selectors for CSS modules
if ((!contextMenuRef.current || !contextMenuRef.current.contains(target)) &&
(!reactionBarRef.current || !reactionBarRef.current.contains(target))) {
handleClose();
}
}
@@ -181,17 +177,15 @@ export function MessageContextMenu({
function handleClose() {
setIsClosing(true);
// Set appropriate closing animation based on opening animation
const closingAnimation = animationClass.replace('entering', 'closing');
setAnimationClass(closingAnimation);
setReactionBarAnimationClass(closingAnimation);
setAnimationClass(styles.closing);
setReactionBarAnimationClass(styles.closing);
// Wait for animation to complete before calling onOpenChange
setTimeout(() => {
onOpenChange(false);
setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening
setReactionBarAnimationClass('entering'); // Reset for next opening
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);
@@ -307,7 +301,7 @@ export function MessageContextMenu({
{/* Reaction Bar */}
<div
ref={reactionBarRef}
className={`context-menu-reaction-bar ${reactionBarSide} ${reactionBarAnimationClass} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
className={`${styles.contextMenuReactionBar} ${reactionBarAnimationClass} ${isEmojiMenuExpanded ? styles.expanded : ""} ${expandUpward ? styles.expandUpward : ""}`}
style={{
position: 'fixed',
...(isEmojiMenuExpanded && expandUpward
@@ -326,11 +320,11 @@ export function MessageContextMenu({
}}
onClick={(e) => e.stopPropagation()}>
{!isEmojiMenuExpanded ? (
<div className="reaction-bar-content">
<div className={styles.reactionBarContent}>
{QUICK_REACTIONS.map((emoji, index) => (
<button
key={index}
className="reaction-emoji-button"
className={styles.reactionEmojiButton}
onClick={async () => await handleReactionClick(emoji)}
title={emoji}
>
@@ -338,7 +332,7 @@ export function MessageContextMenu({
</button>
))}
<button
className="reaction-expand-button"
className={styles.reactionExpandButton}
onClick={handleExpandClick}
title="More emojis"
>
@@ -348,7 +342,7 @@ export function MessageContextMenu({
) : (
<div
ref={emojiMenuRef}
className="emoji-menu-wrapper">
className={styles.emojiMenuWrapper}>
<EmojiMenu
isOpen={true}
onClose={handleClose}
@@ -362,7 +356,7 @@ export function MessageContextMenu({
{/* Context Menu */}
<div
ref={contextMenuRef}
className={`context-menu ${animationClass} ${isEmojiMenuExpanded ? "faded" : ""}`}
className={`${styles.contextMenu} ${animationClass} ${isEmojiMenuExpanded ? styles.faded : ""}`}
style={{
position: 'fixed',
top: `${contextMenuPosition.y}px`,
@@ -373,7 +367,7 @@ export function MessageContextMenu({
{actions.map((action, i) => (
action.show && (
<div
className="context-menu-item"
className={styles.contextMenuItem}
onClick={action.onClick}
key={i}>
<span className="material-symbols">{action.icon}</span>
@@ -15,6 +15,8 @@ import { OnlineStatus } from "./OnlineStatus";
import { typingManager } from "@/core/typingManager";
import { PublicChatPanel } from "./panels/PublicChatPanel";
import { 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;
@@ -196,7 +198,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const panelKey = chat.activePanel?.getState().title || "empty";
return (
<div className="chat-container">
<div className={styles.chatContainer}>
<AnimatePresence mode="wait">
<motion.div
key={panelKey}
@@ -204,12 +206,11 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="chat-wrapper"
className={rightPanelStyles.chatWrapper}
>
<div
ref={messagePanelRef}
className="chat-main"
id="chat-inner"
className={rightPanelStyles.chatMain}
onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
@@ -242,16 +243,16 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
setIsDragging(false);
dragCounterRef.current = 0;
} : undefined}>
<div className="chat-header">
<div className={rightPanelStyles.chatHeader}>
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
className={rightPanelStyles.chatHeaderAvatar}
onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<div className={rightPanelStyles.chatHeaderInfo}>
<div className={rightPanelStyles.infoChat}>
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<ChatHeaderText panel={panel} />
</div>
@@ -262,7 +263,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div>
{panelState?.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div className={rightPanelStyles.chatMessages} id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
@@ -300,7 +301,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<div ref={messagesEndRef} />
</ChatMessages>
) : (
<div className="chat-messages" id="chat-messages">
<div className={rightPanelStyles.chatMessages} id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
@@ -314,30 +315,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
)}
{panel && (
<>
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
<MaterialIcon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<ChatInputWrapper
<ChatInputWrapper
onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null);
@@ -393,9 +371,29 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
}
}}
/>
</>
)}
</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>
@@ -6,6 +6,7 @@
*/
import { useAppState } from "@/pages/chat/state";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineIndicatorProps {
userId: number;
@@ -22,8 +23,8 @@ export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps
}
return (
<div className={`online-indicator ${className}`}>
<div className="indicator-dot online"></div>
<div className={`${styles.onlineIndicator} ${className}`}>
<div className={`${styles.indicatorDot} ${styles.online}`}></div>
</div>
);
}
@@ -6,6 +6,7 @@
*/
import { useAppState } from "@/pages/chat/state";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface OnlineStatusProps {
userId: number;
@@ -38,9 +39,9 @@ export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps
}
return (
<div className="online-status">
<div className={`status-dot ${status?.online ? "online" : "offline"}`}></div>
<span className="status-text">
<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 && (
@@ -6,6 +6,7 @@
*/
import { useMemo } from "react";
import styles from "@/pages/chat/css/TypingIndicators.module.scss";
interface TypingIndicatorProps {
typingUsers: string[]; // Array of usernames who are typing
@@ -23,13 +24,13 @@ export function TypingIndicator({ typingUsers }: TypingIndicatorProps) {
}, [typingUsers]);
return (
<div className="typing-indicator">
<div className="typing-dots">
<span></span>
<span></span>
<span></span>
<div className={styles.typingIndicator}>
<div className={styles.typingDots}>
<span />
<span />
<span />
</div>
<span className="typing-text">{typingText}</span>
<span className={styles.typingText}>{typingText}</span>
</div>
);
}
@@ -1,10 +1,12 @@
import { useState, useEffect } from "react";
import { useAppState, type CallStatus } from "@/pages/chat/state";
import { useAppState } from "@/pages/chat/state";
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 { chat, toggleCallMinimize, user } = useAppState();
@@ -26,20 +28,11 @@ export function CallWindow() {
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [callDuration, setCallDuration] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
const [wasMinimized, setWasMinimized] = useState(false);
const [callData, setCallData] = useState<{
remoteUsername: string | null;
status: CallStatus;
isInitiator: boolean;
isMuted: boolean;
} | null>(null);
const status = callData?.status || call.status;
const remoteUsername = callData?.remoteUsername || call.remoteUsername;
const isInitiator = callData?.isInitiator || call.isInitiator;
const isMuted = callData?.isMuted || call.isMuted;
const status = call.status;
const remoteUsername = call.remoteUsername;
const isInitiator = call.isInitiator;
const isMuted = call.isMuted;
useEffect(() => {
let interval: NodeJS.Timeout;
@@ -57,55 +50,6 @@ export function CallWindow() {
};
}, [call.status, call.startTime]);
// Preserve call data during exit animation
useEffect(() => {
if (call.isActive) {
setCallData({
remoteUsername: call.remoteUsername,
status: call.status,
isInitiator: call.isInitiator,
isMuted: call.isMuted
});
}
}, [call.isActive, call.remoteUsername, call.status, call.isInitiator, call.isMuted]);
// Track minimized state for exit animation
useEffect(() => {
if (call.isActive) {
setWasMinimized(call.isMinimized);
}
}, [call.isActive, call.isMinimized]);
// Handle visibility animation with entrance and exit delays
useEffect(() => {
if (call.isActive) {
setShouldRender(true);
// Small delay to ensure DOM is ready, then trigger animation
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsVisible(true);
});
});
} else {
if (shouldRender) {
// Call ended - start exit animation
setIsVisible(false);
// After animation completes, stop rendering
const timer = setTimeout(() => {
setShouldRender(false);
setCallData(null);
setWasMinimized(false);
}, 400); // Match the CSS transition duration
return () => clearTimeout(timer);
} else {
// Call not active and not rendered - ensure clean state
setShouldRender(false);
setIsVisible(false);
setCallData(null);
setWasMinimized(false);
}
}
}, [call.isActive, shouldRender, wasMinimized]);
// Handle dragging for PiP mode
useEffect(() => {
@@ -135,14 +79,6 @@ export function CallWindow() {
};
}, [isDragging, call.isMinimized, dragOffset]);
// Cleanup effect to reset state when component unmounts
useEffect(() => {
return () => {
setIsVisible(false);
setShouldRender(false);
setCallData(null);
};
}, []);
function formatDuration(seconds: number) {
const mins = Math.floor(seconds / 60);
@@ -166,64 +102,75 @@ export function CallWindow() {
function getGradientClass() {
switch (status) {
case "calling":
return "gradient-calling";
return styles.gradientCalling;
case "connecting":
return "gradient-connecting";
return styles.gradientConnecting;
case "active":
return "gradient-active";
return styles.gradientActive;
default:
return "gradient-default";
return styles.gradientDefault;
}
}
const isMinimized = call.isMinimized;
return (
createPortal(
<>
<audio
ref={remoteAudioRef}
className="remote-audio"
className={styles.remoteAudio}
autoPlay
playsInline
controls />
{shouldRender && (
<div
className={`call-window ${(call.isActive ? call.isMinimized : wasMinimized) ? "minimized" : "maximized"} ${isDragging ? "dragging" : ""} ${getGradientClass()} ${isVisible ? "visible" : "hidden"}`}
style={(call.isActive ? call.isMinimized : wasMinimized) ? {
left: pipPosition.x,
top: pipPosition.y
} : undefined}
onMouseDown={(e) => {
if (call.isMinimized) {
// Only start dragging if not clicking on a button
// TODO change it to e.stopPropagation() on the buttons
if (!e.target.closest("mdui-button-icon")) {
setIsDragging(true);
setDragOffset({
x: e.clientX - pipPosition.x,
y: e.clientY - pipPosition.y
});
}
<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
} : undefined}
initial={false}
exit={isMinimized ?
{ opacity: 0, scale: 0.7 } :
{ opacity: 0, y: -100 }
}
}}
>
<div className="call-header">
<div className="window-controls">
transition={{
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={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn"
className={styles.windowControlBtn}
/>
</div>
<div className="call-header-info">
<h3 className="username">{remoteUsername}</h3>
<p className="status">{getStatusText()}</p>
<div className={styles.callHeaderInfo}>
<h3 className={styles.username}>{remoteUsername}</h3>
<p className={styles.status}>{getStatusText()}</p>
{!call.isMinimized && call.encryptionEmojis.length > 0 && (
<div className="encryption-emojis">
<div className={styles.encryptionEmojis}>
{call.encryptionEmojis.map((emoji, index) => (
<span key={index} className="encryption-emoji">
<span key={index} className={styles.encryptionEmoji}>
{emoji}
</span>
))}
@@ -232,75 +179,75 @@ export function CallWindow() {
</div>
</div>
<div className={`call-content ${(call.isSharingScreen || call.isRemoteScreenSharing) ? "with-screen-share" : ""}`}>
<div className={`${styles.callContent} ${(call.isSharingScreen || call.isRemoteScreenSharing) ? styles.withScreenShare : ""}`}>
{/* Main screen share area - takes most space when active */}
<div className="screen-share-area">
<div className={styles.screenShareArea}>
{/* Local screen share */}
<div
className="video-tile screen-share-tile local-screen-share"
className={`${styles.videoTile} ${styles.screenShareTile} ${styles.localScreenShare}`}
style={{ display: call.isSharingScreen ? "flex" : "none" }}>
<video
ref={localScreenShareRef}
className="video-element screen-share-video"
className={`${styles.videoElement} ${styles.screenShareVideo}`}
autoPlay
playsInline
muted />
<div className="tile-label">Your Screen</div>
<div className={styles.tileLabel}>Your Screen</div>
</div>
{/* Remote screen share */}
<div
className="video-tile screen-share-tile remote-screen-share"
className={`${styles.videoTile} ${styles.screenShareTile} ${styles.remoteScreenShare}`}
style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}>
<video
ref={remoteScreenShareRef}
className="video-element screen-share-video"
className={`${styles.videoElement} ${styles.screenShareVideo}`}
autoPlay
playsInline />
<div className="tile-label">{remoteUsername}&apos;s Screen</div>
<div className={styles.tileLabel}>{remoteUsername}&apos;s Screen</div>
</div>
</div>
{/* Video tiles sidebar - appears on right when screen share is active */}
<div className="video-tiles-sidebar">
<div className={styles.videoTilesSidebar}>
{/* Local video tile */}
<div className="video-tile local-video">
<div className={`${styles.videoTile} ${styles.localVideo}`}>
<video
ref={localVideoRef}
className="video-element"
className={styles.videoElement}
autoPlay
playsInline
muted
style={{ display: call.isVideoEnabled ? "block" : "none" }} />
{!call.isVideoEnabled && (
<div className="video-placeholder">
<img src={defaultAvatar} alt="Avatar" className="placeholder-avatar" />
<span className="placeholder-username">{user.currentUser?.username || "You"}</span>
<div className={styles.videoPlaceholder}>
<img src={defaultAvatar} alt="Avatar" className={styles.placeholderAvatar} />
<span className={styles.placeholderUsername}>{user.currentUser?.username || "You"}</span>
</div>
)}
<div className="tile-label">You</div>
<div className={styles.tileLabel}>You</div>
</div>
{/* Remote video tile */}
<div className="video-tile remote-video">
<div className={`${styles.videoTile} ${styles.remoteVideo}`}>
<video
ref={remoteVideoRef}
className="video-element"
className={styles.videoElement}
autoPlay
playsInline
style={{ display: call.isRemoteVideoEnabled ? "block" : "none" }} />
{!call.isRemoteVideoEnabled && (
<div className="video-placeholder">
<img src={defaultAvatar} alt="Avatar" className="placeholder-avatar" />
<span className="placeholder-username">{remoteUsername}</span>
<div className={styles.videoPlaceholder}>
<img src={defaultAvatar} alt="Avatar" className={styles.placeholderAvatar} />
<span className={styles.placeholderUsername}>{remoteUsername}</span>
</div>
)}
<div className="tile-label">{remoteUsername}</div>
<div className={styles.tileLabel}>{remoteUsername}</div>
</div>
</div>
</div>
<div className="call-controls">
<div className={styles.callControls}>
{status === "calling" && !isInitiator ? (
<>
<MaterialIconButton onClick={acceptCall} icon="call" />
@@ -315,8 +262,9 @@ export function CallWindow() {
</>
)}
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</>,
id("root")
)