mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement profile dialog
This commit is contained in:
@@ -1,12 +1,8 @@
|
||||
import { Message } from "./Message";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
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 { fetchUserProfile } from "@/core/api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "@/utils/utils";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
|
||||
@@ -26,9 +22,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
const { user } = useAppState();
|
||||
|
||||
// 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
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
@@ -48,22 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
}
|
||||
}, [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) {
|
||||
e.preventDefault();
|
||||
@@ -158,27 +135,14 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(message.username === user.currentUser?.username)
|
||||
}
|
||||
onProfileClick={handleProfileClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReactionClick={handleReactionClick}
|
||||
isLoadingProfile={isLoadingProfile}
|
||||
isDm={isDm}
|
||||
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<UserProfileDialog
|
||||
isOpen={profileDialogOpen}
|
||||
onOpenChange={async (value) => {
|
||||
setProfileDialogOpen(value);
|
||||
if (!value) {
|
||||
await delay(1000);
|
||||
setSelectedUserProfile(null);
|
||||
}
|
||||
}}
|
||||
userProfile={selectedUserProfile}
|
||||
/>
|
||||
|
||||
<MaterialDialog
|
||||
headline="Удалить сообщение?"
|
||||
|
||||
@@ -10,6 +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 { ub64 } from "@/utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -132,10 +133,8 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
isAuthor: boolean;
|
||||
onProfileClick: (username: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => void;
|
||||
isLoadingProfile?: boolean;
|
||||
isDm?: boolean;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
@@ -147,7 +146,7 @@ interface Rect {
|
||||
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 [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||
@@ -161,7 +160,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
||||
endRect: Rect;
|
||||
} | null>(null);
|
||||
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
|
||||
const { user } = useAppState();
|
||||
const { user, setProfileDialog } = useAppState();
|
||||
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||
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) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -402,28 +417,24 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
||||
data-id={message.id}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-profile-pic" onClick={handleProfileClick}>
|
||||
<img
|
||||
src={message.profile_picture || defaultAvatar}
|
||||
alt={message.username}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-inner">
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-profile-pic">
|
||||
<img
|
||||
src={message.profile_picture || defaultAvatar}
|
||||
alt={message.username}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
|
||||
className={isLoadingProfile ? "loading" : ""}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthor && !isDm && (
|
||||
<div
|
||||
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
|
||||
className="message-username"
|
||||
onClick={handleProfileClick}>
|
||||
{message.username}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAppState } from "@/pages/chat/state";
|
||||
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { ProfileDialog } from "../ProfileDialog";
|
||||
import { setGlobalMessageHandler } from "@/core/websocket";
|
||||
import type { Message, WebSocketMessage } from "@/core/types";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
@@ -15,7 +16,7 @@ interface MessagePanelRendererProps {
|
||||
}
|
||||
|
||||
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const { applyPendingPanel, chat } = useAppState();
|
||||
const { applyPendingPanel, chat, setProfileDialog } = useAppState();
|
||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||
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 (
|
||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||
<div
|
||||
@@ -213,7 +227,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={panel?.handleProfileClick}
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
@@ -344,6 +358,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile Dialog */}
|
||||
<ProfileDialog />
|
||||
</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,
|
||||
deleteDmEnvelope
|
||||
} from "@/core/api/dmApi";
|
||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||
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";
|
||||
|
||||
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 {
|
||||
const messages = this.getMessages();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
id: string;
|
||||
@@ -47,6 +47,7 @@ export abstract class MessagePanel {
|
||||
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
|
||||
abstract isDm(): boolean;
|
||||
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
|
||||
abstract getProfile(): Promise<ProfileDialogData | null>;
|
||||
|
||||
// Common methods
|
||||
protected updateState(updates: Partial<MessagePanelState>): void {
|
||||
@@ -349,5 +350,4 @@ export abstract class MessagePanel {
|
||||
|
||||
abstract handleEditMessage(messageId: number, content: string): 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 { request } from "@/core/websocket";
|
||||
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 {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user