mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Start DMs
This commit is contained in:
@@ -11,4 +11,5 @@ 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 "cd" to the project directory.
|
- Do NOT "cd" to the project directory.
|
||||||
|
- If possible, try to update files in a single edit.
|
||||||
@@ -17,6 +17,8 @@ def convert_user(user: User) -> dict:
|
|||||||
"last_seen": user.last_seen.isoformat(),
|
"last_seen": user.last_seen.isoformat(),
|
||||||
"online": user.online,
|
"online": user.online,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
|
"profile_picture": user.profile_picture,
|
||||||
|
"bio": user.bio,
|
||||||
"admin": user.username == OWNER_USERNAME
|
"admin": user.username == OWNER_USERNAME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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,69 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useDM } from "../../../hooks/useDM";
|
||||||
|
import { useAppState } from "../../state";
|
||||||
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
|
|
||||||
|
export function DMUsersList() {
|
||||||
|
const { dmUsers, isLoadingUsers, loadUsers, startDMConversation } = useDM();
|
||||||
|
const { chat } = 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mdui-list>
|
||||||
|
{dmUsers.map((user) => (
|
||||||
|
<mdui-list-item
|
||||||
|
key={user.id}
|
||||||
|
headline={user.username}
|
||||||
|
description={user.lastMessage || "Нет сообщений"}
|
||||||
|
onClick={() => startDMConversation(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ 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 defaultAvatar from "../../../resources/images/default-avatar.png";
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
import { useState } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { ProfileDialog } from "../profile/ProfileDialog";
|
import { ProfileDialog } from "../profile/ProfileDialog";
|
||||||
import { SettingsDialog } from "../settings/SettingsDialog";
|
import { SettingsDialog } from "../settings/SettingsDialog";
|
||||||
|
import { DMUsersList } from "./DMUsersList";
|
||||||
|
import type { Tabs } from "mdui";
|
||||||
|
import type { ChatTabs } from "../../state";
|
||||||
|
|
||||||
function BottomAppBar() {
|
function BottomAppBar() {
|
||||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||||
@@ -30,9 +33,13 @@ function ChatTabs() {
|
|||||||
setCurrentChat(chatName);
|
setCurrentChat(chatName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (e: FormEvent<Tabs>) => {
|
||||||
|
setActiveTab((e.target as Tabs).value as ChatTabs);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-tabs">
|
<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="chats">Чаты</mdui-tab>
|
||||||
<mdui-tab value="channels">Каналы</mdui-tab>
|
<mdui-tab value="channels">Каналы</mdui-tab>
|
||||||
<mdui-tab value="contacts">Контакты</mdui-tab>
|
<mdui-tab value="contacts">Контакты</mdui-tab>
|
||||||
@@ -63,7 +70,7 @@ function ChatTabs() {
|
|||||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
<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="contacts">Скоро будет...</mdui-tab-panel>
|
||||||
<mdui-tab-panel slot="panel" value="dms">
|
<mdui-tab-panel slot="panel" value="dms">
|
||||||
<mdui-list id="dm-users"></mdui-list>
|
<DMUsersList />
|
||||||
</mdui-tab-panel>
|
</mdui-tab-panel>
|
||||||
</mdui-tabs>
|
</mdui-tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export function MessageContextMenu({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
}: MessageContextMenuProps) {
|
}: MessageContextMenuProps) {
|
||||||
console.log("MessageContextMenu rendered with position:", position, "message:", message.id);
|
|
||||||
|
|
||||||
// Internal state for dialogs and closing animation
|
// Internal state for dialogs and closing animation
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [replyDialogOpen, setReplyDialogOpen] = useState(false);
|
const [replyDialogOpen, setReplyDialogOpen] = useState(false);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useChat } from "../../hooks/useChat";
|
import { useChat } from "../../hooks/useChat";
|
||||||
|
import { useAppState } from "../../state";
|
||||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||||
import { ChatMainHeader } from "./ChatMainHeader";
|
import { ChatMainHeader } from "./ChatMainHeader";
|
||||||
import { ChatMessages } from "./ChatMessages";
|
import { ChatMessages } from "./ChatMessages";
|
||||||
|
import { DMPanel } from "./DMPanel";
|
||||||
import { delay } from "../../../utils/utils";
|
import { delay } from "../../../utils/utils";
|
||||||
|
|
||||||
export function RightPanel() {
|
export function RightPanel() {
|
||||||
const { isChatSwitching } = useChat();
|
const { isChatSwitching } = useChat();
|
||||||
|
const { chat } = useAppState();
|
||||||
const [switchIn, setSwitchIn] = useState(false);
|
const [switchIn, setSwitchIn] = useState(false);
|
||||||
const [switchOut, setSwitchOut] = useState(false);
|
const [switchOut, setSwitchOut] = useState(false);
|
||||||
|
|
||||||
@@ -23,13 +26,23 @@ export function RightPanel() {
|
|||||||
})();
|
})();
|
||||||
}, [isChatSwitching])
|
}, [isChatSwitching])
|
||||||
|
|
||||||
return (
|
let content: React.ReactNode;
|
||||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
|
||||||
|
if (chat.activeTab === "dms") {
|
||||||
|
content = <DMPanel />
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
<div className="chat-main" id="chat-inner">
|
<div className="chat-main" id="chat-inner">
|
||||||
<ChatMainHeader />
|
<ChatMainHeader />
|
||||||
<ChatMessages />
|
<ChatMessages />
|
||||||
<ChatInputWrapper />
|
<ChatInputWrapper />
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Message, User, WebSocketMessage } from "../core/types";
|
|||||||
import { request } from "../websocket";
|
import { request } from "../websocket";
|
||||||
|
|
||||||
type Page = "login" | "register" | "chat"
|
type Page = "login" | "register" | "chat"
|
||||||
|
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
|
||||||
|
|
||||||
interface ActiveDM {
|
interface ActiveDM {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -13,7 +14,7 @@ interface ActiveDM {
|
|||||||
interface ChatState {
|
interface ChatState {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
currentChat: string;
|
currentChat: string;
|
||||||
activeTab: "chats" | "channels" | "contacts" | "dms";
|
activeTab: ChatTabs;
|
||||||
dmUsers: User[];
|
dmUsers: User[];
|
||||||
activeDm: ActiveDM | null;
|
activeDm: ActiveDM | null;
|
||||||
isChatSwitching: boolean;
|
isChatSwitching: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user