From dd946e7466f2489044d029e19c6e80fb59450bcf Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 17 Oct 2025 00:19:47 +0300 Subject: [PATCH] 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';