mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement chat panels
This commit is contained in:
@@ -11,5 +11,7 @@ When working with this project, follow these rules:
|
|||||||
|
|
||||||
- To typecheck, run "npm run frontend:typecheck".
|
- To typecheck, run "npm run frontend:typecheck".
|
||||||
- To build, run "npm run frontend:build".
|
- To build, run "npm run frontend:build".
|
||||||
|
|
||||||
|
Do NOT execute other commands like "cd".
|
||||||
- Do NOT "cd" to the project directory.
|
- Do NOT "cd" to the project directory.
|
||||||
- If possible, try to update files in a single edit.
|
- If possible, try to update files in a single edit.
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useChat } from "../../hooks/useChat";
|
import { useChat } from "../../hooks/useChat";
|
||||||
|
|
||||||
export function ChatInputWrapper() {
|
interface ChatInputWrapperProps {
|
||||||
|
onSendMessage?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const { sendMessage } = useChat();
|
const { sendMessage } = useChat();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (message.trim()) {
|
if (message.trim()) {
|
||||||
await sendMessage(message);
|
if (onSendMessage) {
|
||||||
|
onSendMessage(message);
|
||||||
|
} else {
|
||||||
|
await sendMessage(message);
|
||||||
|
}
|
||||||
setMessage("");
|
setMessage("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,9 +10,16 @@ import { useState } from "react";
|
|||||||
import { delay } from "../../../utils/utils";
|
import { delay } from "../../../utils/utils";
|
||||||
import { request } from "../../../websocket";
|
import { request } from "../../../websocket";
|
||||||
|
|
||||||
export function ChatMessages() {
|
interface ChatMessagesProps {
|
||||||
const { messages } = useChat();
|
messages?: MessageType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessages({ messages: propMessages }: ChatMessagesProps) {
|
||||||
|
const { messages: hookMessages } = useChat();
|
||||||
const { user } = useAppState();
|
const { user } = useAppState();
|
||||||
|
|
||||||
|
// Use prop messages if provided, otherwise use hook messages
|
||||||
|
const messages = propMessages || hookMessages;
|
||||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||||
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
|
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
|
||||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useDM } from "../../../hooks/useDM";
|
import { useDM } from "../../../hooks/useDM";
|
||||||
import { useAppState } from "../../state";
|
import { useAppState } from "../../state";
|
||||||
|
import { fetchUserPublicKey } from "../../../api/dmApi";
|
||||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
|
|
||||||
export function DMUsersList() {
|
export function DMUsersList() {
|
||||||
const { dmUsers, isLoadingUsers, loadUsers, startDMConversation } = useDM();
|
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||||
const { chat } = useAppState();
|
const { chat, switchToDM } = useAppState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chat.activeTab === "dms") {
|
if (chat.activeTab === "dms") {
|
||||||
@@ -33,6 +34,33 @@ export function DMUsersList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUserClick = async (user: any) => {
|
||||||
|
if (!user.publicKey) {
|
||||||
|
// Get public key if not already loaded
|
||||||
|
const authToken = useAppState.getState().user.authToken;
|
||||||
|
if (!authToken) {
|
||||||
|
console.error("No auth token available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = await fetchUserPublicKey(user.id, authToken);
|
||||||
|
if (publicKey) {
|
||||||
|
user.publicKey = publicKey;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to get public key for user:", user.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await switchToDM({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
publicKey: user.publicKey,
|
||||||
|
profilePicture: user.profile_picture,
|
||||||
|
online: user.online || false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mdui-list>
|
<mdui-list>
|
||||||
{dmUsers.map((user) => (
|
{dmUsers.map((user) => (
|
||||||
@@ -40,7 +68,7 @@ export function DMUsersList() {
|
|||||||
key={user.id}
|
key={user.id}
|
||||||
headline={user.username}
|
headline={user.username}
|
||||||
description={user.lastMessage || "Нет сообщений"}
|
description={user.lastMessage || "Нет сообщений"}
|
||||||
onClick={() => startDMConversation(user)}
|
onClick={() => handleUserClick(user)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PRODUCT_NAME } from "../../../core/config";
|
import { PRODUCT_NAME } from "../../../core/config";
|
||||||
import { useDialog } from "../../contexts/DialogContext";
|
import { useDialog } from "../../contexts/DialogContext";
|
||||||
import { useChat } from "../../hooks/useChat";
|
import { useChat } from "../../hooks/useChat";
|
||||||
|
import { useAppState } from "../../state";
|
||||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||||
@@ -27,14 +28,16 @@ function BottomAppBar() {
|
|||||||
|
|
||||||
|
|
||||||
function ChatTabs() {
|
function ChatTabs() {
|
||||||
const { activeTab, setActiveTab, setCurrentChat } = useChat();
|
const { chat, switchToTab, switchToPublicChat } = useAppState();
|
||||||
|
const { activeTab } = chat;
|
||||||
|
|
||||||
const handleChatClick = (chatName: string) => {
|
const handleChatClick = async (chatName: string) => {
|
||||||
setCurrentChat(chatName);
|
await switchToPublicChat(chatName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTabChange = (e: FormEvent<Tabs>) => {
|
const handleTabChange = async (e: FormEvent<Tabs>) => {
|
||||||
setActiveTab((e.target as Tabs).value as ChatTabs);
|
const tab = (e.target as Tabs).value as ChatTabs;
|
||||||
|
await switchToTab(tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
|
||||||
|
import { ChatMainHeader } from "./ChatMainHeader";
|
||||||
|
import { ChatMessages } from "./ChatMessages";
|
||||||
|
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||||
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
|
|
||||||
|
interface MessagePanelRendererProps {
|
||||||
|
panel: MessagePanel | null;
|
||||||
|
isChatSwitching: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRendererProps) {
|
||||||
|
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||||
|
const [switchIn, setSwitchIn] = useState(false);
|
||||||
|
const [switchOut, setSwitchOut] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Handle panel state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (panel) {
|
||||||
|
setPanelState(panel.getState());
|
||||||
|
|
||||||
|
// Set up state change listener
|
||||||
|
const handleStateChange = (newState: MessagePanelState) => {
|
||||||
|
setPanelState(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
(panel as any).onStateChange = handleStateChange;
|
||||||
|
} else {
|
||||||
|
setPanelState(null);
|
||||||
|
}
|
||||||
|
}, [panel]);
|
||||||
|
|
||||||
|
// Handle chat switching animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (isChatSwitching) {
|
||||||
|
setSwitchOut(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSwitchOut(false);
|
||||||
|
setSwitchIn(true);
|
||||||
|
setTimeout(() => setSwitchIn(false), 200);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}, [isChatSwitching]);
|
||||||
|
|
||||||
|
// Scroll to bottom when messages change
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [panelState?.messages]);
|
||||||
|
|
||||||
|
if (!panel || !panelState) {
|
||||||
|
return (
|
||||||
|
<div className="chat-container">
|
||||||
|
<div className="chat-main" id="chat-inner">
|
||||||
|
<div className="chat-header">
|
||||||
|
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
|
||||||
|
<div className="chat-header-info">
|
||||||
|
<div className="info-chat">
|
||||||
|
<h4 id="chat-name">Select a chat</h4>
|
||||||
|
<p>
|
||||||
|
<span className="online-status"></span>
|
||||||
|
Choose a chat to start messaging
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-messages" id="chat-messages">
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
color: "var(--mdui-color-on-surface-variant)"
|
||||||
|
}}>
|
||||||
|
Select a chat from the sidebar to start messaging
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||||
|
<div className="chat-main" id="chat-inner">
|
||||||
|
<div className="chat-header">
|
||||||
|
<img
|
||||||
|
src={panelState.profilePicture || defaultAvatar}
|
||||||
|
alt="Avatar"
|
||||||
|
className="chat-header-avatar"
|
||||||
|
onClick={panel.handleProfileClick}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<div className="chat-header-info">
|
||||||
|
<div className="info-chat">
|
||||||
|
<h4 id="chat-name">{panelState.title}</h4>
|
||||||
|
<p>
|
||||||
|
<span className={`online-status ${panelState.online ? "online" : "offline"}`}></span>
|
||||||
|
{panelState.online ? "Online" : "Offline"}
|
||||||
|
{panelState.isTyping && " • Typing..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="#" id="hide-chat">Свернуть чат</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-messages" id="chat-messages">
|
||||||
|
{panelState.isLoading ? (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
color: "var(--mdui-color-on-surface-variant)"
|
||||||
|
}}>
|
||||||
|
Loading messages...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChatMessages messages={panelState.messages} />
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatInputWrapper onSendMessage={panel.handleSendMessage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,48 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useChat } from "../../hooks/useChat";
|
|
||||||
import { useAppState } from "../../state";
|
import { useAppState } from "../../state";
|
||||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
import { MessagePanelRenderer } from "./MessagePanelRenderer";
|
||||||
import { ChatMainHeader } from "./ChatMainHeader";
|
|
||||||
import { ChatMessages } from "./ChatMessages";
|
|
||||||
import { DMPanel } from "./DMPanel";
|
|
||||||
import { delay } from "../../../utils/utils";
|
|
||||||
|
|
||||||
export function RightPanel() {
|
export function RightPanel() {
|
||||||
const { isChatSwitching } = useChat();
|
|
||||||
const { chat } = useAppState();
|
const { chat } = useAppState();
|
||||||
const [switchIn, setSwitchIn] = useState(false);
|
|
||||||
const [switchOut, setSwitchOut] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (isChatSwitching) {
|
|
||||||
setSwitchOut(true);
|
|
||||||
} else {
|
|
||||||
setSwitchIn(true);
|
|
||||||
await delay(200);
|
|
||||||
setSwitchIn(false);
|
|
||||||
setSwitchOut(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [isChatSwitching])
|
|
||||||
|
|
||||||
let content: React.ReactNode;
|
|
||||||
|
|
||||||
if (chat.activeTab === "dms") {
|
|
||||||
content = <DMPanel />
|
|
||||||
} else {
|
|
||||||
content = (
|
|
||||||
<div className="chat-main" id="chat-inner">
|
|
||||||
<ChatMainHeader />
|
|
||||||
<ChatMessages />
|
|
||||||
<ChatInputWrapper />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
<MessagePanelRenderer
|
||||||
{content}
|
panel={chat.activePanel}
|
||||||
</div>
|
isChatSwitching={chat.isChatSwitching}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { MessagePanel, type MessagePanelCallbacks } from "./MessagePanel";
|
||||||
|
import {
|
||||||
|
fetchUserPublicKey,
|
||||||
|
fetchDMHistory,
|
||||||
|
decryptDm,
|
||||||
|
sendDMViaWebSocket
|
||||||
|
} from "../../api/dmApi";
|
||||||
|
import type { Message, DmEnvelope } from "../../core/types";
|
||||||
|
import type { UserState } from "../state";
|
||||||
|
|
||||||
|
export interface DMPanelData {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
publicKey: string;
|
||||||
|
profilePicture?: string;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DMPanel extends MessagePanel {
|
||||||
|
private dmData: DMPanelData | null = null;
|
||||||
|
private messagesLoaded: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
user: UserState,
|
||||||
|
callbacks: MessagePanelCallbacks,
|
||||||
|
onStateChange: (state: any) => void
|
||||||
|
) {
|
||||||
|
super("dm", user, callbacks, onStateChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(): Promise<void> {
|
||||||
|
if (this.dmData && !this.messagesLoaded) {
|
||||||
|
await this.loadMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(): void {
|
||||||
|
// DM doesn't need special cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMessages(): Promise<void> {
|
||||||
|
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
|
||||||
|
|
||||||
|
this.setLoading(true);
|
||||||
|
try {
|
||||||
|
const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50);
|
||||||
|
const decryptedMessages: Message[] = [];
|
||||||
|
let maxIncomingId = 0;
|
||||||
|
|
||||||
|
for (const env of messages) {
|
||||||
|
try {
|
||||||
|
const text = await decryptDm(env, this.dmData!.publicKey);
|
||||||
|
const isAuthor = env.senderId !== this.dmData!.userId;
|
||||||
|
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
|
||||||
|
|
||||||
|
decryptedMessages.push({
|
||||||
|
id: env.id,
|
||||||
|
content: text,
|
||||||
|
username: username,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
is_read: false,
|
||||||
|
is_edited: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
||||||
|
maxIncomingId = env.id;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error decrypting message:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearMessages();
|
||||||
|
decryptedMessages.forEach(msg => this.addMessage(msg));
|
||||||
|
|
||||||
|
// Update last read ID
|
||||||
|
if (maxIncomingId > 0) {
|
||||||
|
this.setLastReadId(this.dmData.userId, maxIncomingId);
|
||||||
|
}
|
||||||
|
this.messagesLoaded = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load DM history:", error);
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(content: string): Promise<void> {
|
||||||
|
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendDMViaWebSocket(
|
||||||
|
this.dmData.userId,
|
||||||
|
this.dmData.publicKey,
|
||||||
|
content,
|
||||||
|
this.currentUser.authToken
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send DM:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set DM conversation data
|
||||||
|
setDMData(dmData: DMPanelData): void {
|
||||||
|
this.dmData = dmData;
|
||||||
|
this.messagesLoaded = false;
|
||||||
|
this.updateState({
|
||||||
|
id: `dm-${dmData.userId}`,
|
||||||
|
title: dmData.username,
|
||||||
|
profilePicture: dmData.profilePicture,
|
||||||
|
online: dmData.online
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming WebSocket DM messages
|
||||||
|
handleWebSocketMessage = async (response: any): Promise<void> => {
|
||||||
|
if (response.type === "dmNew" && this.dmData) {
|
||||||
|
const { senderId, recipientId, ...envelope } = response.data;
|
||||||
|
|
||||||
|
// If this is for the active DM conversation
|
||||||
|
if (senderId === this.dmData.userId || recipientId === this.dmData.userId) {
|
||||||
|
try {
|
||||||
|
const plaintext = await decryptDm(envelope, this.dmData.publicKey);
|
||||||
|
const isAuthor = senderId !== this.dmData.userId;
|
||||||
|
|
||||||
|
this.addMessage({
|
||||||
|
id: envelope.id,
|
||||||
|
content: plaintext,
|
||||||
|
username: isAuthor ? "You" : this.dmData.username,
|
||||||
|
timestamp: envelope.timestamp,
|
||||||
|
is_read: false,
|
||||||
|
is_edited: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update last read if it's from the other user
|
||||||
|
if (senderId === this.dmData.userId) {
|
||||||
|
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to decrypt incoming DM:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset for DM switching
|
||||||
|
reset(): void {
|
||||||
|
this.dmData = null;
|
||||||
|
this.messagesLoaded = false;
|
||||||
|
this.clearMessages();
|
||||||
|
this.updateState({
|
||||||
|
id: "dm",
|
||||||
|
title: "Select a user",
|
||||||
|
profilePicture: undefined,
|
||||||
|
online: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update auth token
|
||||||
|
setAuthToken(authToken: string): void {
|
||||||
|
this.currentUser.authToken = authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for localStorage
|
||||||
|
private getLastReadId(userId: number): number {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(`dmLastRead:${userId}`);
|
||||||
|
return v ? Number(v) : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLastReadId(userId: number, id: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { User, Message } from "../../core/types";
|
||||||
|
import type { UserState } from "../state";
|
||||||
|
|
||||||
|
export interface MessagePanelState {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
profilePicture?: string;
|
||||||
|
online: boolean;
|
||||||
|
messages: Message[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isTyping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessagePanelCallbacks {
|
||||||
|
onSendMessage: (content: string) => void;
|
||||||
|
onEditMessage: (messageId: number, content: string) => void;
|
||||||
|
onDeleteMessage: (messageId: number) => void;
|
||||||
|
onReplyToMessage: (messageId: number, content: string) => void;
|
||||||
|
onProfileClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class MessagePanel {
|
||||||
|
protected state: MessagePanelState;
|
||||||
|
protected callbacks: MessagePanelCallbacks;
|
||||||
|
protected onStateChange: (state: MessagePanelState) => void;
|
||||||
|
protected currentUser: UserState;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
currentUser: UserState,
|
||||||
|
callbacks: MessagePanelCallbacks,
|
||||||
|
onStateChange: (state: MessagePanelState) => void
|
||||||
|
) {
|
||||||
|
this.state = {
|
||||||
|
id,
|
||||||
|
title: "",
|
||||||
|
online: false,
|
||||||
|
messages: [],
|
||||||
|
isLoading: false,
|
||||||
|
isTyping: false
|
||||||
|
};
|
||||||
|
this.currentUser = currentUser;
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.onStateChange = onStateChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abstract methods that must be implemented by subclasses
|
||||||
|
abstract activate(): Promise<void>;
|
||||||
|
abstract deactivate(): void;
|
||||||
|
abstract loadMessages(): Promise<void>;
|
||||||
|
abstract sendMessage(content: string): Promise<void>;
|
||||||
|
|
||||||
|
// Common methods
|
||||||
|
protected updateState(updates: Partial<MessagePanelState>): void {
|
||||||
|
this.state = { ...this.state, ...updates };
|
||||||
|
this.onStateChange(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addMessage(message: Message): void {
|
||||||
|
const messageExists = this.state.messages.some(msg => msg.id === message.id);
|
||||||
|
if (!messageExists) {
|
||||||
|
this.updateState({
|
||||||
|
messages: [...this.state.messages, message]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateMessage(messageId: number, updates: Partial<Message>): void {
|
||||||
|
this.updateState({
|
||||||
|
messages: this.state.messages.map(msg =>
|
||||||
|
msg.id === messageId ? { ...msg, ...updates } : msg
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected removeMessage(messageId: number): void {
|
||||||
|
this.updateState({
|
||||||
|
messages: this.state.messages.filter(msg => msg.id !== messageId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearMessages(): void {
|
||||||
|
this.updateState({ messages: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setLoading(loading: boolean): void {
|
||||||
|
this.updateState({ isLoading: loading });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setTyping(typing: boolean): void {
|
||||||
|
this.updateState({ isTyping: typing });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
getState(): MessagePanelState {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
return this.state.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTitle(): string {
|
||||||
|
return this.state.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessages(): Message[] {
|
||||||
|
return [...this.state.messages];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
handleSendMessage = (content: string): void => {
|
||||||
|
this.sendMessage(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleEditMessage = (messageId: number, content: string): void => {
|
||||||
|
this.callbacks.onEditMessage(messageId, content);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDeleteMessage = (messageId: number): void => {
|
||||||
|
this.callbacks.onDeleteMessage(messageId);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReplyToMessage = (messageId: number, content: string): void => {
|
||||||
|
this.callbacks.onReplyToMessage(messageId, content);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleProfileClick = (): void => {
|
||||||
|
this.callbacks.onProfileClick();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { MessagePanel, type MessagePanelCallbacks } from "./MessagePanel";
|
||||||
|
import { API_BASE_URL } from "../../core/config";
|
||||||
|
import { getAuthHeaders } from "../../auth/api";
|
||||||
|
import { request } from "../../websocket";
|
||||||
|
import type { Message, WebSocketMessage } from "../../core/types";
|
||||||
|
import type { UserState } from "../state";
|
||||||
|
|
||||||
|
export class PublicChatPanel extends MessagePanel {
|
||||||
|
private chatName: string;
|
||||||
|
private messagesLoaded: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
chatName: string,
|
||||||
|
currentUser: UserState,
|
||||||
|
callbacks: MessagePanelCallbacks,
|
||||||
|
onStateChange: (state: any) => void
|
||||||
|
) {
|
||||||
|
super(`public-${chatName}`, currentUser, callbacks, onStateChange);
|
||||||
|
this.chatName = chatName;
|
||||||
|
this.updateState({
|
||||||
|
title: chatName,
|
||||||
|
online: true // Public chats are always "online"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(): Promise<void> {
|
||||||
|
if (!this.messagesLoaded) {
|
||||||
|
await this.loadMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(): void {
|
||||||
|
// Public chat doesn't need special cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMessages(): Promise<void> {
|
||||||
|
if (!this.currentUser.authToken || this.messagesLoaded) return;
|
||||||
|
|
||||||
|
this.setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/get_messages`, {
|
||||||
|
headers: getAuthHeaders(this.currentUser.authToken)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.messages && data.messages.length > 0) {
|
||||||
|
this.clearMessages();
|
||||||
|
data.messages.forEach((msg: Message) => {
|
||||||
|
this.addMessage(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.messagesLoaded = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading public chat messages:", error);
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(content: string): Promise<void> {
|
||||||
|
if (!this.currentUser.authToken || !content.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request({
|
||||||
|
data: { content: content.trim() },
|
||||||
|
credentials: {
|
||||||
|
scheme: "Bearer",
|
||||||
|
credentials: this.currentUser.authToken
|
||||||
|
},
|
||||||
|
type: "sendMessage"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
console.error("Error sending message:", response.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending message:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming WebSocket messages
|
||||||
|
handleWebSocketMessage = (response: WebSocketMessage): void => {
|
||||||
|
switch (response.type) {
|
||||||
|
case 'messageEdited':
|
||||||
|
if (response.data) {
|
||||||
|
this.updateMessage(response.data.id, response.data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'messageDeleted':
|
||||||
|
if (response.data && response.data.message_id) {
|
||||||
|
this.removeMessage(response.data.message_id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'newMessage':
|
||||||
|
if (response.data) {
|
||||||
|
this.addMessage(response.data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset for chat switching
|
||||||
|
reset(): void {
|
||||||
|
this.messagesLoaded = false;
|
||||||
|
this.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chat name
|
||||||
|
setChatName(chatName: string): void {
|
||||||
|
this.chatName = chatName;
|
||||||
|
this.updateState({
|
||||||
|
id: `public-${chatName}`,
|
||||||
|
title: chatName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update auth token
|
||||||
|
setAuthToken(authToken: string): void {
|
||||||
|
this.currentUser.authToken = authToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-3
@@ -1,6 +1,9 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Message, User, WebSocketMessage } from "../core/types";
|
import type { Message, User, WebSocketMessage } from "../core/types";
|
||||||
import { request } from "../websocket";
|
import { request } from "../websocket";
|
||||||
|
import { MessagePanel } from "./panels/MessagePanel";
|
||||||
|
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
||||||
|
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
|
||||||
|
|
||||||
type Page = "login" | "register" | "chat"
|
type Page = "login" | "register" | "chat"
|
||||||
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||||
@@ -18,9 +21,12 @@ interface ChatState {
|
|||||||
dmUsers: User[];
|
dmUsers: User[];
|
||||||
activeDm: ActiveDM | null;
|
activeDm: ActiveDM | null;
|
||||||
isChatSwitching: boolean;
|
isChatSwitching: boolean;
|
||||||
|
activePanel: MessagePanel | null;
|
||||||
|
publicChatPanel: PublicChatPanel | null;
|
||||||
|
dmPanel: DMPanel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserState {
|
export interface UserState {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
}
|
}
|
||||||
@@ -40,6 +46,10 @@ interface AppState {
|
|||||||
setActiveDm: (dm: ChatState["activeDm"]) => void;
|
setActiveDm: (dm: ChatState["activeDm"]) => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
setIsChatSwitching: (value: boolean) => void;
|
setIsChatSwitching: (value: boolean) => void;
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => void;
|
||||||
|
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||||
|
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||||
|
switchToTab: (tab: ChatTabs) => Promise<void>;
|
||||||
|
|
||||||
// User state
|
// User state
|
||||||
user: UserState;
|
user: UserState;
|
||||||
@@ -58,7 +68,10 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
activeTab: "chats",
|
activeTab: "chats",
|
||||||
dmUsers: [],
|
dmUsers: [],
|
||||||
activeDm: null,
|
activeDm: null,
|
||||||
isChatSwitching: false
|
isChatSwitching: false,
|
||||||
|
activePanel: null,
|
||||||
|
publicChatPanel: null,
|
||||||
|
dmPanel: null
|
||||||
},
|
},
|
||||||
setIsChatSwitching: (value: boolean) => set((state) => ({
|
setIsChatSwitching: (value: boolean) => set((state) => ({
|
||||||
chat: {
|
chat: {
|
||||||
@@ -159,5 +172,134 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
authToken: null
|
authToken: null
|
||||||
},
|
},
|
||||||
currentPage: "login"
|
currentPage: "login"
|
||||||
}))
|
})),
|
||||||
|
|
||||||
|
// Panel management
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: panel
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
switchToPublicChat: async (chatName: string) => {
|
||||||
|
const state = get();
|
||||||
|
const { user, chat } = state;
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
// Start chat switching animation
|
||||||
|
state.setIsChatSwitching(true);
|
||||||
|
|
||||||
|
// Create or get public chat panel
|
||||||
|
let publicChatPanel = chat.publicChatPanel;
|
||||||
|
if (!publicChatPanel) {
|
||||||
|
const callbacks = {
|
||||||
|
onSendMessage: (content: string) => {},
|
||||||
|
onEditMessage: (messageId: number, content: string) => {},
|
||||||
|
onDeleteMessage: (messageId: number) => {},
|
||||||
|
onReplyToMessage: (messageId: number, content: string) => {},
|
||||||
|
onProfileClick: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
publicChatPanel = new PublicChatPanel(
|
||||||
|
chatName,
|
||||||
|
user,
|
||||||
|
callbacks,
|
||||||
|
() => {} // State change handled by MessagePanelRenderer
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
publicChatPanel.setChatName(chatName);
|
||||||
|
publicChatPanel.setAuthToken(user.authToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for animation
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 250));
|
||||||
|
|
||||||
|
// Activate panel
|
||||||
|
await publicChatPanel.activate();
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: publicChatPanel,
|
||||||
|
publicChatPanel: publicChatPanel,
|
||||||
|
currentChat: chatName,
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// End animation
|
||||||
|
state.setIsChatSwitching(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
switchToDM: async (dmData: DMPanelData) => {
|
||||||
|
const state = get();
|
||||||
|
const { user, chat } = state;
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
// Start chat switching animation
|
||||||
|
state.setIsChatSwitching(true);
|
||||||
|
|
||||||
|
// Create or get DM panel
|
||||||
|
let dmPanel = chat.dmPanel;
|
||||||
|
if (!dmPanel) {
|
||||||
|
const callbacks = {
|
||||||
|
onSendMessage: (content: string) => {},
|
||||||
|
onEditMessage: (messageId: number, content: string) => {},
|
||||||
|
onDeleteMessage: (messageId: number) => {},
|
||||||
|
onReplyToMessage: (messageId: number, content: string) => {},
|
||||||
|
onProfileClick: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
dmPanel = new DMPanel(
|
||||||
|
user,
|
||||||
|
callbacks,
|
||||||
|
() => {} // State change handled by MessagePanelRenderer
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dmPanel.setAuthToken(user.authToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set DM data
|
||||||
|
dmPanel.setDMData(dmData);
|
||||||
|
|
||||||
|
// Wait for animation
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 250));
|
||||||
|
|
||||||
|
// Activate panel
|
||||||
|
await dmPanel.activate();
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: dmPanel,
|
||||||
|
dmPanel: dmPanel,
|
||||||
|
activeDm: {
|
||||||
|
userId: dmData.userId,
|
||||||
|
username: dmData.username,
|
||||||
|
publicKey: dmData.publicKey
|
||||||
|
},
|
||||||
|
activeTab: "dms"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// End animation
|
||||||
|
state.setIsChatSwitching(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
switchToTab: async (tab: ChatTabs) => {
|
||||||
|
const state = get();
|
||||||
|
state.setActiveTab(tab);
|
||||||
|
|
||||||
|
if (tab === "chats") {
|
||||||
|
await state.switchToPublicChat("Общий чат");
|
||||||
|
} else if (tab === "dms") {
|
||||||
|
// DM tab - no specific panel until user is selected
|
||||||
|
state.setActivePanel(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
Reference in New Issue
Block a user