mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement usernames and profile sections
This commit is contained in:
@@ -105,7 +105,7 @@ export default function LoginPage() {
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
label="@Имя пользователя"
|
||||
id="login-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function RegisterPage() {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const displayNameElement = useRef<TextField>(null);
|
||||
const usernameElement = useRef<TextField>(null);
|
||||
const passwordElement = useRef<TextField>(null);
|
||||
const confirmPasswordElement = useRef<TextField>(null);
|
||||
@@ -36,11 +37,12 @@ export default function RegisterPage() {
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const displayName = displayNameElement.current!.value.trim();
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password || !confirmPassword) {
|
||||
if (!displayName || !username || !password || !confirmPassword) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
@@ -50,11 +52,22 @@ export default function RegisterPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayName.length < 1 || displayName.length > 64) {
|
||||
showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate username format (only English letters, numbers, dashes, underscores)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 5 || password.length > 50) {
|
||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||
return;
|
||||
@@ -62,6 +75,7 @@ export default function RegisterPage() {
|
||||
|
||||
try {
|
||||
const request: RegisterRequest = {
|
||||
display_name: displayName,
|
||||
username: username,
|
||||
password: password,
|
||||
confirm_password: confirmPassword
|
||||
@@ -97,7 +111,18 @@ export default function RegisterPage() {
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
label="Отображаемое имя"
|
||||
id="register-display-name"
|
||||
name="display_name"
|
||||
variant="outlined"
|
||||
icon="badge--filled"
|
||||
autocomplete="name"
|
||||
maxlength={64}
|
||||
counter
|
||||
required
|
||||
ref={displayNameElement} />
|
||||
<MaterialTextField
|
||||
label="@Имя пользователя"
|
||||
id="register-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.error-message {
|
||||
color: $color-dark-error;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.profile-picture-section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -134,15 +139,15 @@
|
||||
|
||||
.profile-sections {
|
||||
margin: 16px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
width: calc(100% - (16px * 2));
|
||||
box-sizing: border-box;
|
||||
|
||||
.section {
|
||||
$edge-radius: 24px;
|
||||
|
||||
background: $color-dark-surface-container-high;
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px;
|
||||
@@ -150,6 +155,9 @@
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
transition: outline 0.1s ease;
|
||||
outline: 0px solid transparent;
|
||||
outline-offset: -1px;
|
||||
|
||||
.content-container {
|
||||
display: flex;
|
||||
@@ -179,6 +187,25 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First and last section
|
||||
&:first-child {
|
||||
border-top-left-radius: $edge-radius;
|
||||
border-top-right-radius: $edge-radius;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: $edge-radius;
|
||||
border-bottom-right-radius: $edge-radius;
|
||||
}
|
||||
|
||||
&.error {
|
||||
outline: 1px solid $color-dark-error;
|
||||
|
||||
.error-message {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useDM() {
|
||||
|
||||
decryptedMessages.push({
|
||||
id: env.id,
|
||||
user_id: env.senderId,
|
||||
content: text,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
|
||||
@@ -19,6 +19,7 @@ export type CallStatus = "calling" | "connecting" | "active" | "ended";
|
||||
export interface ProfileDialogData {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
profilePicture?: string;
|
||||
bio?: string;
|
||||
memberSince?: string;
|
||||
|
||||
@@ -1,20 +1,78 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, type ReactNode } 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 { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
|
||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { OnlineStatus } from "./right/OnlineStatus";
|
||||
|
||||
interface SectionProps {
|
||||
type: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly: boolean;
|
||||
placeholder: string;
|
||||
textArea?: boolean;
|
||||
}
|
||||
|
||||
function Section({ type, icon, label, error, value, onChange, readOnly, placeholder, textArea = false }: SectionProps) {
|
||||
let valueComponent: ReactNode = null;
|
||||
|
||||
if (onChange) {
|
||||
if (textArea) {
|
||||
valueComponent = (
|
||||
<RichTextArea
|
||||
text={value || ""}
|
||||
onTextChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className="value"
|
||||
rows={1}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
valueComponent = (
|
||||
<input
|
||||
className="value"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
readOnly={readOnly} />
|
||||
);
|
||||
}
|
||||
} else {
|
||||
valueComponent = (
|
||||
<span className="value">{value}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`section ${type} ${error ? 'error' : ''}`}>
|
||||
<mdui-icon name={icon} />
|
||||
<div className="content-container">
|
||||
<label className="label">{label}</label>
|
||||
{valueComponent}
|
||||
{error && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileDialog() {
|
||||
const { chat, user, closeProfileDialog } = useAppState();
|
||||
const { chat, user, closeProfileDialog, setUser } = 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 [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
@@ -40,23 +98,19 @@ export function ProfileDialog() {
|
||||
}
|
||||
}, [chat.profileDialog, isOpen]);
|
||||
|
||||
const fetchFreshProfileData = async (profileData: ProfileDialogData) => {
|
||||
async function fetchFreshProfileData(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 it's not the public chat and has a user ID, fetch fresh data
|
||||
if (profileData.userId && profileData.username !== "Общий чат") {
|
||||
const userProfile = await fetchUserProfileById(user.authToken, profileData.userId);
|
||||
if (userProfile) {
|
||||
freshData = {
|
||||
userId: userProfile.id,
|
||||
username: userProfile.username,
|
||||
profilePicture: userProfile.profile_picture,
|
||||
bio: userProfile.bio,
|
||||
...userProfile,
|
||||
memberSince: userProfile.created_at,
|
||||
online: userProfile.online,
|
||||
isOwnProfile: profileData.isOwnProfile
|
||||
};
|
||||
}
|
||||
@@ -72,7 +126,7 @@ export function ProfileDialog() {
|
||||
setCurrentData(profileData);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Trigger transition after component mounts
|
||||
useEffect(() => {
|
||||
@@ -90,13 +144,13 @@ export function ProfileDialog() {
|
||||
|
||||
// Handle ESC key
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}
|
||||
@@ -117,6 +171,13 @@ export function ProfileDialog() {
|
||||
}
|
||||
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
||||
|
||||
// Validate fields when data changes
|
||||
useEffect(() => {
|
||||
if (currentData && isOpen) {
|
||||
validateFields();
|
||||
}
|
||||
}, [currentData, isOpen]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!originalData || !currentData) return false;
|
||||
|
||||
@@ -127,13 +188,14 @@ export function ProfileDialog() {
|
||||
};
|
||||
|
||||
return (
|
||||
normalizeValue(originalData.display_name) !== normalizeValue(currentData.display_name) ||
|
||||
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
|
||||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
|
||||
originalData.profilePicture !== currentData.profilePicture
|
||||
);
|
||||
}, [originalData, currentData]);
|
||||
|
||||
const handleClose = async () => {
|
||||
async function handleClose() {
|
||||
if (hasChanges) {
|
||||
try {
|
||||
await confirm({
|
||||
@@ -151,7 +213,7 @@ export function ProfileDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const triggerCloseAnimation = () => {
|
||||
function triggerCloseAnimation() {
|
||||
if (backdropRef.current && dialogRef.current) {
|
||||
backdropRef.current.classList.remove('open');
|
||||
dialogRef.current.classList.remove('open');
|
||||
@@ -165,29 +227,41 @@ export function ProfileDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
function handleBackdropClick(e: React.MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (!currentData) return;
|
||||
setCurrentData({ ...currentData, username: e.target.value });
|
||||
const newValue = e.target.value;
|
||||
setCurrentData({ ...currentData, display_name: newValue });
|
||||
|
||||
// Validate display name in real-time
|
||||
validateDisplayName(newValue);
|
||||
};
|
||||
|
||||
const handleBioChange = (newBio: string) => {
|
||||
function handleUsernameChange(value: string) {
|
||||
if (!currentData) return;
|
||||
setCurrentData({ ...currentData, username: value });
|
||||
|
||||
// Validate username in real-time
|
||||
validateUsername(value);
|
||||
};
|
||||
|
||||
function handleBioChange(newBio: string) {
|
||||
if (!currentData) return;
|
||||
setCurrentData({ ...currentData, bio: newBio });
|
||||
};
|
||||
|
||||
const handleProfilePictureClick = () => {
|
||||
function handleProfilePictureClick() {
|
||||
if (currentData?.isOwnProfile) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
// Open cropper dialog here - for now just update the image
|
||||
@@ -202,15 +276,60 @@ export function ProfileDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
function validateDisplayName(value: string) {
|
||||
let error = "";
|
||||
|
||||
if (!value || value.trim().length === 0) {
|
||||
error = "Отображаемое имя не может быть пустым";
|
||||
} else if (value.length > 64) {
|
||||
error = "Отображаемое имя не может быть длиннее 64 символов";
|
||||
}
|
||||
|
||||
setErrors(prev => ({ ...prev, display_name: error }));
|
||||
};
|
||||
|
||||
function validateUsername(value: string) {
|
||||
let error = "";
|
||||
|
||||
if (!value || value.trim().length === 0) {
|
||||
error = "Имя пользователя не может быть пустым";
|
||||
} else if (value.length < 3) {
|
||||
error = "Имя пользователя должно быть не менее 3 символов";
|
||||
} else if (value.length > 20) {
|
||||
error = "Имя пользователя не может быть длиннее 20 символов";
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
error = "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания";
|
||||
}
|
||||
|
||||
setErrors(prev => ({ ...prev, username: error }));
|
||||
};
|
||||
|
||||
function validateFields() {
|
||||
if (currentData) {
|
||||
validateDisplayName(currentData.display_name || "");
|
||||
validateUsername(currentData.username || "");
|
||||
}
|
||||
|
||||
return !errors.display_name && !errors.username;
|
||||
};
|
||||
|
||||
async function handleSave() {
|
||||
if (!currentData || !user.authToken || !originalData) return;
|
||||
|
||||
// Validate fields first
|
||||
if (!validateFields()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Update profile data
|
||||
const updateData: any = {};
|
||||
if (originalData.display_name !== currentData.display_name) {
|
||||
updateData.display_name = currentData.display_name;
|
||||
}
|
||||
if (originalData.username !== currentData.username) {
|
||||
updateData.nickname = currentData.username;
|
||||
updateData.username = currentData.username;
|
||||
}
|
||||
if (originalData.bio !== currentData.bio) {
|
||||
updateData.description = currentData.bio;
|
||||
@@ -233,22 +352,51 @@ export function ProfileDialog() {
|
||||
// Update the original data to match current data
|
||||
setOriginalData(currentData);
|
||||
|
||||
// If this is the current user's profile and username was changed, update the current user data
|
||||
if (currentData.isOwnProfile && user.currentUser && user.authToken) {
|
||||
const updatedUser = {
|
||||
...user.currentUser,
|
||||
username: currentData.username || user.currentUser.username,
|
||||
display_name: currentData.display_name || user.currentUser.display_name,
|
||||
bio: currentData.bio || user.currentUser.bio,
|
||||
profile_picture: currentData.profilePicture || user.currentUser.profile_picture
|
||||
};
|
||||
setUser(user.authToken, updatedUser);
|
||||
}
|
||||
|
||||
// Close dialog with animation after successful save
|
||||
triggerCloseAnimation();
|
||||
} catch (error) {
|
||||
console.error("Failed to save profile:", error);
|
||||
// Handle API errors
|
||||
if (error instanceof Error && error.message.includes("уже занято")) {
|
||||
setErrors({ username: "Это имя пользователя уже занято" });
|
||||
} else {
|
||||
setErrors({ general: "Ошибка при сохранении профиля" });
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const fabVisible = useMemo(() => {
|
||||
let hasErrors = false;
|
||||
Object.values(errors).forEach(error => {
|
||||
if (error) {
|
||||
hasErrors = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors;
|
||||
}, [hasChanges, currentData?.isOwnProfile, isSaving, errors]);
|
||||
|
||||
if (!isOpen || !currentData) return null;
|
||||
|
||||
@@ -281,57 +429,64 @@ export function ProfileDialog() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
{currentData.username && (
|
||||
<div className="username-section">
|
||||
<input
|
||||
className="username-input"
|
||||
type="text"
|
||||
value={currentData.username}
|
||||
onChange={handleUsernameChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Имя пользователя"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Display Name */}
|
||||
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
|
||||
<input
|
||||
className="username-input"
|
||||
type="text"
|
||||
value={currentData.display_name}
|
||||
onChange={handleDisplayNameChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Имя"
|
||||
/>
|
||||
{errors.display_name && (
|
||||
<div className="error-message">{errors.display_name}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Online Status */}
|
||||
{currentData?.userId && (
|
||||
{(currentData?.userId || currentData?.isOwnProfile) && (
|
||||
<div className="online-status-section">
|
||||
<OnlineStatus userId={currentData.userId} />
|
||||
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-sections">
|
||||
<Section
|
||||
type="username"
|
||||
error={errors.username}
|
||||
icon="alternate_email--filled"
|
||||
label="Имя пользователя:"
|
||||
value={currentData.username}
|
||||
onChange={handleUsernameChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="username" />
|
||||
|
||||
|
||||
{/* 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>
|
||||
<Section
|
||||
type="bio"
|
||||
icon="info--filled"
|
||||
label="О себе:"
|
||||
value={currentData.bio}
|
||||
onChange={handleBioChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Нет информации о себе"
|
||||
textArea
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<Section
|
||||
type="member-since"
|
||||
icon="calendar_month--filled"
|
||||
label="Участник с:"
|
||||
value={formatDate(currentData.memberSince)}
|
||||
readOnly={true}
|
||||
placeholder="Участник с:"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +495,7 @@ export function ProfileDialog() {
|
||||
{currentData.isOwnProfile && (
|
||||
<mdui-fab
|
||||
icon="check"
|
||||
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
|
||||
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,8 @@ export function ChatHeader() {
|
||||
const handleProfileClick = () => {
|
||||
setProfileDialog({
|
||||
userId: user.currentUser?.id,
|
||||
username: profileData?.nickname || "Пользователь",
|
||||
username: profileData?.username || "Пользователь",
|
||||
display_name: profileData?.display_name || "Пользователь",
|
||||
profilePicture: profileData?.profile_picture,
|
||||
bio: profileData?.description,
|
||||
memberSince: user.currentUser?.created_at,
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { useAppState, type ChatTabs } from "@/pages/chat/state";
|
||||
import { UnifiedChatsList } from "./UnifiedChatsList";
|
||||
import type { FormEvent } from "react";
|
||||
import type { Tabs } from "mdui/components/tabs";
|
||||
|
||||
export function ChatTabs() {
|
||||
const { chat, setActiveTab } = useAppState();
|
||||
|
||||
function handleChange(e: FormEvent<Tabs> & CustomEvent<{ value: string }>) {
|
||||
setActiveTab(e.detail.value as ChatTabs);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs
|
||||
value={chat.activeTab}
|
||||
full-width
|
||||
onChange={handleChange}>
|
||||
onChange={(e) => setActiveTab((e.target as Tabs).value as ChatTabs)}>
|
||||
<mdui-tab value="chats">
|
||||
Чаты
|
||||
</mdui-tab>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||
import type { Message } from "@/core/types";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { OnlineIndicator } from "../right/OnlineIndicator";
|
||||
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
|
||||
interface PublicChat {
|
||||
@@ -20,6 +20,7 @@ interface PublicChat {
|
||||
interface DMConversation {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
profile_picture?: string;
|
||||
online?: boolean;
|
||||
type: "dm";
|
||||
@@ -84,6 +85,7 @@ export function UnifiedChatsList() {
|
||||
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
profile_picture: user.profile_picture,
|
||||
online: user.online,
|
||||
type: "dm" as const,
|
||||
@@ -172,13 +174,13 @@ export function UnifiedChatsList() {
|
||||
};
|
||||
}, [allChats]);
|
||||
|
||||
const formatPublicChatMessage = (chatId: string): string => {
|
||||
function formatPublicChatMessage(chatId: string): string {
|
||||
const lastMessage = lastMessages[chatId];
|
||||
if (!lastMessage) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const isCurrentUser = lastMessage.username === user.currentUser?.username;
|
||||
const isCurrentUser = lastMessage.user_id === user.currentUser?.id;
|
||||
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
|
||||
|
||||
const maxContentLength = 50 - prefix.length;
|
||||
@@ -187,14 +189,13 @@ export function UnifiedChatsList() {
|
||||
: lastMessage.content;
|
||||
|
||||
return prefix + content;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const handlePublicChatClick = async (chatName: string) => {
|
||||
async function handlePublicChatClick(chatName: string) {
|
||||
await switchToPublicChat(chatName);
|
||||
};
|
||||
}
|
||||
|
||||
const handleDMClick = async (dmConversation: DMConversation) => {
|
||||
async function handleDMClick(dmConversation: DMConversation) {
|
||||
if (!dmConversation.publicKey) {
|
||||
const authToken = useAppState.getState().user.authToken;
|
||||
if (!authToken) return;
|
||||
@@ -215,7 +216,7 @@ export function UnifiedChatsList() {
|
||||
profilePicture: dmConversation.profile_picture,
|
||||
online: dmConversation.online || false
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return (
|
||||
@@ -256,7 +257,7 @@ export function UnifiedChatsList() {
|
||||
return (
|
||||
<mdui-list-item
|
||||
key={`dm-${chat.id}`}
|
||||
headline={chat.username}
|
||||
headline={chat.display_name}
|
||||
onClick={() => handleDMClick(chat)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
@@ -266,7 +267,7 @@ export function UnifiedChatsList() {
|
||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.username}
|
||||
alt={chat.display_name}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
|
||||
@@ -133,7 +133,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
message={message}
|
||||
isAuthor={isDm ?
|
||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(message.username === user.currentUser?.username)
|
||||
(message.user_id === user.currentUser?.id)
|
||||
}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReactionClick={handleReactionClick}
|
||||
@@ -158,7 +158,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
message={contextMenu.message}
|
||||
isAuthor={isDm ?
|
||||
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(contextMenu.message.username === user.currentUser?.username)
|
||||
(contextMenu.message.user_id === user.currentUser?.id)
|
||||
}
|
||||
onEdit={handleEdit}
|
||||
onReply={handleReply}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||
import { fetchUserProfileById } from "@/core/api/profileApi";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -389,10 +389,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
||||
};
|
||||
|
||||
async function handleProfileClick() {
|
||||
if (!user.authToken || !message.username) return;
|
||||
if (!user.authToken || !message.user_id) return;
|
||||
|
||||
try {
|
||||
const userProfile = await fetchUserProfile(user.authToken, message.username);
|
||||
const userProfile = await fetchUserProfileById(user.authToken, message.user_id);
|
||||
if (userProfile) {
|
||||
setProfileDialog({
|
||||
...userProfile,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope
|
||||
} from "@/core/api/dmApi";
|
||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||
import { fetchUserProfileById } from "@/core/api/profileApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||
@@ -84,6 +84,7 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
const dmMsg: Message = {
|
||||
id: env.id,
|
||||
user_id: env.senderId,
|
||||
content: content,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
@@ -353,12 +354,13 @@ export class DMPanel extends MessagePanel {
|
||||
if (!this.dmData || !this.currentUser.authToken) return null;
|
||||
|
||||
try {
|
||||
const userProfile = await fetchUserProfile(this.currentUser.authToken, this.dmData.username);
|
||||
const userProfile = await fetchUserProfileById(this.currentUser.authToken, this.dmData.userId);
|
||||
if (!userProfile) return null;
|
||||
|
||||
return {
|
||||
userId: userProfile.id,
|
||||
username: userProfile.username,
|
||||
display_name: userProfile.display_name,
|
||||
profilePicture: userProfile.profile_picture,
|
||||
bio: userProfile.bio,
|
||||
memberSince: userProfile.created_at,
|
||||
|
||||
@@ -258,6 +258,7 @@ export abstract class MessagePanel {
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const tempMessage: Message = {
|
||||
id: -1, // Temporary negative ID
|
||||
user_id: this.currentUser.currentUser?.id ?? -1,
|
||||
username: this.currentUser.currentUser?.username ?? "You",
|
||||
content: content.trim(),
|
||||
is_read: false,
|
||||
|
||||
@@ -121,7 +121,7 @@ export class PublicChatPanel extends MessagePanel {
|
||||
const newMsg = response.data;
|
||||
|
||||
// Check if this is a confirmation of a message we sent
|
||||
const isOurMessage = newMsg.username === this.currentUser.currentUser?.username;
|
||||
const isOurMessage = newMsg.user_id === this.currentUser.currentUser?.id;
|
||||
if (isOurMessage) {
|
||||
// This is our message being confirmed, find the temp message and replace it
|
||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||
@@ -199,7 +199,8 @@ export class PublicChatPanel extends MessagePanel {
|
||||
|
||||
async getProfile(): Promise<ProfileDialogData | null> {
|
||||
return {
|
||||
username: "Общий чат",
|
||||
username: "general",
|
||||
display_name: "Общий чат",
|
||||
bio: "Общаемся со всеми пользователями FromChat!",
|
||||
isOwnProfile: false
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user