mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/react-dms' into feature/react
This commit is contained in:
@@ -9,6 +9,11 @@ When working with this project, follow these rules:
|
||||
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||
need to typecheck or build the app, in that case:
|
||||
|
||||
- To typecheck, run "npm run frontend:typecheck".
|
||||
- To build, run "npm run frontend:build".
|
||||
- Do NOT "cd" to the project directory.
|
||||
- To typecheck, run `npm run frontend:typecheck`.
|
||||
- To build, run `npm run frontend:build`.
|
||||
|
||||
Do NOT execute other commands like "cd".
|
||||
- Do NOT "cd" to the project directory.
|
||||
- If possible, try to update files in a single edit.
|
||||
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
|
||||
make it async. The import is `<project>/frontend/src/utils/utils`.
|
||||
@@ -17,6 +17,8 @@ def convert_user(user: User) -> dict:
|
||||
"last_seen": user.last_seen.isoformat(),
|
||||
"online": user.online,
|
||||
"username": user.username,
|
||||
"profile_picture": user.profile_picture,
|
||||
"bio": user.bio,
|
||||
"admin": user.username == OWNER_USERNAME
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,8 @@ class MessaggingSocketManager:
|
||||
db.add(env)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
await self.send_to_user(env.recipient_id, {
|
||||
|
||||
payload = {
|
||||
"type": "dmNew",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
@@ -317,8 +318,11 @@ class MessaggingSocketManager:
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
}
|
||||
})
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
|
||||
}
|
||||
|
||||
await self.send_to_user(env.recipient_id, payload);
|
||||
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
|
||||
await self.send_to_user(env.sender_id, payload);
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, type, e)
|
||||
elif type == "editMessage":
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { API_BASE_URL } from "../core/config";
|
||||
import { getAuthHeaders } from "../auth/api";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
|
||||
import { randomBytes } from "../crypto/kdf";
|
||||
import { getCurrentKeys } from "../auth/crypto";
|
||||
import { request } from "../websocket";
|
||||
import type { FetchDMResponse, SendDMRequest, DmEnvelope, User } from "../core/types";
|
||||
import { b64, ub64 } from "../utils/utils";
|
||||
|
||||
export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string, token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDm(since: number | undefined, token: string): Promise<DmEnvelope[]> {
|
||||
const url = new URL(`${API_BASE_URL}/dm/fetch`);
|
||||
if (since) url.searchParams.set("since", String(since));
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: FetchDMResponse = await response.json();
|
||||
return data.messages ?? [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
}
|
||||
|
||||
export async function fetchUsers(token: string): Promise<User[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.publicKey;
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppState } from "../ui/state";
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUserPublicKey,
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
} from "../api/dmApi";
|
||||
import type { User, Message } from "../core/types";
|
||||
import { websocket } from "../websocket";
|
||||
|
||||
interface DMUser extends User {
|
||||
lastMessage?: string;
|
||||
unreadCount: number;
|
||||
publicKey?: string | null;
|
||||
}
|
||||
|
||||
export function useDM() {
|
||||
const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState();
|
||||
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const usersLoadedRef = useRef(false);
|
||||
|
||||
// Load last message and unread count for a specific user
|
||||
const loadUserLastMessage = useCallback(async (dmUser: DMUser) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
// Get public key
|
||||
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) return;
|
||||
|
||||
// Get message history
|
||||
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
|
||||
if (messages.length === 0) return;
|
||||
|
||||
// Find last message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let lastPlaintext: string | null = null;
|
||||
|
||||
try {
|
||||
lastPlaintext = await decryptDm(lastMessage, publicKey);
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message:", error);
|
||||
}
|
||||
|
||||
// Calculate unread count
|
||||
const lastReadId = getLastReadId(dmUser.id);
|
||||
let unreadCount = 0;
|
||||
for (const msg of messages) {
|
||||
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
|
||||
unreadCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user state
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === dmUser.id
|
||||
? {
|
||||
...u,
|
||||
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
|
||||
unreadCount,
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
));
|
||||
} catch (error) {
|
||||
console.error("Failed to load last message for user:", dmUser.id, error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load users when DM tab is active
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
|
||||
|
||||
usersLoadedRef.current = true;
|
||||
setIsLoadingUsers(true);
|
||||
try {
|
||||
const users = await fetchUsers(user.authToken);
|
||||
console.log("Fetched users:", users);
|
||||
const dmUsersWithState: DMUser[] = users.map(user => ({
|
||||
...user,
|
||||
unreadCount: 0,
|
||||
lastMessage: undefined,
|
||||
publicKey: null
|
||||
}));
|
||||
|
||||
setDmUsersState(dmUsersWithState);
|
||||
setDmUsers(users);
|
||||
|
||||
// Load last messages and unread counts for visible users
|
||||
// Call loadUserLastMessage directly without dependency
|
||||
for (const dmUser of dmUsersWithState) {
|
||||
if (!user.authToken) continue;
|
||||
|
||||
try {
|
||||
// Get public key
|
||||
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) continue;
|
||||
|
||||
// Get message history
|
||||
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
|
||||
if (messages.length === 0) continue;
|
||||
|
||||
// Find last message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let lastPlaintext: string | null = null;
|
||||
|
||||
try {
|
||||
lastPlaintext = await decryptDm(lastMessage, publicKey);
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message:", error);
|
||||
}
|
||||
|
||||
// Calculate unread count
|
||||
const lastReadId = getLastReadId(dmUser.id);
|
||||
let unreadCount = 0;
|
||||
for (const msg of messages) {
|
||||
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
|
||||
unreadCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user state
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === dmUser.id
|
||||
? {
|
||||
...u,
|
||||
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
|
||||
unreadCount,
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
));
|
||||
} catch (error) {
|
||||
console.error("Failed to load last message for user:", dmUser.id, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM users:", error);
|
||||
} finally {
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
}, [user.authToken, isLoadingUsers]);
|
||||
|
||||
// Reset users loaded flag when user changes
|
||||
useEffect(() => {
|
||||
usersLoadedRef.current = false;
|
||||
}, [user.authToken]);
|
||||
|
||||
// Load DM history for active conversation
|
||||
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
|
||||
if (!user.authToken || isLoadingHistory) return;
|
||||
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
const messages = await fetchDMHistory(userId, user.authToken, 50);
|
||||
const decryptedMessages: Message[] = [];
|
||||
let maxIncomingId = 0;
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const text = await decryptDm(env, publicKey);
|
||||
const isAuthor = env.senderId !== userId;
|
||||
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
||||
|
||||
decryptedMessages.push({
|
||||
id: env.id,
|
||||
content: text,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
|
||||
if (env.senderId === userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decrypting message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
decryptedMessages.forEach(msg => addMessage(msg));
|
||||
|
||||
// Update last read ID
|
||||
if (maxIncomingId > 0) {
|
||||
setLastReadId(userId, maxIncomingId);
|
||||
// Clear unread count
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === userId ? { ...u, unreadCount: 0 } : u
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM history:", error);
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
|
||||
|
||||
// Send DM message
|
||||
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken);
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Start DM conversation
|
||||
const startDMConversation = useCallback(async (dmUser: DMUser) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
// Get public key if not already loaded
|
||||
let publicKey = dmUser.publicKey;
|
||||
if (!publicKey) {
|
||||
publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) return;
|
||||
}
|
||||
|
||||
// Set active DM
|
||||
setActiveDm({
|
||||
userId: dmUser.id,
|
||||
username: dmUser.username,
|
||||
publicKey
|
||||
});
|
||||
|
||||
// Load conversation history
|
||||
await loadDMHistory(dmUser.id, publicKey);
|
||||
} catch (error) {
|
||||
console.error("Failed to start DM conversation:", error);
|
||||
}
|
||||
}, [user.authToken, setActiveDm, loadDMHistory]);
|
||||
|
||||
// WebSocket message handler
|
||||
useEffect(() => {
|
||||
const handleWebSocketMessage = async (e: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "dmNew") {
|
||||
const { senderId, recipientId, ...envelope } = msg.data;
|
||||
|
||||
// If this is for the active DM conversation
|
||||
if (chat.activeDm && (senderId === chat.activeDm.userId || recipientId === chat.activeDm.userId)) {
|
||||
try {
|
||||
const plaintext = await decryptDm(envelope, chat.activeDm.publicKey!);
|
||||
const isAuthor = senderId !== chat.activeDm.userId;
|
||||
|
||||
addMessage({
|
||||
id: envelope.id,
|
||||
content: plaintext,
|
||||
username: isAuthor ? (user.currentUser?.username || "Unknown") : (chat.activeDm.username || "Unknown"),
|
||||
timestamp: envelope.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
|
||||
// Update last read if it's from the other user
|
||||
if (senderId === chat.activeDm.userId) {
|
||||
setLastReadId(chat.activeDm.userId, Math.max(getLastReadId(chat.activeDm.userId), envelope.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt incoming DM:", error);
|
||||
}
|
||||
} else {
|
||||
// Update unread count for other users
|
||||
const otherUserId = senderId;
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === otherUserId
|
||||
? { ...u, unreadCount: u.unreadCount + 1 }
|
||||
: u
|
||||
));
|
||||
|
||||
// Update last message preview
|
||||
try {
|
||||
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const plaintext = await decryptDm(envelope, publicKey);
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === otherUserId
|
||||
? {
|
||||
...u,
|
||||
lastMessage: plaintext.split(/\r?\n/).slice(0, 2).join("\n"),
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update last message preview:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", handleWebSocketMessage);
|
||||
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
||||
}, [chat.activeDm, user.currentUser, addMessage]);
|
||||
|
||||
// Force reload users (useful for refreshing the list)
|
||||
const reloadUsers = useCallback(() => {
|
||||
usersLoadedRef.current = false;
|
||||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
return {
|
||||
dmUsers,
|
||||
isLoadingUsers,
|
||||
isLoadingHistory,
|
||||
loadUsers,
|
||||
reloadUsers,
|
||||
startDMConversation,
|
||||
sendDMMessage,
|
||||
loadUserLastMessage
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for localStorage
|
||||
function getLastReadId(userId: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(`dmLastRead:${userId}`);
|
||||
return v ? Number(v) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastReadId(userId: number, id: number): void {
|
||||
try {
|
||||
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||
} catch {}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { useChat } from "../../hooks/useChat";
|
||||
|
||||
export function ChatInputWrapper() {
|
||||
interface ChatInputWrapperProps {
|
||||
onSendMessage?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) {
|
||||
const [message, setMessage] = useState("");
|
||||
const { sendMessage } = useChat();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (message.trim()) {
|
||||
await sendMessage(message);
|
||||
if (onSendMessage) {
|
||||
onSendMessage(message);
|
||||
} else {
|
||||
await sendMessage(message);
|
||||
}
|
||||
setMessage("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,13 +6,22 @@ import type { UserProfile } from "../../../core/types";
|
||||
import { UserProfileDialog } from "./UserProfileDialog";
|
||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||
import { fetchUserProfile } from "../../api/profileApi";
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { delay } from "../../../utils/utils";
|
||||
import { request } from "../../../websocket";
|
||||
|
||||
export function ChatMessages() {
|
||||
const { messages } = useChat();
|
||||
interface ChatMessagesProps {
|
||||
messages?: MessageType[];
|
||||
isDm?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages: propMessages, children, isDm = false }: ChatMessagesProps) {
|
||||
const { messages: hookMessages } = useChat();
|
||||
const { user } = useAppState();
|
||||
|
||||
// Use prop messages if provided, otherwise use hook messages
|
||||
const messages = propMessages || hookMessages;
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
||||
@@ -43,7 +52,6 @@ export function ChatMessages() {
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, message: MessageType) => {
|
||||
e.preventDefault();
|
||||
console.log("Context menu triggered for message:", message.id, "at position:", e.clientX, e.clientY);
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
message,
|
||||
@@ -128,8 +136,9 @@ export function ChatMessages() {
|
||||
onProfileClick={handleProfileClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
isLoadingProfile={isLoadingProfile}
|
||||
/>
|
||||
isDm={isDm} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<UserProfileDialog
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppState } from "../../state";
|
||||
import { useDM } from "../../../hooks/useDM";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
|
||||
export function DMPanel() {
|
||||
const { chat } = useAppState();
|
||||
const { sendDMMessage, isLoadingHistory } = useDM();
|
||||
const [message, setMessage] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activeDm = chat.activeDm;
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chat.messages]);
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message.trim() || !activeDm?.publicKey) return;
|
||||
|
||||
try {
|
||||
await sendDMMessage(activeDm.userId, activeDm.publicKey, message);
|
||||
setMessage("");
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileClick = () => {
|
||||
// TODO: Implement profile dialog for DM user
|
||||
console.log("Profile clicked for DM user:", activeDm?.username);
|
||||
};
|
||||
|
||||
if (!activeDm) {
|
||||
return (
|
||||
<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">Выберите пользователя</h4>
|
||||
<p>
|
||||
<span className="online-status"></span>
|
||||
Выберите пользователя для начала разговора
|
||||
</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)"
|
||||
}}>
|
||||
Выберите пользователя из списка для начала личных сообщений
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-main" id="chat-inner">
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{activeDm.username}</h4>
|
||||
<p>
|
||||
<span className="online-status"></span>
|
||||
Личные сообщения
|
||||
</p>
|
||||
</div>
|
||||
<a href="#" id="hide-chat">Свернуть чат</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
{isLoadingHistory ? (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ChatMessages />
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input">
|
||||
<form className="input-group" id="message-form" onSubmit={handleSendMessage}>
|
||||
<input
|
||||
type="text"
|
||||
className="message-input"
|
||||
id="message-input"
|
||||
placeholder="Напишите сообщение..."
|
||||
autoComplete="off"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">send</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDM } from "../../../hooks/useDM";
|
||||
import { useAppState } from "../../state";
|
||||
import { fetchUserPublicKey } from "../../../api/dmApi";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
|
||||
export function DMUsersList() {
|
||||
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
|
||||
const { chat, switchToDM } = useAppState();
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.activeTab === "dms") {
|
||||
loadUsers();
|
||||
}
|
||||
}, [chat.activeTab, loadUsers]);
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return (
|
||||
<mdui-list>
|
||||
<mdui-list-item headline="Загрузка..." description="Получение списка пользователей...">
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
|
||||
if (dmUsers.length === 0) {
|
||||
return (
|
||||
<mdui-list>
|
||||
<mdui-list-item headline="Нет пользователей" description="Пользователи не найдены">
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<mdui-list>
|
||||
{dmUsers.map((user) => (
|
||||
<mdui-list-item
|
||||
key={user.id}
|
||||
headline={user.username}
|
||||
description={user.lastMessage || "Нет сообщений"}
|
||||
onClick={() => handleUserClick(user)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img
|
||||
src={user.profile_picture || defaultAvatar}
|
||||
alt={user.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
{user.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{user.unreadCount}
|
||||
</mdui-badge>
|
||||
)}
|
||||
</mdui-list-item>
|
||||
))}
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { PRODUCT_NAME } from "../../../core/config";
|
||||
import { useDialog } from "../../contexts/DialogContext";
|
||||
import { useChat } from "../../hooks/useChat";
|
||||
import { useAppState } from "../../state";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
import { useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||
import { SettingsDialog } from "../settings/SettingsDialog";
|
||||
import { DMUsersList } from "./DMUsersList";
|
||||
import type { Tabs } from "mdui";
|
||||
import type { ChatTabs } from "../../state";
|
||||
|
||||
function BottomAppBar() {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
@@ -24,15 +28,21 @@ function BottomAppBar() {
|
||||
|
||||
|
||||
function ChatTabs() {
|
||||
const { activeTab, setActiveTab, setCurrentChat } = useChat();
|
||||
const { chat, switchToTab, switchToPublicChat } = useAppState();
|
||||
const { activeTab } = chat;
|
||||
|
||||
const handleChatClick = (chatName: string) => {
|
||||
setCurrentChat(chatName);
|
||||
const handleChatClick = async (chatName: string) => {
|
||||
await switchToPublicChat(chatName);
|
||||
};
|
||||
|
||||
const handleTabChange = async (e: FormEvent<Tabs>) => {
|
||||
const tab = (e.target as Tabs).value as ChatTabs;
|
||||
await switchToTab(tab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={activeTab} full-width onChange={(e: Event & any) => setActiveTab(e.value)}>
|
||||
<mdui-tabs value={activeTab} full-width onChange={handleTabChange}>
|
||||
<mdui-tab value="chats">Чаты</mdui-tab>
|
||||
<mdui-tab value="channels">Каналы</mdui-tab>
|
||||
<mdui-tab value="contacts">Контакты</mdui-tab>
|
||||
@@ -63,7 +73,7 @@ function ChatTabs() {
|
||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="dms">
|
||||
<mdui-list id="dm-users"></mdui-list>
|
||||
<DMUsersList />
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,11 @@ interface MessageProps {
|
||||
onProfileClick: (username: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
isLoadingProfile?: boolean;
|
||||
isDm?: boolean;
|
||||
}
|
||||
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false }: MessageProps) {
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) {
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
console.log("Message context menu event triggered for message:", message.id);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, message);
|
||||
@@ -26,7 +26,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
>
|
||||
<div className="message-inner">
|
||||
{/* Add profile picture for received messages */}
|
||||
{!isAuthor && (
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-profile-pic">
|
||||
<img
|
||||
src={message.profile_picture || defaultAvatar}
|
||||
@@ -42,7 +42,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthor && (
|
||||
{!isAuthor && !isDm && (
|
||||
<div
|
||||
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
|
||||
@@ -30,8 +30,6 @@ export function MessageContextMenu({
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: MessageContextMenuProps) {
|
||||
console.log("MessageContextMenu rendered with position:", position, "message:", message.id);
|
||||
|
||||
// Internal state for dialogs and closing animation
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [replyDialogOpen, setReplyDialogOpen] = useState(false);
|
||||
@@ -116,7 +114,6 @@ export function MessageContextMenu({
|
||||
}, [isOpen, isClosing, editDialogOpen, replyDialogOpen]);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
console.log("Context menu action triggered:", action);
|
||||
switch (action) {
|
||||
case "reply":
|
||||
setReplyDialogOpen(true);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { setGlobalMessageHandler } from "../../../websocket";
|
||||
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.onStateChange = handleStateChange;
|
||||
|
||||
// Set up WebSocket message handler for this panel
|
||||
if (panel.handleWebSocketMessage) {
|
||||
setGlobalMessageHandler(panel.handleWebSocketMessage);
|
||||
}
|
||||
} else {
|
||||
setPanelState(null);
|
||||
// Clear global message handler when no panel is active
|
||||
setGlobalMessageHandler(null);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (panel && (panel as any).onStateChange) {
|
||||
(panel as any).onStateChange = 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{panelState.isLoading ? (
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Loading messages...
|
||||
</div>
|
||||
</div>
|
||||
): (
|
||||
<ChatMessages messages={panelState.messages} isDm={panel.isDm()}>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
)}
|
||||
|
||||
<ChatInputWrapper onSendMessage={panel.handleSendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useChat } from "../../hooks/useChat";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { ChatMainHeader } from "./ChatMainHeader";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { delay } from "../../../utils/utils";
|
||||
import { useAppState } from "../../state";
|
||||
import { MessagePanelRenderer } from "./MessagePanelRenderer";
|
||||
|
||||
export function RightPanel() {
|
||||
const { isChatSwitching } = useChat();
|
||||
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])
|
||||
const { chat } = useAppState();
|
||||
|
||||
return (
|
||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||
<div className="chat-main" id="chat-inner">
|
||||
<ChatMainHeader />
|
||||
<ChatMessages />
|
||||
<ChatInputWrapper />
|
||||
</div>
|
||||
</div>
|
||||
<MessagePanelRenderer
|
||||
panel={chat.activePanel}
|
||||
isChatSwitching={chat.isChatSwitching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export function MaterialDialog(props: FullDialogProps) {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === "attributes" && mutation.attributeName === "open") {
|
||||
const isOpen = dialog.hasAttribute("open");
|
||||
console.log("isOpen:", isOpen);
|
||||
if (isOpen !== props.open) {
|
||||
props.onOpenChange(isOpen);
|
||||
}
|
||||
|
||||
@@ -70,40 +70,8 @@ export function useChat() {
|
||||
}
|
||||
}, [user.authToken]);
|
||||
|
||||
// Handle WebSocket messages
|
||||
useEffect(() => {
|
||||
const handleWebSocketMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage = JSON.parse(event.data);
|
||||
|
||||
switch (response.type) {
|
||||
case 'messageEdited':
|
||||
if (response.data) {
|
||||
updateMessage(response.data.id, response.data);
|
||||
}
|
||||
break;
|
||||
case 'messageDeleted':
|
||||
if (response.data && response.data.message_id) {
|
||||
removeMessage(response.data.message_id);
|
||||
}
|
||||
break;
|
||||
case 'newMessage':
|
||||
if (response.data) {
|
||||
addMessage(response.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", handleWebSocketMessage);
|
||||
|
||||
return () => {
|
||||
websocket.removeEventListener("message", handleWebSocketMessage);
|
||||
};
|
||||
}, [addMessage, updateMessage, removeMessage, user.currentUser]);
|
||||
// WebSocket messages are now handled by the active panel
|
||||
// No need for duplicate handling here
|
||||
|
||||
// Load messages only once when component mounts and user is authenticated
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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);
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
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 ? this.currentUser.currentUser?.username ?? "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,135 @@
|
||||
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;
|
||||
public 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>;
|
||||
abstract isDm(): boolean;
|
||||
|
||||
// Optional WebSocket message handler (can be overridden by subclasses)
|
||||
handleWebSocketMessage?: (response: any) => 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,127 @@
|
||||
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"
|
||||
});
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+147
-4
@@ -1,8 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
import type { Message, User, WebSocketMessage } from "../core/types";
|
||||
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"
|
||||
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||
|
||||
interface ActiveDM {
|
||||
userId: number;
|
||||
@@ -13,13 +17,16 @@ interface ActiveDM {
|
||||
interface ChatState {
|
||||
messages: Message[];
|
||||
currentChat: string;
|
||||
activeTab: "chats" | "channels" | "contacts" | "dms";
|
||||
activeTab: ChatTabs;
|
||||
dmUsers: User[];
|
||||
activeDm: ActiveDM | null;
|
||||
isChatSwitching: boolean;
|
||||
activePanel: MessagePanel | null;
|
||||
publicChatPanel: PublicChatPanel | null;
|
||||
dmPanel: DMPanel | null;
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
export interface UserState {
|
||||
currentUser: User | null;
|
||||
authToken: string | null;
|
||||
}
|
||||
@@ -39,6 +46,10 @@ interface AppState {
|
||||
setActiveDm: (dm: ChatState["activeDm"]) => void;
|
||||
clearMessages: () => 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: UserState;
|
||||
@@ -57,7 +68,10 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
activeTab: "chats",
|
||||
dmUsers: [],
|
||||
activeDm: null,
|
||||
isChatSwitching: false
|
||||
isChatSwitching: false,
|
||||
activePanel: null,
|
||||
publicChatPanel: null,
|
||||
dmPanel: null
|
||||
},
|
||||
setIsChatSwitching: (value: boolean) => set((state) => ({
|
||||
chat: {
|
||||
@@ -158,5 +172,134 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
authToken: null
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
}));
|
||||
+44
-11
@@ -29,17 +29,41 @@ function create(): WebSocket {
|
||||
*/
|
||||
export let websocket: WebSocket = create();
|
||||
|
||||
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let listener: ((e: MessageEvent) => void) | null = null;
|
||||
listener = (e) => {
|
||||
resolve(JSON.parse(e.data));
|
||||
websocket.removeEventListener("message", listener!);
|
||||
}
|
||||
websocket.addEventListener("message", listener);
|
||||
websocket.send(JSON.stringify(payload))
|
||||
/**
|
||||
* Global WebSocket message handler reference
|
||||
* This will be set by the active panel to handle incoming messages
|
||||
*/
|
||||
let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null;
|
||||
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
/**
|
||||
* Set the global WebSocket message handler
|
||||
* @param handler - Function to handle WebSocket messages
|
||||
*/
|
||||
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void {
|
||||
globalMessageHandler = handler;
|
||||
}
|
||||
|
||||
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> {
|
||||
console.log("WebSocket request:", payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
function requestInner() {
|
||||
let listener: ((e: MessageEvent) => void) | null = null;
|
||||
listener = (e) => {
|
||||
resolve(JSON.parse(e.data));
|
||||
websocket.removeEventListener("message", listener!);
|
||||
}
|
||||
websocket.addEventListener("message", listener);
|
||||
websocket.send(JSON.stringify(payload))
|
||||
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
}
|
||||
|
||||
if (websocket.readyState == 0) {
|
||||
websocket.addEventListener("open", requestInner);
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
} else {
|
||||
requestInner();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,6 +94,15 @@ async function onError() {
|
||||
// --------------
|
||||
|
||||
websocket.addEventListener("message", (e) => {
|
||||
// handleWebSocketMessage(JSON.parse(e.data));
|
||||
try {
|
||||
const response: WebSocketMessage = JSON.parse(e.data);
|
||||
|
||||
// Route message to global handler if set
|
||||
if (globalMessageHandler) {
|
||||
globalMessageHandler(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
});
|
||||
websocket.addEventListener("error", onError);
|
||||
Reference in New Issue
Block a user