Implement username search

This commit is contained in:
2025-10-17 00:19:47 +03:00
Unverified
parent 13c258ad95
commit dd946e7466
14 changed files with 582 additions and 44 deletions
+40 -4
View File
@@ -1,4 +1,40 @@
View git diff between the branch i specified and HEAD. If no branch is specified, # Code Cleanup Command
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 ## Overview
mustn't alter the behavior. 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 <branch>..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
```
+16
View File
@@ -223,3 +223,19 @@ def list_users(current_user: User = Depends(get_current_user), db: Session = Dep
def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): 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() row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
return {"publicKey": row.public_key_b64 if row else None} 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]
}
+43
View File
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from .account import convert_user
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse
from push_service import push_service 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}") @router.put("/edit_message/{message_id}")
async def edit_message( async def edit_message(
message_id: int, message_id: int,
+20
View File
@@ -169,3 +169,23 @@ export async function deleteDmEnvelope(id: number, recipientId: number, authToke
data: { id, recipientId } data: { id, recipientId }
}); });
} }
export async function fetchDMConversations(token: string): Promise<any[]> {
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<User[]> {
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 || [];
}
+118
View File
@@ -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<string>("48px");
const searchContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const parentContainerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) {
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 ? <mdui-icon name={defaultIcon}></mdui-icon> : null;
} else if (typeof icon === 'string') {
return <mdui-icon name={icon}></mdui-icon>;
} else {
return icon;
}
};
return (
<div
ref={parentContainerRef}
className="search-parent"
>
<div
ref={searchContainerRef}
className={`search-bar-container ${isExpanded ? "expanded" : "collapsed"}`}
style={{ height: dynamicHeight }}
onClick={!isExpanded ? handleToggle : undefined}
>
{/* Single Search Bar Element */}
<div className="search-bar">
{/* Left Icon */}
<div className="search-icon">
{renderIcon(leftIcon, "search--outlined")}
</div>
{/* Input/Placeholder */}
<div className="search-input-container">
{isExpanded ? (
<input
ref={inputRef}
type="text"
placeholder={placeholder}
className="search-input"
value={searchQuery}
onChange={handleQueryChange}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="search-placeholder">{placeholder}</span>
)}
</div>
{/* Right Icon */}
<div className="search-clear">
{renderIcon(rightIcon)}
</div>
</div>
{/* Results Section - Only visible when expanded */}
{isExpanded && (
<div className="search-results">
{children}
</div>
)}
</div>
</div>
);
}
@@ -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;
}
}
}
}
+1
View File
@@ -16,6 +16,7 @@ body {
background-color: $color-dark-surface; background-color: $color-dark-surface;
color: $color-dark-on-surface; color: $color-dark-on-surface;
line-height: 1.6; line-height: 1.6;
overflow: hidden;
#main-wrapper { #main-wrapper {
flex: 1; flex: 1;
+8 -1
View File
@@ -96,6 +96,7 @@
height: 100%; height: 100%;
z-index: 1000; z-index: 1000;
min-height: 0; // allow children to manage their own scrolling min-height: 0; // allow children to manage their own scrolling
position: relative; // provide positioning context for absolute children
.chat-header-left { .chat-header-left {
display: flex; display: flex;
@@ -106,7 +107,6 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 16px; padding: 16px;
overflow: hidden;
.product-name { .product-name {
flex-grow: 1; flex-grow: 1;
@@ -192,6 +192,13 @@
} }
} }
// Search container
.search-container {
position: relative;
flex-shrink: 0;
height: 48px + 8px;
}
// ChatHeader component styles // ChatHeader component styles
.chat-header-left { .chat-header-left {
.product-name { .product-name {
+12 -16
View File
@@ -1,11 +1,11 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { import {
fetchUsers,
fetchUserPublicKey, fetchUserPublicKey,
fetchDMHistory, fetchDMHistory,
decryptDm, decryptDm,
sendDMViaWebSocket sendDMViaWebSocket,
fetchDMConversations
} from "@/core/api/dmApi"; } from "@/core/api/dmApi";
import type { User, Message, DmEncryptedJSON } from "@/core/types"; import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
@@ -72,32 +72,28 @@ export function useDM() {
} }
}, [user.authToken]); }, [user.authToken]);
// Load users when DM tab is active // Load DM conversations when chats tab is active
const loadUsers = useCallback(async () => { const loadUsers = useCallback(async () => {
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return; if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
usersLoadedRef.current = true; usersLoadedRef.current = true;
setIsLoadingUsers(true); setIsLoadingUsers(true);
try { try {
const users = await fetchUsers(user.authToken); const conversations = await fetchDMConversations(user.authToken);
console.log("Fetched users:", users); console.log("Fetched conversations:", conversations);
const dmUsersWithState: DMUser[] = users.map(user => ({
...user, const dmUsersWithState: DMUser[] = conversations.map((conv: any) => ({
unreadCount: 0, ...conv.user,
lastMessage: undefined, unreadCount: conv.unreadCount,
lastMessage: conv.lastMessage ? "Последнее сообщение" : undefined,
publicKey: null publicKey: null
})); }));
setDmUsersState(dmUsersWithState); 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) { } catch (error) {
console.error("Failed to load DM users:", error); console.error("Failed to load DM conversations:", error);
} finally { } finally {
setIsLoadingUsers(false); setIsLoadingUsers(false);
} }
+2 -2
View File
@@ -10,7 +10,7 @@ import { API_BASE_URL } from "@/core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; 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"; export type CallStatus = "calling" | "connecting" | "active" | "ended";
@@ -403,7 +403,7 @@ export const useAppState = create<AppState>((set, get) => ({
username: dmData.username, username: dmData.username,
publicKey: dmData.publicKey publicKey: dmData.publicKey
}, },
activeTab: "dms" activeTab: "chats"
} }
})); }));
@@ -9,28 +9,14 @@ export function DMUsersList() {
const { chat, switchToDM } = useAppState(); const { chat, switchToDM } = useAppState();
useEffect(() => { useEffect(() => {
if (chat.activeTab === "dms") { if (chat.activeTab === "chats") {
loadUsers(); loadUsers();
} }
}, [chat.activeTab, loadUsers]); }, [chat.activeTab, loadUsers]);
if (isLoadingUsers) { if (isLoadingUsers) {
return ( return (
<mdui-list> <mdui-circular-progress />
<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>
); );
} }
@@ -5,6 +5,7 @@ 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 { DMUsersList } from "./DMUsersList";
import { UsernameSearch } from "./UsernameSearch";
import type { Tabs } from "mdui"; import type { Tabs } from "mdui";
import type { ChatTabs } from "@/pages/chat/state"; import type { ChatTabs } from "@/pages/chat/state";
@@ -54,7 +55,6 @@ function ChatTabs() {
<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>
<mdui-tab value="dms">ЛС</mdui-tab>
<mdui-tab-panel slot="panel" value="chats"> <mdui-tab-panel slot="panel" value="chats">
<mdui-list> <mdui-list>
@@ -76,13 +76,13 @@ function ChatTabs() {
> >
<img src={defaultAvatar} alt="" slot="icon" /> <img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item> </mdui-list-item>
{/* DM conversations will be loaded here */}
<DMUsersList />
</mdui-list> </mdui-list>
</mdui-tab-panel> </mdui-tab-panel>
<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">
<DMUsersList />
</mdui-tab-panel>
</mdui-tabs> </mdui-tabs>
</div> </div>
); );
@@ -109,6 +109,9 @@ export function LeftPanel() {
return ( return (
<div className="chat-list" id="chat-list"> <div className="chat-list" id="chat-list">
<ChatHeader /> <ChatHeader />
<div className="search-container">
<UsernameSearch />
</div>
<ChatTabs /> <ChatTabs />
<BottomAppBar /> <BottomAppBar />
</div> </div>
@@ -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<SearchUser[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [debounceTimeout, setDebounceTimeout] = useState<NodeJS.Timeout | null>(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 (
<SearchBar
placeholder="Поиск"
searchQuery={searchQuery}
onQueryChange={handleQueryChange}
isExpanded={isExpanded}
onToggleExpanded={handleToggleExpanded}
leftIcon={isExpanded ? (
<mdui-button-icon
className="back-button"
onClick={(e) => {
e.stopPropagation();
handleToggleExpanded();
}}
type="button"
icon="arrow_back--outlined"
/>
) : "search--outlined"}
>
{isSearching && (
<div className="search-loading">
<mdui-circular-progress value={0}></mdui-circular-progress>
<span>Поиск...</span>
</div>
)}
{!isSearching && searchQuery.length >= 2 && searchResults.length === 0 && (
<div className="search-empty">
<span>Пользователи не найдены</span>
</div>
)}
{!isSearching && searchResults.length > 0 && (
<mdui-list>
{searchResults.map((searchUser) => (
<mdui-list-item
key={searchUser.id}
headline={searchUser.username}
description={searchUser.online ? "В сети" : "Не в сети"}
onClick={() => handleUserClick(searchUser)}
style={{ cursor: "pointer" }}
>
<img
src={searchUser.profile_picture || defaultAvatar}
alt={searchUser.username}
slot="icon"
onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar;
}}
/>
</mdui-list-item>
))}
</mdui-list>
)}
{!isSearching && searchQuery.length < 2 && (
<div className="search-hint">
<span>Введите минимум 2 символа для поиска</span>
</div>
)}
</SearchBar>
);
}
+1
View File
@@ -22,6 +22,7 @@ import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch'; import 'mdui/components/switch';
import 'mdui/components/chip'; import 'mdui/components/chip';
import "mdui/mdui.css"; import "mdui/mdui.css";
import 'mdui/components/circular-progress';
import { setColorScheme } from 'mdui/functions/setColorScheme.js'; import { setColorScheme } from 'mdui/functions/setColorScheme.js';