Implement profile dialog

This commit is contained in:
2025-10-18 13:32:04 +03:00
Unverified
parent a92f91d791
commit b5b5547927
18 changed files with 735 additions and 590 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
--- ---
description: Documentation rules alwaysApply: true
alwaysApply: false
--- ---
When documenting this project, follow these rules: When documenting this project, follow these rules:
@@ -10,6 +10,7 @@ interface RichTextAreaProps {
className?: string; className?: string;
rows?: number; rows?: number;
autoComplete?: string; autoComplete?: string;
readOnly?: boolean;
} }
export function RichTextArea({ export function RichTextArea({
@@ -21,6 +22,7 @@ export function RichTextArea({
className, className,
rows = 1, rows = 1,
autoComplete = "off", autoComplete = "off",
readOnly = false
}: RichTextAreaProps) { }: RichTextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null); const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -183,10 +185,10 @@ export function RichTextArea({
value={text} value={text}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
autoComplete={autoComplete} autoComplete={readOnly ? "off" : autoComplete}
onChange={handleChange} onChange={readOnly ? undefined : handleChange}
onKeyDown={handleKeyDown} onKeyDown={readOnly ? undefined : handleKeyDown}
/> readOnly={readOnly} />
<textarea <textarea
aria-hidden aria-hidden
readOnly readOnly
+26 -39
View File
@@ -25,9 +25,31 @@
position: relative; position: relative;
width: fit-content; width: fit-content;
display: flex; display: flex;
align-items: flex-end; align-items: flex-start;
gap: 8px; gap: 8px;
&.received {
.message-profile-pic {
width: 40px;
height: 40px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
}
}
}
.message-inner { .message-inner {
border-radius: 12px; border-radius: 12px;
position: relative; position: relative;
@@ -38,37 +60,16 @@
max-width: 100%; max-width: 100%;
display: inline-block; display: inline-block;
.message-profile-pic {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-bottom: 4px;
margin: 10px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
.message-username { .message-username {
font-weight: 600; font-weight: 600;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-size: 0.9rem; font-size: 0.9rem;
transition: color 0.2s ease;
margin: 10px; margin: 10px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover { &:hover {
color: $color-dark-primary; transform: scale(1.05);
text-decoration: underline;
} }
} }
@@ -285,20 +286,6 @@
} }
} }
.message-profile-pic {
img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
&.loading {
opacity: 0.6;
cursor: default;
}
}
}
.message-username { .message-username {
&.loading { &.loading {
+146 -199
View File
@@ -2,250 +2,197 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
// Profile styles // Profile Dialog Styles
#profile-dialog .content { .profile-dialog-backdrop {
display: flex; position: fixed;
flex-direction: column; top: 0;
gap: 24px; left: 0;
min-width: 400px;
.header-top {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
position: relative;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline;
.profile-picture-container {
position: relative;
$size: 70px;
width: $size;
height: $size;
flex-shrink: 0;
#profile-picture {
width: $size;
height: $size;
border-radius: 50%;
object-fit: cover;
}
.upload-overlay {
position: absolute;
bottom: 0;
right: 0; right: 0;
width: 28px; bottom: 0;
height: 28px; background: rgba(0, 0, 0, 0.6);
cursor: pointer; backdrop-filter: blur(20px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 30px;
box-sizing: border-box;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
&.open {
opacity: 1;
visibility: visible;
} }
} }
mdui-text-field { .profile-dialog {
flex: 1; width: 100%;
} max-width: 500px;
} max-height: calc(100vh - 60px);
background: $color-dark-surface-container;
#profile-form { border-radius: 16px;
box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14),
0 9px 46px 8px rgba(0, 0, 0, 0.12),
0 11px 15px -7px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; transform: scale(0.9);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
mdui-text-field { &.open {
width: 100%; transform: scale(1);
opacity: 1;
} }
.dialog-actions { .profile-dialog-content {
display: flex;
gap: 12px;
padding-top: 16px;
border-top: 1px solid $color-dark-outline;
> * {
flex: 1; flex: 1;
} overflow-y: auto;
}
}
}
// User profile dialog content styles
#user-profile-dialog .content {
display: flex; display: flex;
gap: 1.5rem; flex-direction: column;
align-items: center;
.profile-picture-section { .profile-picture-section {
flex-shrink: 0; position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 16px;
.profile-picture { .profile-picture {
width: 80px; width: 120px;
height: 80px; height: 120px;
border-radius: 50%; border-radius: 60px;
object-fit: cover; object-fit: cover;
border: 2px solid $color-dark-outline; border: 3px solid $color-dark-outline;
}
} }
.profile-info { .profile-picture-edit-overlay {
flex: 1; position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 60px;
background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 1rem; justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
.username-section { .username-section {
display: flex; text-align: center;
align-items: center;
gap: 0.75rem;
.username { .username-input {
margin: 0; background: none;
border: none;
font-size: 1.5rem;
font-weight: 500;
color: $color-dark-on-surface; color: $color-dark-on-surface;
font-size: 1.1rem; text-align: center;
font-weight: 600; outline: none;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
cursor: text;
}
} }
.online-status { .online-status-section {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: center;
font-size: 0.85rem; gap: 8px;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-weight: 500;
&.online {
color: $success;
background-color: rgba(76, 175, 80, 0.1);
.online-indicator { .online-indicator {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background-color: $success; background: $color-dark-primary;
}
}
&.offline { &.offline {
color: $color-dark-on-surface-variant; background: $color-dark-on-surface-variant;
background-color: rgba(255, 255, 255, 0.05);
.offline-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: $color-dark-on-surface-variant;
}
}
} }
} }
.bio-section { .status-text {
label { font-size: 0.875rem;
display: block;
color: $color-dark-on-surface-variant;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.bio-display {
color: $color-dark-on-surface; color: $color-dark-on-surface;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.75rem;
background-color: $color-dark-surface;
border-radius: 8px;
border: 1px solid $color-dark-outline;
min-height: 60px;
}
.bio-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
} }
} }
.profile-stats { .profile-sections {
margin: 16px;
border-radius: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 4px;
.stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
.stat-label {
color: $color-dark-on-surface-variant;
font-size: 0.85rem;
}
.stat-value {
color: $color-dark-on-surface;
font-size: 0.85rem;
font-weight: 500;
}
}
}
.profile-actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
mdui-button {
flex: 1;
}
}
}
}
// Cropper Dialog Styles
#cropper-dialog {
.cropper-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 500px;
max-width: 600px;
}
.cropper-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline;
h3 {
margin: 0;
color: $color-dark-on-surface;
}
}
.cropper-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background: $color-dark-surface-container;
border-radius: 8px;
overflow: hidden; overflow: hidden;
width: calc(100% - (16px * 2));
box-sizing: border-box;
#cropper-area { .section {
width: 100%; background: $color-dark-surface-container-high;
height: 100%; border-radius: 10px;
min-height: 400px; padding: 8px 16px;
}
}
.cropper-actions {
display: flex; display: flex;
gap: 12px; flex-direction: row;
justify-content: flex-end; gap: 16px;
padding-top: 16px; align-items: center;
border-top: 1px solid $color-dark-outline;
.content-container {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
.label {
font-size: small;
color: $color-dark-on-surface-variant;
}
.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;
}
}
}
}
}
}
.profile-dialog-fab {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 1002;
transform: translateY(100px);
transition: transform 0.3s ease;
&.visible {
transform: translateY(0);
}
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@
@use "chat-input"; @use "chat-input";
@use "message-reactions"; @use "message-reactions";
@use "context-menu"; @use "context-menu";
@use "profile-dialog";
@use "settings-dialog"; @use "settings-dialog";
@use "animations"; @use "animations";
@use "callWindow"; @use "callWindow";
@use "profile-dialog";
@@ -0,0 +1,51 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Cropper Dialog Styles
#cropper-dialog {
.cropper-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 500px;
max-width: 600px;
}
.cropper-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline;
h3 {
margin: 0;
color: $color-dark-on-surface;
}
}
.cropper-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background: $color-dark-surface-container;
border-radius: 8px;
overflow: hidden;
#cropper-area {
width: 100%;
height: 100%;
min-height: 400px;
}
}
.cropper-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid $color-dark-outline;
}
}
+31
View File
@@ -14,6 +14,16 @@ export type ChatTabs = "chats" | "channels" | "contacts";
export type CallStatus = "calling" | "connecting" | "active" | "ended"; export type CallStatus = "calling" | "connecting" | "active" | "ended";
export interface ProfileDialogData {
userId?: number;
username?: string;
profilePicture?: string;
bio?: string;
memberSince?: string;
online?: boolean;
isOwnProfile: boolean;
}
interface ActiveDM { interface ActiveDM {
userId: number; userId: number;
username: string; username: string;
@@ -50,6 +60,7 @@ interface ChatState {
dmPanel: DMPanel | null; dmPanel: DMPanel | null;
pendingPanel?: MessagePanel | null; pendingPanel?: MessagePanel | null;
call: CallState; call: CallState;
profileDialog: ProfileDialogData | null;
} }
export interface UserState { export interface UserState {
@@ -94,6 +105,10 @@ interface AppState {
setUser: (token: string, user: User) => void; setUser: (token: string, user: User) => void;
logout: () => void; logout: () => void;
restoreUserFromStorage: () => Promise<void>; restoreUserFromStorage: () => Promise<void>;
// Profile dialog state
setProfileDialog: (data: ProfileDialogData | null) => void;
closeProfileDialog: () => void;
} }
export const useAppState = create<AppState>((set, get) => ({ export const useAppState = create<AppState>((set, get) => ({
@@ -115,6 +130,7 @@ export const useAppState = create<AppState>((set, get) => ({
publicChatPanel: null, publicChatPanel: null,
dmPanel: null, dmPanel: null,
pendingPanel: null, pendingPanel: null,
profileDialog: null,
call: { call: {
isActive: false, isActive: false,
status: "ended", status: "ended",
@@ -577,5 +593,20 @@ export const useAppState = create<AppState>((set, get) => ({
isMinimized: !state.chat.call.isMinimized isMinimized: !state.chat.call.isMinimized
} }
} }
})),
// Profile dialog state management
setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({
chat: {
...state.chat,
profileDialog: data
}
})),
closeProfileDialog: () => set((state) => ({
chat: {
...state.chat,
profileDialog: null
}
})) }))
})); }));
@@ -0,0 +1,347 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { useAppState } from "@/pages/chat/state";
import type { ProfileDialogData } from "@/pages/chat/state";
import defaultAvatar from "@/images/default-avatar.png";
import { confirm } from "mdui/functions/confirm";
import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi";
import { RichTextArea } from "@/core/components/RichTextArea";
export function ProfileDialog() {
const { chat, user, closeProfileDialog } = useAppState();
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 fileInputRef = useRef<HTMLInputElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Handle dialog open/close based on state
useEffect(() => {
if (chat.profileDialog && !isOpen) {
// Fetch fresh data when opening dialog
fetchFreshProfileData(chat.profileDialog);
} else if (!chat.profileDialog && isOpen) {
// Start close animation
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.remove('open');
dialogRef.current.classList.remove('open');
// Wait for animation to complete before closing
setTimeout(() => {
setIsOpen(false);
}, 300); // Match CSS transition duration
} else {
setIsOpen(false);
}
}
}, [chat.profileDialog, isOpen]);
const fetchFreshProfileData = async (profileData: ProfileDialogData) => {
if (!user.authToken) return;
try {
let freshData = profileData;
// If it's not the public chat and has a username, fetch fresh data
if (profileData.username && profileData.username !== "Общий чат" && profileData.userId) {
const userProfile = await fetchUserProfile(user.authToken, profileData.username);
if (userProfile) {
freshData = {
userId: userProfile.id,
username: userProfile.username,
profilePicture: userProfile.profile_picture,
bio: userProfile.bio,
memberSince: userProfile.created_at,
online: userProfile.online,
isOwnProfile: profileData.isOwnProfile
};
}
}
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);
}
};
// Trigger transition after component mounts
useEffect(() => {
if (isOpen) {
// Small delay to ensure DOM is ready for transition
const timer = setTimeout(() => {
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.add('open');
dialogRef.current.classList.add('open');
}
}, 10);
return () => clearTimeout(timer);
}
}, [isOpen]);
// Handle ESC key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
handleClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}
}, [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.username) !== normalizeValue(currentData.username) ||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
originalData.profilePicture !== currentData.profilePicture
);
}, [originalData, currentData]);
const handleClose = async () => {
if (hasChanges) {
try {
await confirm({
headline: "Несохраненные изменения",
description: "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?",
confirmText: "Закрыть",
cancelText: "Отмена"
});
triggerCloseAnimation();
} catch {
// User cancelled, do nothing
}
} else {
triggerCloseAnimation();
}
};
const triggerCloseAnimation = () => {
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.remove('open');
dialogRef.current.classList.remove('open');
// Wait for animation to complete before closing
setTimeout(() => {
closeProfileDialog();
}, 300); // Match CSS transition duration
} else {
closeProfileDialog();
}
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
};
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!currentData) return;
setCurrentData({ ...currentData, username: e.target.value });
};
const handleBioChange = (newBio: string) => {
if (!currentData) return;
setCurrentData({ ...currentData, bio: newBio });
};
const handleProfilePictureClick = () => {
if (currentData?.isOwnProfile) {
fileInputRef.current?.click();
}
};
const 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);
}
};
const handleSave = async () => {
if (!currentData || !user.authToken || !originalData) return;
setIsSaving(true);
try {
// Update profile data
const updateData: any = {};
if (originalData.username !== currentData.username) {
updateData.nickname = currentData.username;
}
if (originalData.bio !== currentData.bio) {
updateData.description = currentData.bio;
}
if (Object.keys(updateData).length > 0) {
await updateProfile(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 uploadProfilePicture(user.authToken, blob);
}
}
// Update the original data to match current data
setOriginalData(currentData);
// Close dialog with animation after successful save
triggerCloseAnimation();
} catch (error) {
console.error("Failed to save profile:", error);
} finally {
setIsSaving(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
day: "numeric"
});
};
if (!isOpen || !currentData) return null;
return createPortal(
<div
ref={backdropRef}
className="profile-dialog-backdrop"
onClick={handleBackdropClick}
>
<div ref={dialogRef} className="profile-dialog">
<div className="profile-dialog-content">
{/* Profile Picture */}
<div className="profile-picture-section">
<img
className="profile-picture"
src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
{currentData.isOwnProfile && (
<div
className="profile-picture-edit-overlay"
onClick={handleProfilePictureClick}
>
<mdui-icon name="camera_alt--filled" />
</div>
)}
</div>
{/* Username */}
{currentData.username && (
<div className="username-section">
<input
className="username-input"
type="text"
value={currentData.username}
onChange={handleUsernameChange}
readOnly={!currentData.isOwnProfile}
placeholder="Имя пользователя"
/>
</div>
)}
{/* Online Status */}
{currentData.online !== undefined && (
<div className="online-status-section">
<span className={`online-indicator ${currentData.online ? "" : "offline"}`} />
<span className="status-text">
{currentData.online ? "Онлайн" : "Оффлайн"}
</span>
</div>
)}
<div className="profile-sections">
{/* Bio */}
{currentData.bio !== undefined && (
<div className="section bio">
<mdui-icon name="info--filled" />
<div className="content-container">
<label className="label">О себе:</label>
<RichTextArea
text={currentData.bio || ""}
onTextChange={handleBioChange}
placeholder="Нет информации о себе"
className="value"
rows={1}
readOnly={!currentData.isOwnProfile}
/>
</div>
</div>
)}
{/* Member Since */}
{currentData.memberSince && (
<div className="section member-since">
<mdui-icon name="calendar_month--filled" />
<div className="content-container">
<span className="label">Участник с:</span>
<span className="value">
{formatDate(currentData.memberSince)}
</span>
</div>
</div>
)}
</div>
</div>
{/* Save FAB */}
{currentData.isOwnProfile && (
<mdui-fab
icon="check"
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
onClick={handleSave}
disabled={isSaving}
/>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileSelect}
/>
</div>
</div>,
document.getElementById("root")!
);
}
+15 -5
View File
@@ -2,21 +2,32 @@ import { PRODUCT_NAME } from "@/core/config";
import useProfile from "@/pages/chat/hooks/useProfile"; import useProfile from "@/pages/chat/hooks/useProfile";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react"; import { useState } from "react";
import { ProfileDialog } from "./profile/ProfileDialog"; import { useAppState } from "@/pages/chat/state";
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar"; import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
export function ChatHeader() { export function ChatHeader() {
const { profileData } = useProfile(); const { profileData } = useProfile();
const [isProfileOpen, setIsProfileOpen] = useState(false); const { setProfileDialog, user } = useAppState();
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
const handleProfileClick = () => {
setProfileDialog({
userId: user.currentUser?.id,
username: profileData?.nickname || "Пользователь",
profilePicture: profileData?.profile_picture,
bio: profileData?.description,
memberSince: user.currentUser?.created_at,
online: user.currentUser?.online,
isOwnProfile: true
});
};
return ( return (
<> <>
<header className="chat-header-left"> <header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div> <div className="product-name">{PRODUCT_NAME}</div>
<div className="profile"> <div className="profile">
<a href="#" id="profile-open" onClick={() => setIsProfileOpen(true)}> <a href="#" id="profile-open" onClick={handleProfileClick}>
<img <img
src={profilePictureUrl} src={profilePictureUrl}
alt="" alt=""
@@ -26,7 +37,6 @@ export function ChatHeader() {
</div> </div>
</header> </header>
<MinimizedCallBar /> <MinimizedCallBar />
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
</> </>
); );
} }
@@ -1,3 +1,5 @@
import "./css/cropper-dialog.scss";
export function CropperDialog() { export function CropperDialog() {
return ( return (
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc> <mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
@@ -1,179 +0,0 @@
import { useState, useEffect, useRef, type FormEvent } from "react";
import defaultAvatar from "@/images/default-avatar.png";
import type { TextField } from "mdui/components/text-field";
import type { DialogProps } from "@/core/types";
import { MaterialDialog } from "@/core/components/Dialog";
import useProfile from "@/pages/chat/hooks/useProfile";
import { ImageCropper } from "./ImageCropper";
import { MaterialTextField } from "@/core/components/TextField";
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
const [username, setUsername] = useState(profileData?.nickname ?? "");
const [description, setDescription] = useState(profileData?.description ?? "");
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [showCropper, setShowCropper] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Update form fields when profile data changes
useEffect(() => {
if (profileData) {
setUsername(profileData.nickname || "");
setDescription(profileData.description || "");
}
}, [profileData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const success = await updateProfileData({
nickname: username.trim() || undefined,
description: description.trim() || undefined
});
if (success) {
onOpenChange(false);
}
};
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) {
setSelectedImage(file);
setShowCropper(true);
}
};
const handleCropComplete = async (croppedImageData: string) => {
try {
// Convert data URL to blob
const response = await fetch(croppedImageData);
const blob = await response.blob();
const success = await uploadProfilePictureData(blob);
if (success) {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
} catch (error) {
console.error('Error processing cropped image:', error);
}
};
const handleCropCancel = () => {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
return (
<>
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
<div className="content">
<div className="header-top">
<div className="profile-picture-container">
<img
id="profile-picture"
src={profilePictureUrl}
alt="Ваше фото"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
<mdui-button-icon
icon="camera_alt--filled"
id="upload-pfp-btn"
className="upload-overlay"
variant="filled"
onClick={handleUploadClick}
disabled={isUpdating}
/>
<input
ref={fileInputRef}
type="file"
id="pfp-file-input"
accept="image/*"
style={{ display: "none" }}
onChange={handleImageSelect}
/>
</div>
<MaterialTextField
id="username-field"
label="Имя пользователя"
variant="outlined"
value={username}
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
autocomplete="username"
disabled={isLoading || isUpdating} />
</div>
<form id="profile-form" onSubmit={handleSubmit}>
<MaterialTextField
id="description-field"
label="О себе"
variant="outlined"
value={description}
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
placeholder="Расскажите о себе..."
autocomplete="none"
disabled={isLoading || isUpdating} />
<div className="dialog-actions">
<mdui-button
type="submit"
id="profile-submit"
disabled={isLoading || isUpdating}
>
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
</mdui-button>
<mdui-button
id="profile-dialog-close"
variant="outlined"
onClick={() => onOpenChange(false)}
disabled={isUpdating}
>
Закрыть
</mdui-button>
</div>
</form>
</div>
</MaterialDialog>
{/* Image Cropper Dialog */}
<MaterialDialog
id="cropper-dialog"
close-on-overlay-click
close-on-esc
open={showCropper}
onOpenChange={setShowCropper}
>
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" onClick={handleCropCancel} />
</div>
<div className="cropper-container">
<ImageCropper
imageFile={selectedImage}
onCrop={handleCropComplete}
onCancel={handleCropCancel}
/>
</div>
</div>
</MaterialDialog>
</>
);
}
@@ -1,12 +1,8 @@
import { Message } from "./Message"; import { Message } from "./Message";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import type { Message as MessageType } from "@/core/types"; import type { Message as MessageType } from "@/core/types";
import type { UserProfile } from "@/core/types";
import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { fetchUserProfile } from "@/core/api/profileApi";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { delay } from "@/utils/utils";
import { MaterialDialog } from "@/core/components/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types"; import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
@@ -26,9 +22,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
const { user } = useAppState(); const { user } = useAppState();
// Use prop messages (panels provide their own messages) // Use prop messages (panels provide their own messages)
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
// Context menu state // Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({ const [contextMenu, setContextMenu] = useState<ContextMenuState>({
@@ -48,22 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
} }
}, [deleteDialogOpen]); }, [deleteDialogOpen]);
async function handleProfileClick(username: string) {
if (!user.authToken) return;
setIsLoadingProfile(true);
try {
const profile = await fetchUserProfile(user.authToken, username);
if (profile) {
setSelectedUserProfile(profile);
setProfileDialogOpen(true);
}
} catch (error) {
console.error("Failed to fetch user profile:", error);
} finally {
setIsLoadingProfile(false);
}
};
function handleContextMenu(e: React.MouseEvent, message: MessageType) { function handleContextMenu(e: React.MouseEvent, message: MessageType) {
e.preventDefault(); e.preventDefault();
@@ -158,27 +135,14 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) : (message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(message.username === user.currentUser?.username) (message.username === user.currentUser?.username)
} }
onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick} onReactionClick={handleReactionClick}
isLoadingProfile={isLoadingProfile}
isDm={isDm} isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} /> dmRecipientPublicKey={dmRecipientPublicKey} />
))} ))}
{children} {children}
</div> </div>
<UserProfileDialog
isOpen={profileDialogOpen}
onOpenChange={async (value) => {
setProfileDialogOpen(value);
if (!value) {
await delay(1000);
setSelectedUserProfile(null);
}
}}
userProfile={selectedUserProfile}
/>
<MaterialDialog <MaterialDialog
headline="Удалить сообщение?" headline="Удалить сообщение?"
+23 -12
View File
@@ -10,6 +10,7 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { getAuthHeaders } from "@/core/api/authApi"; import { getAuthHeaders } from "@/core/api/authApi";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { fetchUserProfile } from "@/core/api/profileApi";
import { ub64 } from "@/utils/utils"; import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -132,10 +133,8 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
interface MessageProps { interface MessageProps {
message: MessageType; message: MessageType;
isAuthor: boolean; isAuthor: boolean;
onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void; onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
onReactionClick?: (messageId: number, emoji: string) => void; onReactionClick?: (messageId: number, emoji: string) => void;
isLoadingProfile?: boolean;
isDm?: boolean; isDm?: boolean;
dmRecipientPublicKey?: string; dmRecipientPublicKey?: string;
} }
@@ -147,7 +146,7 @@ interface Rect {
height: number height: number
} }
export function Message({ message, isAuthor, onProfileClick, onContextMenu, onReactionClick, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) { export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
const [formattedMessage, setFormattedMessage] = useState({ __html: "" }); const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map()); const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set()); const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
@@ -161,7 +160,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
endRect: Rect; endRect: Rect;
} | null>(null); } | null>(null);
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
const { user } = useAppState(); const { user, setProfileDialog } = useAppState();
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map()); const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope; const dmEnvelope = message.runtimeData?.dmEnvelope;
@@ -389,6 +388,22 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
} }
}; };
async function handleProfileClick() {
if (!user.authToken || !message.username) return;
try {
const userProfile = await fetchUserProfile(user.authToken, message.username);
if (userProfile) {
setProfileDialog({
...userProfile,
isOwnProfile: false
});
}
} catch (error) {
console.error("Failed to fetch user profile:", error);
}
}
function handleContextMenu(e: React.MouseEvent) { function handleContextMenu(e: React.MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -402,15 +417,11 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
data-id={message.id} data-id={message.id}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<div className="message-inner">
{!isAuthor && !isDm && ( {!isAuthor && !isDm && (
<div className="message-profile-pic"> <div className="message-profile-pic" onClick={handleProfileClick}>
<img <img
src={message.profile_picture || defaultAvatar} src={message.profile_picture || defaultAvatar}
alt={message.username} alt={message.username}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
className={isLoadingProfile ? "loading" : ""}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.src = defaultAvatar; target.src = defaultAvatar;
@@ -419,11 +430,11 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
</div> </div>
)} )}
<div className="message-inner">
{!isAuthor && !isDm && ( {!isAuthor && !isDm && (
<div <div
className={`message-username ${isLoadingProfile ? "loading" : ""}`} className="message-username"
onClick={() => !isLoadingProfile && onProfileClick(message.username)} onClick={handleProfileClick}>
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
{message.username} {message.username}
</div> </div>
)} )}
@@ -3,6 +3,7 @@ import { useAppState } from "@/pages/chat/state";
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
import { ChatMessages } from "./ChatMessages"; import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper"; import { ChatInputWrapper } from "./ChatInputWrapper";
import { ProfileDialog } from "../ProfileDialog";
import { setGlobalMessageHandler } from "@/core/websocket"; import { setGlobalMessageHandler } from "@/core/websocket";
import type { Message, WebSocketMessage } from "@/core/types"; import type { Message, WebSocketMessage } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
@@ -15,7 +16,7 @@ interface MessagePanelRendererProps {
} }
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, chat } = useAppState(); const { applyPendingPanel, chat, setProfileDialog } = useAppState();
const messagePanelRef = useRef<HTMLDivElement>(null); const messagePanelRef = useRef<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null); const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const [switchIn, setSwitchIn] = useState(false); const [switchIn, setSwitchIn] = useState(false);
@@ -170,6 +171,19 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
} }
}; };
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);
}
}
return ( return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}> <div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div <div
@@ -213,7 +227,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
src={panelState?.profilePicture || defaultAvatar} src={panelState?.profilePicture || defaultAvatar}
alt="Avatar" alt="Avatar"
className="chat-header-avatar" className="chat-header-avatar"
onClick={panel?.handleProfileClick} onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }} style={{ cursor: panel ? "pointer" : "default" }}
/> />
<div className="chat-header-info"> <div className="chat-header-info">
@@ -344,6 +358,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</> </>
)} )}
</div> </div>
{/* Profile Dialog */}
<ProfileDialog />
</div> </div>
); );
} }
@@ -1,71 +0,0 @@
import type { DialogProps } from "@/core/types";
import type { UserProfile } from "@/core/types";
import { MaterialDialog } from "@/core/components/Dialog";
import { formatTime } from "@/utils/utils";
import defaultAvatar from "@/images/default-avatar.png";
interface UserProfileDialogProps extends DialogProps {
userProfile: UserProfile | null;
}
export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserProfileDialogProps) {
const content = userProfile ? (
<div className="content">
<div className="profile-picture-section">
<img
className="profile-picture"
alt="Profile Picture"
src={userProfile.profile_picture || defaultAvatar}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
</div>
<div className="profile-info">
<div className="username-section">
<h4 className="username">{userProfile.username}</h4>
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
{userProfile.online ? (
<>
<span className="online-indicator"></span> Онлайн
</>
) : (
<>
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
</>
)}
</div>
</div>
<div className="bio-section">
<label>О себе:</label>
<div className="bio-display">
{userProfile.bio || "No bio available."}
</div>
</div>
<div className="profile-stats">
<div className="stat">
<span className="stat-label">Зарегистрирован:</span>
<span className="stat-value member-since">{formatTime(userProfile.created_at)}</span>
</div>
<div className="stat">
<span className="stat-label">Last seen:</span>
<span className="stat-value last-seen">{formatTime(userProfile.last_seen)}</span>
</div>
</div>
<div className="profile-actions">
<mdui-button id="dm-button" variant="filled">
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
Send Message
</mdui-button>
</div>
</div>
</div>
) : null
return (
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc id="user-profile-dialog">
{content}
</MaterialDialog>
);
}
@@ -7,8 +7,9 @@ import {
editDmEnvelope, editDmEnvelope,
deleteDmEnvelope deleteDmEnvelope
} from "@/core/api/dmApi"; } from "@/core/api/dmApi";
import { fetchUserProfile } from "@/core/api/profileApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState } from "@/pages/chat/state"; import type { UserState, ProfileDialogData } from "@/pages/chat/state";
import { formatDMUsername } from "@/pages/chat/hooks/useDM"; import { formatDMUsername } from "@/pages/chat/hooks/useDM";
export interface DMPanelData { export interface DMPanelData {
@@ -322,7 +323,27 @@ export class DMPanel extends MessagePanel {
}); });
} }
handleProfileClick(): void {} async getProfile(): Promise<ProfileDialogData | null> {
if (!this.dmData || !this.currentUser.authToken) return null;
try {
const userProfile = await fetchUserProfile(this.currentUser.authToken, this.dmData.username);
if (!userProfile) return null;
return {
userId: userProfile.id,
username: userProfile.username,
profilePicture: userProfile.profile_picture,
bio: userProfile.bio,
memberSince: userProfile.created_at,
online: userProfile.online,
isOwnProfile: false
};
} catch (error) {
console.error("Failed to fetch user profile:", error);
return null;
}
}
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void { updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
const messages = this.getMessages(); const messages = this.getMessages();
@@ -1,5 +1,5 @@
import type { Message, WebSocketMessage } from "@/core/types"; import type { Message, WebSocketMessage } from "@/core/types";
import type { UserState } from "@/pages/chat/state"; import type { UserState, ProfileDialogData } from "@/pages/chat/state";
export interface MessagePanelState { export interface MessagePanelState {
id: string; id: string;
@@ -47,6 +47,7 @@ export abstract class MessagePanel {
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>; protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
abstract isDm(): boolean; abstract isDm(): boolean;
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>; abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
abstract getProfile(): Promise<ProfileDialogData | null>;
// Common methods // Common methods
protected updateState(updates: Partial<MessagePanelState>): void { protected updateState(updates: Partial<MessagePanelState>): void {
@@ -349,5 +350,4 @@ export abstract class MessagePanel {
abstract handleEditMessage(messageId: number, content: string): Promise<void>; abstract handleEditMessage(messageId: number, content: string): Promise<void>;
abstract handleDeleteMessage(messageId: number): Promise<void>; abstract handleDeleteMessage(messageId: number): Promise<void>;
abstract handleProfileClick(): void;
} }
@@ -3,7 +3,7 @@ import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "@/core/api/authApi"; import { getAuthHeaders } from "@/core/api/authApi";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types"; import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types";
import type { UserState } from "@/pages/chat/state"; import type { UserState, ProfileDialogData } from "@/pages/chat/state";
export class PublicChatPanel extends MessagePanel { export class PublicChatPanel extends MessagePanel {
private messagesLoaded: boolean = false; private messagesLoaded: boolean = false;
@@ -197,5 +197,11 @@ export class PublicChatPanel extends MessagePanel {
}); });
} }
handleProfileClick(): void {} async getProfile(): Promise<ProfileDialogData | null> {
return {
username: "Общий чат",
bio: "Общаемся со всеми пользователями FromChat!",
isOwnProfile: false
};
}
} }