From dd946e7466f2489044d029e19c6e80fb59450bcf Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 17 Oct 2025 00:19:47 +0300 Subject: [PATCH 1/4] Implement username search --- .cursor/commands/clean-up.md | 44 ++++- backend/routes/account.py | 18 +- backend/routes/messaging.py | 43 +++++ frontend/src/core/api/dmApi.ts | 20 +++ frontend/src/core/components/SearchBar.tsx | 118 +++++++++++++ .../src/core/components/css/searchBar.scss | 154 +++++++++++++++++ frontend/src/css/style.scss | 1 + frontend/src/pages/chat/css/_left-panel.scss | 9 +- frontend/src/pages/chat/hooks/useDM.ts | 28 ++-- frontend/src/pages/chat/state.ts | 4 +- .../src/pages/chat/ui/left/DMUsersList.tsx | 18 +- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 11 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 157 ++++++++++++++++++ frontend/src/utils/material.ts | 1 + 14 files changed, 582 insertions(+), 44 deletions(-) create mode 100644 frontend/src/core/components/SearchBar.tsx create mode 100644 frontend/src/core/components/css/searchBar.scss create mode 100644 frontend/src/pages/chat/ui/left/UsernameSearch.tsx diff --git a/.cursor/commands/clean-up.md b/.cursor/commands/clean-up.md index b18b60b..5c020eb 100644 --- a/.cursor/commands/clean-up.md +++ b/.cursor/commands/clean-up.md @@ -1,4 +1,40 @@ -View git diff between the branch i specified and HEAD. If no branch is specified, -default to main. Identify code that needs to be cleaned up, like debug logs, -unused variables etc. Think twice before removing or adding code, because you -mustn't alter the behavior. \ No newline at end of file +# Code Cleanup Command + +## Overview +Analyze git diff between the specified branch and HEAD (defaults to main if no branch specified) and clean up code quality issues without altering functionality. + +## Process +1. **Get diff**: Run `git diff ..HEAD` to see changes +2. **Identify issues**: Look for code quality problems in the diff +3. **Clean up**: Remove only the identified issues +4. **Verify**: Ensure no behavioral changes + +## What to Clean Up +- **Debug artifacts**: `console.log()`, `debugger`, `print()` statements +- **Unused code**: Variables, imports, functions, parameters +- **Commented code**: Dead code blocks, TODO comments (unless active) +- **Formatting**: Inconsistent spacing, trailing whitespace +- **Temporary code**: Test values, hardcoded strings meant to be dynamic +- **Redundant code**: Duplicate logic, unnecessary intermediate variables + +## What NOT to Touch +- **Functional logic**: Don't change how features work +- **API interfaces**: Keep method signatures intact +- **Configuration**: Don't modify settings or constants +- **Comments**: Keep documentation and explanatory comments +- **Error handling**: Don't remove try-catch blocks or validation + +## Safety Rules +- ✅ Only modify code that appears in the git diff +- ✅ Preserve all existing functionality +- ✅ Maintain code readability and structure +- ❌ Don't refactor or optimize beyond cleanup +- ❌ Don't add new features or improvements +- ❌ Don't change variable names or function signatures + +## Example +```bash +# If user specifies: "/clean-up main" +git diff main +# Clean only the issues found in this diff +``` \ No newline at end of file diff --git a/backend/routes/account.py b/backend/routes/account.py index 4f0e0af..0448417 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -222,4 +222,20 @@ def list_users(current_user: User = Depends(get_current_user), db: Session = Dep @router.get("/crypto/public-key/of/{user_id}") def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first() - return {"publicKey": row.public_key_b64 if row else None} \ No newline at end of file + return {"publicKey": row.public_key_b64 if row else None} + + +@router.get("/users/search") +def search_users(q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + if len(q.strip()) < 2: + return {"users": []} + + # Case-insensitive partial match on username + users = db.query(User).filter( + User.username.ilike(f"%{q.strip()}%"), + User.id != current_user.id # Exclude current user + ).order_by(User.username.asc()).limit(20).all() + + return { + "users": [convert_user(u) for u in users] + } \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index d76a28d..dc976d6 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -9,6 +9,7 @@ from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db +from .account import convert_user from constants import OWNER_USERNAME from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse from push_service import push_service @@ -448,6 +449,48 @@ async def dm_history(other_user_id: int, current_user: User = Depends(get_curren ) +@router.get("/dm/conversations") +async def get_dm_conversations(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + # Get all DM conversations where current user is involved + conversations_query = db.query(DMEnvelope).filter( + (DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id) + ).order_by(DMEnvelope.timestamp.desc()) + + # Group by the "other user" (not current user) and get latest message + conversations = {} + for envelope in conversations_query: + other_user_id = envelope.recipient_id if envelope.sender_id == current_user.id else envelope.sender_id + + if other_user_id not in conversations: + conversations[other_user_id] = envelope + + # Get user info for each conversation + result = [] + for other_user_id, latest_message in conversations.items(): + other_user = db.query(User).filter(User.id == other_user_id).first() + if other_user: + # Calculate unread count for this conversation + unread_count = db.query(DMEnvelope).filter( + DMEnvelope.sender_id == other_user_id, + DMEnvelope.recipient_id == current_user.id, + DMEnvelope.id > getattr(latest_message, 'last_read_id', 0) # This would need to be stored somewhere + ).count() + + result.append({ + "user": convert_user(other_user), + "lastMessage": convert_dm_envelope(latest_message), + "unreadCount": unread_count + }) + + # Sort by latest message timestamp + result.sort(key=lambda x: x["lastMessage"]["timestamp"], reverse=True) + + return { + "status": "success", + "conversations": result + } + + @router.put("/edit_message/{message_id}") async def edit_message( message_id: int, diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index 629285a..4b3bd71 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -169,3 +169,23 @@ export async function deleteDmEnvelope(id: number, recipientId: number, authToke data: { id, recipientId } }); } + +export async function fetchDMConversations(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/dm/conversations`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.conversations || []; +} + +export async function searchUsers(query: string, token: string): Promise { + if (query.length < 2) return []; + + const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} diff --git a/frontend/src/core/components/SearchBar.tsx b/frontend/src/core/components/SearchBar.tsx new file mode 100644 index 0000000..b73593d --- /dev/null +++ b/frontend/src/core/components/SearchBar.tsx @@ -0,0 +1,118 @@ +import { useState, useEffect, useRef } from "react"; +import "./css/searchBar.scss"; + +interface SearchBarProps { + placeholder: string; + children?: React.ReactNode; + searchQuery: string; + onQueryChange: (query: string) => void; + isExpanded: boolean; + onToggleExpanded: () => void; + leftIcon?: string | React.ReactNode; + rightIcon?: string | React.ReactNode; +} + +export default function SearchBar({ + placeholder, + children, + searchQuery, + onQueryChange, + isExpanded, + onToggleExpanded, + leftIcon = "search--outlined", + rightIcon = null +}: SearchBarProps) { + const [dynamicHeight, setDynamicHeight] = useState("48px"); + const searchContainerRef = useRef(null); + const inputRef = useRef(null); + const parentContainerRef = useRef(null); + + + // Focus input when expanded and manage height + useEffect(() => { + if (isExpanded && inputRef.current) { + inputRef.current.focus(); + // Set expanded height + const leftPanel = document.getElementById('chat-list'); + if (leftPanel) { + const panelHeight = leftPanel.offsetHeight; + setDynamicHeight(`${panelHeight}px`); + } + } else { + // Set collapsed height + setDynamicHeight("48px"); + } + }, [isExpanded]); + + function handleToggle() { + onToggleExpanded(); + }; + + function handleQueryChange(e: React.ChangeEvent) { + const query = e.target.value; + onQueryChange(query); + }; + + // Helper function to render icon + function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) { + if (icon === null) return null; + if (!icon) { + return defaultIcon ? : null; + } else if (typeof icon === 'string') { + return ; + } else { + return icon; + } + }; + + return ( +
+
+ {/* Single Search Bar Element */} +
+ {/* Left Icon */} +
+ {renderIcon(leftIcon, "search--outlined")} +
+ + {/* Input/Placeholder */} +
+ {isExpanded ? ( + e.stopPropagation()} + /> + ) : ( + {placeholder} + )} +
+ + {/* Right Icon */} +
+ {renderIcon(rightIcon)} +
+
+ + {/* Results Section - Only visible when expanded */} + {isExpanded && ( +
+ {children} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/core/components/css/searchBar.scss b/frontend/src/core/components/css/searchBar.scss new file mode 100644 index 0000000..ea84499 --- /dev/null +++ b/frontend/src/core/components/css/searchBar.scss @@ -0,0 +1,154 @@ +@use "../../../css/material" as *; + +$font-size: 16px; + +// Search container +.search-parent { + position: relative; + height: 100%; + width: 100%; +} + +// SearchBar component styles +.search-bar-container { + position: absolute; + z-index: 1001; + overflow: hidden; + + // All properties animate together simultaneously + transition: + height 0.4s cubic-bezier(0.4, 0, 0.2, 1), + top 0.4s cubic-bezier(0.4, 0, 0.2, 1), + left 0.4s cubic-bezier(0.4, 0, 0.2, 1), + right 0.4s cubic-bezier(0.4, 0, 0.2, 1), + border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + // Initial background color for smooth transition + background-color: $color-dark-surface-container-high; + + &.collapsed { + top: 8px; + left: 16px; + right: 16px; + border-radius: 24px; + // Height will be set dynamically by React (48px) + // background-color inherited from parent + } + + &.expanded { + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 0; + background-color: $color-dark-surface-container; + // Height will be set dynamically by React + } + + // Single search bar element + .search-bar { + display: flex; + align-items: center; + padding: 0 16px; + height: 48px; + gap: 12px; + cursor: pointer; + + .search-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + + mdui-icon { + color: $color-dark-on-surface-variant; + font-size: 20px; + cursor: pointer; + } + } + + .search-input-container { + flex: 1; + display: flex; + align-items: center; + + .search-placeholder { + color: $color-dark-on-surface-variant; + font-size: $font-size; + pointer-events: none; + } + + .search-input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: $color-dark-on-surface; + font-size: $font-size; + padding: 8px 0; + pointer-events: auto; + + &::placeholder { + color: $color-dark-on-surface-variant; + } + } + } + + .search-clear { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + + mdui-icon { + color: $color-dark-on-surface-variant; + font-size: 20px; + cursor: pointer; + } + } + } + + // Results section + .search-results { + flex: 1; + overflow-y: auto; + + .search-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 32px; + color: $color-dark-on-surface-variant; + + mdui-circular-progress { + --mdui-circular-progress-color: $color-dark-primary; + } + } + + .search-empty, + .search-hint { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + color: $color-dark-on-surface-variant; + text-align: center; + } + + // Custom styling for search result images + mdui-list-item { + img[slot="icon"] { + $size: 48px; + + width: $size; + height: $size; + border-radius: 50%; + object-fit: cover; + } + } + } +} diff --git a/frontend/src/css/style.scss b/frontend/src/css/style.scss index 9ab39fe..993bfec 100644 --- a/frontend/src/css/style.scss +++ b/frontend/src/css/style.scss @@ -16,6 +16,7 @@ body { background-color: $color-dark-surface; color: $color-dark-on-surface; line-height: 1.6; + overflow: hidden; #main-wrapper { flex: 1; diff --git a/frontend/src/pages/chat/css/_left-panel.scss b/frontend/src/pages/chat/css/_left-panel.scss index 1c47a74..27add66 100644 --- a/frontend/src/pages/chat/css/_left-panel.scss +++ b/frontend/src/pages/chat/css/_left-panel.scss @@ -96,6 +96,7 @@ height: 100%; z-index: 1000; min-height: 0; // allow children to manage their own scrolling + position: relative; // provide positioning context for absolute children .chat-header-left { display: flex; @@ -106,7 +107,6 @@ justify-content: center; align-items: center; padding: 16px; - overflow: hidden; .product-name { flex-grow: 1; @@ -192,6 +192,13 @@ } } +// Search container +.search-container { + position: relative; + flex-shrink: 0; + height: 48px + 8px; +} + // ChatHeader component styles .chat-header-left { .product-name { diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 07029c3..989aa95 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -1,11 +1,11 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useAppState } from "@/pages/chat/state"; import { - fetchUsers, fetchUserPublicKey, fetchDMHistory, decryptDm, - sendDMViaWebSocket + sendDMViaWebSocket, + fetchDMConversations } from "@/core/api/dmApi"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -72,32 +72,28 @@ export function useDM() { } }, [user.authToken]); - // Load users when DM tab is active + // Load DM conversations when chats 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, + const conversations = await fetchDMConversations(user.authToken); + console.log("Fetched conversations:", conversations); + + const dmUsersWithState: DMUser[] = conversations.map((conv: any) => ({ + ...conv.user, + unreadCount: conv.unreadCount, + lastMessage: conv.lastMessage ? "Последнее сообщение" : undefined, publicKey: null })); setDmUsersState(dmUsersWithState); - setDmUsers(users); + setDmUsers(conversations.map((conv: any) => conv.user)); - // Load last messages and unread counts for visible users - // Call loadUserLastMessage directly without dependency - for (const dmUser of dmUsersWithState) { - await loadUserLastMessage(dmUser); - } } catch (error) { - console.error("Failed to load DM users:", error); + console.error("Failed to load DM conversations:", error); } finally { setIsLoadingUsers(false); } diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index cf16791..a14b2de 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -10,7 +10,7 @@ import { API_BASE_URL } from "@/core/config"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; -export type ChatTabs = "chats" | "channels" | "contacts" | "dms"; +export type ChatTabs = "chats" | "channels" | "contacts"; export type CallStatus = "calling" | "connecting" | "active" | "ended"; @@ -403,7 +403,7 @@ export const useAppState = create((set, get) => ({ username: dmData.username, publicKey: dmData.publicKey }, - activeTab: "dms" + activeTab: "chats" } })); diff --git a/frontend/src/pages/chat/ui/left/DMUsersList.tsx b/frontend/src/pages/chat/ui/left/DMUsersList.tsx index 6c25f09..04ce84a 100644 --- a/frontend/src/pages/chat/ui/left/DMUsersList.tsx +++ b/frontend/src/pages/chat/ui/left/DMUsersList.tsx @@ -9,28 +9,14 @@ export function DMUsersList() { const { chat, switchToDM } = useAppState(); useEffect(() => { - if (chat.activeTab === "dms") { + if (chat.activeTab === "chats") { loadUsers(); } }, [chat.activeTab, loadUsers]); if (isLoadingUsers) { return ( - - - - - - ); - } - - if (dmUsers.length === 0) { - return ( - - - - - + ); } diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index a6bf4d8..8340d82 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -5,6 +5,7 @@ import { useState, type FormEvent } from "react"; import { ProfileDialog } from "./profile/ProfileDialog"; import { SettingsDialog } from "./settings/SettingsDialog"; import { DMUsersList } from "./DMUsersList"; +import { UsernameSearch } from "./UsernameSearch"; import type { Tabs } from "mdui"; import type { ChatTabs } from "@/pages/chat/state"; @@ -54,7 +55,6 @@ function ChatTabs() { Чаты Каналы Контакты - ЛС @@ -76,13 +76,13 @@ function ChatTabs() { > + + {/* DM conversations will be loaded here */} + Скоро будет... Скоро будет... - - - ); @@ -109,6 +109,9 @@ export function LeftPanel() { return (
+
+ +
diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx new file mode 100644 index 0000000..0ee7f4d --- /dev/null +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -0,0 +1,157 @@ +import { useState, useEffect } from "react"; +import { useAppState } from "@/pages/chat/state"; +import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi"; +import type { User } from "@/core/types"; +import defaultAvatar from "@/images/default-avatar.png"; +import SearchBar from "@/core/components/SearchBar"; + +interface SearchUser extends User { + publicKey?: string | null; +} + +export function UsernameSearch() { + const { user, switchToDM } = useAppState(); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [debounceTimeout, setDebounceTimeout] = useState(null); + + // Debounced search + useEffect(() => { + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + + if (searchQuery.length > 1) { + setIsSearching(true); + const newTimeout = setTimeout(async () => { + if (user.authToken) { + try { + const users = await searchUsers(searchQuery, user.authToken); + setSearchResults(users); + } catch (error) { + console.error("Search failed:", error); + setSearchResults([]); + } finally { + setIsSearching(false); + } + } + }, 300); + setDebounceTimeout(newTimeout); + } else { + setSearchResults([]); + setIsSearching(false); + } + + return () => { + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + }; + }, [searchQuery, user.authToken]); + + async function handleUserClick(searchUser: SearchUser) { + if (!user.authToken) return; + + try { + let publicKey = searchUser.publicKey; + if (!publicKey) { + const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken); + publicKey = fetchedPublicKey; + } + + if (publicKey) { + switchToDM({ + userId: searchUser.id, + username: searchUser.username, + publicKey: publicKey, + profilePicture: searchUser.profile_picture, + online: searchUser.online || false + }); + // Collapse search + setIsExpanded(false); + setSearchQuery(""); + setSearchResults([]); + } + } catch (error) { + console.error("Failed to start DM conversation:", error); + } + } + + function handleQueryChange(query: string) { + setSearchQuery(query); + } + + function handleToggleExpanded() { + if (isExpanded) { + // Collapsing + setSearchQuery(""); + setSearchResults([]); + } + setIsExpanded(!isExpanded); + } + + return ( + { + e.stopPropagation(); + handleToggleExpanded(); + }} + type="button" + icon="arrow_back--outlined" + /> + ) : "search--outlined"} + > + {isSearching && ( +
+ + Поиск... +
+ )} + + {!isSearching && searchQuery.length >= 2 && searchResults.length === 0 && ( +
+ Пользователи не найдены +
+ )} + + {!isSearching && searchResults.length > 0 && ( + + {searchResults.map((searchUser) => ( + handleUserClick(searchUser)} + style={{ cursor: "pointer" }} + > + {searchUser.username} { + (e.target as HTMLImageElement).src = defaultAvatar; + }} + /> + + ))} + + )} + + {!isSearching && searchQuery.length < 2 && ( +
+ Введите минимум 2 символа для поиска +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/utils/material.ts b/frontend/src/utils/material.ts index 7ab2607..f684d15 100644 --- a/frontend/src/utils/material.ts +++ b/frontend/src/utils/material.ts @@ -22,6 +22,7 @@ import 'mdui/components/top-app-bar-title'; import 'mdui/components/switch'; import 'mdui/components/chip'; import "mdui/mdui.css"; +import 'mdui/components/circular-progress'; import { setColorScheme } from 'mdui/functions/setColorScheme.js'; From a92f91d79189399e526b114af7fb744ab24cb34d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 17 Oct 2025 15:52:12 +0300 Subject: [PATCH 2/4] Implement live last message update --- .cursor/rules/general.mdc | 1 + backend/routes/messaging.py | 2 + frontend/src/core/api/dmApi.ts | 8 +- frontend/src/core/types.d.ts | 3 + frontend/src/pages/chat/css/_left-panel.scss | 9 + frontend/src/pages/chat/hooks/useDM.ts | 255 +++++++++++----- .../src/pages/chat/ui/left/BottomAppBar.tsx | 0 frontend/src/pages/chat/ui/left/ChatTabs.tsx | 43 +-- .../src/pages/chat/ui/left/DMUsersList.tsx | 80 ------ frontend/src/pages/chat/ui/left/LeftPanel.tsx | 79 +---- .../pages/chat/ui/left/UnifiedChatsList.tsx | 272 ++++++++++++++++++ .../src/pages/chat/ui/left/UsernameSearch.tsx | 4 +- .../src/pages/chat/ui/right/ChatMessages.tsx | 10 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 10 +- 14 files changed, 518 insertions(+), 258 deletions(-) delete mode 100644 frontend/src/pages/chat/ui/left/BottomAppBar.tsx delete mode 100644 frontend/src/pages/chat/ui/left/DMUsersList.tsx create mode 100644 frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 0eefb7f..8504a0a 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -16,6 +16,7 @@ When working with this project, follow these rules: - Use double quotes ("") for strings consistently. - Prefer functional components over class components in React. - Use TypeScript strictly - avoid `any` types unless absolutely necessary. +- DO NOT leave placeholders - ask me when it would be better or implement it fully. ## File Operations - If possible, try to update files in a single edit when making multiple changes. diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index dc976d6..d24dacc 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -835,6 +835,8 @@ class MessaggingSocketManager: "type": "dmEdited", "data": { "id": env.id, + "senderId": env.sender_id, + "recipientId": env.recipient_id, "iv": env.iv_b64, "ciphertext": env.ciphertext_b64, "iv2": env.iv2_b64, diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index 4b3bd71..e2003d8 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -170,7 +170,13 @@ export async function deleteDmEnvelope(id: number, recipientId: number, authToke }); } -export async function fetchDMConversations(token: string): Promise { +export interface DMConversationResponse { + user: User; + lastMessage: DmEnvelope; + unreadCount: number; +} + +export async function fetchDMConversations(token: string): Promise { const res = await fetch(`${API_BASE_URL}/dm/conversations`, { headers: getAuthHeaders(token, true) }); diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 940ba78..6b9382c 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -305,11 +305,14 @@ export interface Attachment { // Utils export interface DMEditPayload { id: number; + senderId: number; + recipientId: number; iv: string; ciphertext: string; iv2: string; wrappedMk: string; salt: string; + timestamp: string; } // Requests diff --git a/frontend/src/pages/chat/css/_left-panel.scss b/frontend/src/pages/chat/css/_left-panel.scss index 27add66..4f4eced 100644 --- a/frontend/src/pages/chat/css/_left-panel.scss +++ b/frontend/src/pages/chat/css/_left-panel.scss @@ -239,3 +239,12 @@ } } } + +// Description styling for list items +.list-description { + word-wrap: break-word; + overflow: hidden; + display: -webkit-box; + line-clamp: 2; + -webkit-box-orient: vertical; +} diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 989aa95..9e4b36d 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -5,7 +5,8 @@ import { fetchDMHistory, decryptDm, sendDMViaWebSocket, - fetchDMConversations + fetchDMConversations, + type DMConversationResponse } from "@/core/api/dmApi"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -16,8 +17,34 @@ export interface DMUser extends User { publicKey?: string | null; } +// Utility function for consistent username formatting in DM messages +export function formatDMUsername( + senderId: number, + _recipientId: number, + currentUserId: number, + otherUsername: string +): string { + const isFromCurrentUser = senderId === currentUserId; + return isFromCurrentUser ? "Вы" : otherUsername; +} + +// Utility function for consistent message content formatting +export function formatDMMessageContent( + content: string, + senderId: number, + currentUserId: number +): string { + const isFromCurrentUser = senderId === currentUserId; + const prefix = isFromCurrentUser ? "Вы: " : ""; + const maxContentLength = 50 - prefix.length; + const truncatedContent = content.length > maxContentLength + ? content.substring(0, maxContentLength) + "..." + : content; + return prefix + truncatedContent; +} + export function useDM() { - const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState(); + const { user, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState(); const [dmUsers, setDmUsersState] = useState([]); const [isLoadingUsers, setIsLoadingUsers] = useState(false); const [isLoadingHistory, setIsLoadingHistory] = useState(false); @@ -80,17 +107,42 @@ export function useDM() { setIsLoadingUsers(true); try { const conversations = await fetchDMConversations(user.authToken); - console.log("Fetched conversations:", conversations); - const dmUsersWithState: DMUser[] = conversations.map((conv: any) => ({ - ...conv.user, - unreadCount: conv.unreadCount, - lastMessage: conv.lastMessage ? "Последнее сообщение" : undefined, - publicKey: null - })); + // Process conversations and decrypt last messages + const dmUsersWithState: DMUser[] = await Promise.all( + conversations.map(async (conv: DMConversationResponse) => { + let lastMessageContent: string | undefined = undefined; + + if (conv.lastMessage) { + try { + // Get the public key for the other user + const otherUserId = conv.lastMessage.senderId === user.currentUser?.id + ? conv.lastMessage.recipientId + : conv.lastMessage.senderId; + + const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + if (publicKey) { + // Decrypt the last message + const decryptedJson = await decryptDm(conv.lastMessage, publicKey!); + const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; + lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!); + } + } catch (error) { + console.error("Failed to decrypt last message for user", conv.user.id, error); + } + } + + return { + ...conv.user, + unreadCount: conv.unreadCount, + lastMessage: lastMessageContent, + publicKey: null + }; + }) + ); setDmUsersState(dmUsersWithState); - setDmUsers(conversations.map((conv: any) => conv.user)); + setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user)); } catch (error) { console.error("Failed to load DM conversations:", error); @@ -192,7 +244,67 @@ export function useDM() { } }, [user.authToken, setActiveDm, loadDMHistory]); - // WebSocket message handler + // Force reload users (useful for refreshing the list) + const reloadUsers = useCallback(() => { + usersLoadedRef.current = false; + loadUsers(); + }, [loadUsers]); + + // Reload a specific user's conversation data + const reloadUserConversation = useCallback(async (userId: number) => { + if (!user.authToken) return; + + try { + const conversations = await fetchDMConversations(user.authToken); + const userConversation = conversations.find(conv => conv.user.id === userId); + + if (userConversation) { + let lastMessageContent: string | undefined = undefined; + + if (userConversation.lastMessage) { + try { + // Get the public key for the other user + const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id + ? userConversation.lastMessage.recipientId + : userConversation.lastMessage.senderId; + + const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + if (publicKey) { + // Decrypt the last message + const decryptedJson = await decryptDm(userConversation.lastMessage, publicKey!); + const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; + lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!); + } + } catch (error) { + console.error("Failed to decrypt last message for user", userId, error); + } + } + + // Update the specific user in the state + setDmUsersState(prev => prev.map(u => { + if (u.id === userId) { + return { + ...u, + lastMessage: lastMessageContent, + unreadCount: userConversation.unreadCount + }; + } + return u; + })); + } else { + // If conversation no longer exists, remove the user from the list + setDmUsersState(prev => prev.filter(u => u.id !== userId)); + // Get current dmUsers and filter out the removed user + const currentDmUsers = useAppState.getState().chat.dmUsers; + setDmUsers(currentDmUsers.filter((u: User) => u.id !== userId)); + } + } catch (error) { + console.error("Failed to reload user conversation:", error); + } + }, [user.authToken]); + + + // WebSocket message handler for conversation list updates useEffect(() => { async function handleWebSocketMessage(e: MessageEvent) { try { @@ -200,56 +312,70 @@ export function useDM() { 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); - } + // Update conversation list (not active conversation - that's handled by DMPanel) + if (!user.currentUser?.id) { + return; } + const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; + + // Update unread count and last message preview + try { + const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + if (publicKey) { + const decryptedJson = await decryptDm(envelope, publicKey); + const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; + const messageContent = decryptedData.data.content; + const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); + + setDmUsersState(prev => prev.map(u => + u.id === otherUserId + ? { + ...u, + unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount, + lastMessage: formattedMessage, + publicKey + } + : u + )); + } + } catch (error) { + console.error("Failed to update last message preview:", error); + } + } else if (msg.type === "dmEdited") { + const { id, senderId, recipientId, ...envelope } = msg.data; + + // Update last message preview for conversation list + if (!user.currentUser?.id) { + return; + } + const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; + try { + const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + if (publicKey) { + const decryptedJson = await decryptDm(envelope, publicKey); + const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; + const messageContent = decryptedData.data.content; + const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); + setDmUsersState(prev => prev.map(u => + u.id === otherUserId + ? { + ...u, + lastMessage: formattedMessage, + publicKey + } + : u + )); + } + } catch (error) { + console.error("Failed to update edited message preview:", error); + } + } else if (msg.type === "dmDeleted") { + const { senderId, recipientId } = msg.data; + + // Reload only the specific user's conversation + if (!user.currentUser?.id) return; + const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; + reloadUserConversation(otherUserId); } } catch (error) { console.error("Failed to handle WebSocket message:", error); @@ -259,13 +385,7 @@ export function useDM() { 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]); + }, [user.currentUser, user.authToken, reloadUserConversation]); return { dmUsers, @@ -273,6 +393,7 @@ export function useDM() { isLoadingHistory, loadUsers, reloadUsers, + reloadUserConversation, startDMConversation, sendDMMessage, loadUserLastMessage diff --git a/frontend/src/pages/chat/ui/left/BottomAppBar.tsx b/frontend/src/pages/chat/ui/left/BottomAppBar.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/pages/chat/ui/left/ChatTabs.tsx b/frontend/src/pages/chat/ui/left/ChatTabs.tsx index bf5bb8f..01f6460 100644 --- a/frontend/src/pages/chat/ui/left/ChatTabs.tsx +++ b/frontend/src/pages/chat/ui/left/ChatTabs.tsx @@ -1,11 +1,21 @@ -import { useAppState } from "@/pages/chat/state"; +import { useAppState, type ChatTabs } from "@/pages/chat/state"; +import { UnifiedChatsList } from "./UnifiedChatsList"; +import type { FormEvent } from "react"; +import type { Tabs } from "mdui/components/tabs"; export function ChatTabs() { - const { chat, setActiveTab, switchToPublicChat } = useAppState(); + const { chat, setActiveTab } = useAppState(); + + function handleChange(e: FormEvent & CustomEvent<{ value: string }>) { + setActiveTab(e.detail.value as ChatTabs); + } return (
- setActiveTab(e.detail.value)}> + Чаты @@ -15,37 +25,12 @@ export function ChatTabs() { Контакты - - ЛС - - - await switchToPublicChat("Общий чат")} - style={{ cursor: "pointer" }} - > - - - await switchToPublicChat("Общий чат 2")} - style={{ cursor: "pointer" }} - > - - - + Скоро будет... Скоро будет... - - -
); diff --git a/frontend/src/pages/chat/ui/left/DMUsersList.tsx b/frontend/src/pages/chat/ui/left/DMUsersList.tsx deleted file mode 100644 index 04ce84a..0000000 --- a/frontend/src/pages/chat/ui/left/DMUsersList.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect } from "react"; -import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; -import { useAppState } from "@/pages/chat/state"; -import { fetchUserPublicKey } from "@/core/api/dmApi"; -import defaultAvatar from "@/images/default-avatar.png"; - -export function DMUsersList() { - const { dmUsers, isLoadingUsers, loadUsers } = useDM(); - const { chat, switchToDM } = useAppState(); - - useEffect(() => { - if (chat.activeTab === "chats") { - loadUsers(); - } - }, [chat.activeTab, loadUsers]); - - if (isLoadingUsers) { - return ( - - ); - } - - async function handleUserClick(user: DMUser) { - if (!user.publicKey) { - // Get public key if not already loaded - const authToken = useAppState.getState().user.authToken; - if (!authToken) 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 ( - - {dmUsers.map((user: DMUser) => ( - handleUserClick(user)} - style={{ cursor: "pointer" }} - > - {user.username} { - (e.target as HTMLImageElement).src = defaultAvatar; - }} - /> - {user.unreadCount > 0 && ( - - {user.unreadCount} - - )} - - ))} - - ); -} diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index 8340d82..47401bf 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -1,13 +1,9 @@ -import { PRODUCT_NAME } from "@/core/config"; import { useAppState } from "@/pages/chat/state"; -import defaultAvatar from "@/images/default-avatar.png"; -import { useState, type FormEvent } from "react"; -import { ProfileDialog } from "./profile/ProfileDialog"; +import { useState } from "react"; import { SettingsDialog } from "./settings/SettingsDialog"; -import { DMUsersList } from "./DMUsersList"; import { UsernameSearch } from "./UsernameSearch"; -import type { Tabs } from "mdui"; -import type { ChatTabs } from "@/pages/chat/state"; +import { ChatTabs } from "./ChatTabs"; +import { ChatHeader } from "./ChatHeader"; function BottomAppBar() { const [settingsOpen, onSettingsOpenChange] = useState(false); @@ -36,75 +32,6 @@ function BottomAppBar() { ); } - -function ChatTabs() { - const { chat, setActiveTab, switchToPublicChat } = useAppState(); - const { activeTab } = chat; - - async function handleChatClick(chatName: string) { - await switchToPublicChat(chatName); - } - - function handleTabChange(e: FormEvent) { - setActiveTab((e.target as Tabs).value as ChatTabs); - } - - return ( -
- - Чаты - Каналы - Контакты - - - - handleChatClick("Общий чат")} - style={{ cursor: "pointer" }} - > - - - handleChatClick("Общий чат 2")} - style={{ cursor: "pointer" }} - > - - - - {/* DM conversations will be loaded here */} - - - - Скоро будет... - Скоро будет... - -
- ); -} - - -function ChatHeader() { - const [isProfileOpen, setProfileOpen] = useState(false); - - return ( -
-
{PRODUCT_NAME}
- - -
- ); -} - export function LeftPanel() { return (
diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx new file mode 100644 index 0000000..7d4ea27 --- /dev/null +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -0,0 +1,272 @@ +import { useState, useEffect, useCallback } from "react"; +import { useAppState } from "@/pages/chat/state"; +import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "@/core/api/authApi"; +import { fetchUserPublicKey } from "@/core/api/dmApi"; +import type { Message } from "@/core/types"; +import { websocket } from "@/core/websocket"; +import defaultAvatar from "@/images/default-avatar.png"; + +interface PublicChat { + id: string; + name: string; + type: "public"; + lastMessage?: Message; +} + +interface DMConversation { + id: number; + username: string; + profile_picture?: string; + online?: boolean; + type: "dm"; + lastMessage?: string; + unreadCount: number; + publicKey?: string | null; +} + +type ChatItem = PublicChat | DMConversation; + +export function UnifiedChatsList() { + const { user, switchToPublicChat, switchToDM, chat } = useAppState(); + const { dmUsers, isLoadingUsers, loadUsers } = useDM(); + + const [publicChats] = useState([ + { id: "general", name: "Общий чат", type: "public" }, + { id: "general2", name: "Общий чат 2", type: "public" } + ]); + const [lastMessages, setLastMessages] = useState>({}); + const [allChats, setAllChats] = useState([]); + + // Load public chat last messages + const loadLastMessages = useCallback(async () => { + if (!user.authToken) return; + + try { + const response = await fetch(`${API_BASE_URL}/get_messages`, { + headers: getAuthHeaders(user.authToken) + }); + + if (response.ok) { + const data = await response.json(); + if (data.messages && data.messages.length > 0) { + const lastMessage = data.messages[data.messages.length - 1]; + + setLastMessages({ + general: lastMessage, + general2: lastMessage + }); + } + } + } catch (error) { + console.error("Error loading last messages:", error); + } + }, [user.authToken]); + + // Load DM users when chats tab is active + useEffect(() => { + if (chat.activeTab === "chats") { + loadUsers(); + loadLastMessages(); + } + }, [chat.activeTab, loadUsers, loadLastMessages]); + + // Combine public chats and DMs into one list + useEffect(() => { + const publicChatItems: ChatItem[] = publicChats.map(chat => ({ + ...chat, + lastMessage: lastMessages[chat.id] + })); + + const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({ + id: user.id, + username: user.username, + profile_picture: user.profile_picture, + online: user.online, + type: "dm" as const, + lastMessage: user.lastMessage, + unreadCount: user.unreadCount, + publicKey: user.publicKey + })); + + // Combine and sort by last message timestamp (DMs first, then public chats) + const combined = [...dmChatItems, ...publicChatItems]; + setAllChats(combined); + }, [publicChats, lastMessages, dmUsers]); + + // WebSocket listener for public chat message updates + useEffect(() => { + if (!websocket) return; + + const handleWebSocketMessage = (e: MessageEvent) => { + try { + const msg = JSON.parse(e.data); + + if (msg.type === "newMessage") { + const newMessage = msg.data as Message; + // Update all public chats with the new message + setLastMessages(prev => { + const updated = { ...prev }; + publicChats.forEach(chat => { + updated[chat.id] = newMessage; + }); + return updated; + }); + } else if (msg.type === "messageEdited") { + const editedMessage = msg.data as Message; + // Update only if the edited message is the current last message + setLastMessages(prev => { + const updated = { ...prev }; + publicChats.forEach(chat => { + if (updated[chat.id]?.id === editedMessage.id) { + updated[chat.id] = editedMessage; + } + }); + return updated; + }); + } else if (msg.type === "messageDeleted") { + const deletedMessageId = msg.data?.message_id; + let needsReload = false; + + setLastMessages(prev => { + const updated = { ...prev }; + publicChats.forEach(chat => { + if (updated[chat.id]?.id === deletedMessageId) { + updated[chat.id] = undefined; + needsReload = true; + } + }); + return updated; + }); + + if (needsReload) { + loadLastMessages(); + } + } + } catch (error) { + console.error("Failed to handle WebSocket message in UnifiedChatsList:", error); + } + }; + + websocket.addEventListener("message", handleWebSocketMessage); + return () => websocket.removeEventListener("message", handleWebSocketMessage); + }, [publicChats, loadLastMessages]); + + const formatPublicChatMessage = (chatId: string): string => { + const lastMessage = lastMessages[chatId]; + if (!lastMessage) { + return ""; + } + + const isCurrentUser = lastMessage.username === user.currentUser?.username; + const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `; + + const maxContentLength = 50 - prefix.length; + const content = lastMessage.content.length > maxContentLength + ? lastMessage.content.substring(0, maxContentLength) + "..." + : lastMessage.content; + + return prefix + content; + }; + + + const handlePublicChatClick = async (chatName: string) => { + await switchToPublicChat(chatName); + }; + + const handleDMClick = async (dmConversation: DMConversation) => { + if (!dmConversation.publicKey) { + const authToken = useAppState.getState().user.authToken; + if (!authToken) return; + + const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); + if (publicKey) { + dmConversation.publicKey = publicKey; + } else { + console.error("Failed to get public key for user:", dmConversation.id); + return; + } + } + + await switchToDM({ + userId: dmConversation.id, + username: dmConversation.username, + publicKey: dmConversation.publicKey, + profilePicture: dmConversation.profile_picture, + online: dmConversation.online || false + }); + }; + + if (isLoadingUsers) { + return ( + + ); + } + + return ( + + {allChats.map((chat) => { + if (chat.type === "public") { + return ( + handlePublicChatClick(chat.name)} + style={{ cursor: "pointer" }} + > + {formatPublicChatMessage(chat.id) && ( + + {formatPublicChatMessage(chat.id)} + + )} + {chat.name} + + ); + } else { + return ( + handleDMClick(chat)} + style={{ cursor: "pointer" }} + > + + {chat.lastMessage || "Нет сообщений"} + + {chat.username} { + (e.target as HTMLImageElement).src = defaultAvatar; + }} + /> + {chat.unreadCount > 0 && ( + + {chat.unreadCount} + + )} + + ); + } + })} + + ); +} diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index 0ee7f4d..af8e328 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -130,10 +130,12 @@ export function UsernameSearch() { handleUserClick(searchUser)} style={{ cursor: "pointer" }} > + + {searchUser.online ? "В сети" : "Не в сети"} + {searchUser.username} { if (response.type === "dmNew" && this.dmData) { From b5b5547927d16dd29a8cebd801d0b99b86c6d51b Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 18 Oct 2025 13:32:04 +0300 Subject: [PATCH 3/4] Implement profile dialog --- .cursor/rules/docs.mdc | 3 +- frontend/src/core/components/RichTextArea.tsx | 10 +- frontend/src/pages/chat/css/_message.scss | 67 ++- .../src/pages/chat/css/_profile-dialog.scss | 387 ++++++++---------- frontend/src/pages/chat/css/chat.scss | 4 +- .../src/pages/chat/css/cropper-dialog.scss | 51 +++ frontend/src/pages/chat/state.ts | 31 ++ frontend/src/pages/chat/ui/ProfileDialog.tsx | 347 ++++++++++++++++ .../src/pages/chat/ui/left/ChatHeader.tsx | 20 +- .../chat/ui/left/profile/CropperDialog.tsx | 2 + .../chat/ui/left/profile/ProfileDialog.tsx | 179 -------- .../src/pages/chat/ui/right/ChatMessages.tsx | 36 -- frontend/src/pages/chat/ui/right/Message.tsx | 57 +-- .../chat/ui/right/MessagePanelRenderer.tsx | 21 +- .../pages/chat/ui/right/UserProfileDialog.tsx | 71 ---- .../src/pages/chat/ui/right/panels/DMPanel.ts | 25 +- .../chat/ui/right/panels/MessagePanel.ts | 4 +- .../chat/ui/right/panels/PublicChatPanel.ts | 10 +- 18 files changed, 735 insertions(+), 590 deletions(-) create mode 100644 frontend/src/pages/chat/css/cropper-dialog.scss create mode 100644 frontend/src/pages/chat/ui/ProfileDialog.tsx delete mode 100644 frontend/src/pages/chat/ui/left/profile/ProfileDialog.tsx delete mode 100644 frontend/src/pages/chat/ui/right/UserProfileDialog.tsx diff --git a/.cursor/rules/docs.mdc b/.cursor/rules/docs.mdc index 4b13faf..a3125d3 100644 --- a/.cursor/rules/docs.mdc +++ b/.cursor/rules/docs.mdc @@ -1,6 +1,5 @@ --- -description: Documentation rules -alwaysApply: false +alwaysApply: true --- When documenting this project, follow these rules: diff --git a/frontend/src/core/components/RichTextArea.tsx b/frontend/src/core/components/RichTextArea.tsx index 8fb44e0..c2854a6 100644 --- a/frontend/src/core/components/RichTextArea.tsx +++ b/frontend/src/core/components/RichTextArea.tsx @@ -10,6 +10,7 @@ interface RichTextAreaProps { className?: string; rows?: number; autoComplete?: string; + readOnly?: boolean; } export function RichTextArea({ @@ -21,6 +22,7 @@ export function RichTextArea({ className, rows = 1, autoComplete = "off", + readOnly = false }: RichTextAreaProps) { const textareaRef = useRef(null); const hiddenTextareaRef = useRef(null); @@ -183,10 +185,10 @@ export function RichTextArea({ value={text} placeholder={placeholder} rows={rows} - autoComplete={autoComplete} - onChange={handleChange} - onKeyDown={handleKeyDown} - /> + autoComplete={readOnly ? "off" : autoComplete} + onChange={readOnly ? undefined : handleChange} + onKeyDown={readOnly ? undefined : handleKeyDown} + readOnly={readOnly} />