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
+20
View File
@@ -169,3 +169,23 @@ export async function deleteDmEnvelope(id: number, recipientId: number, authToke
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;
color: $color-dark-on-surface;
line-height: 1.6;
overflow: hidden;
#main-wrapper {
flex: 1;
+8 -1
View File
@@ -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 {
+12 -16
View File
@@ -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);
}
+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 { 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<AppState>((set, get) => ({
username: dmData.username,
publicKey: dmData.publicKey
},
activeTab: "dms"
activeTab: "chats"
}
}));
@@ -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 (
<mdui-list>
<mdui-list-item headline="Загрузка..." description="Получение списка пользователей...">
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
);
}
if (dmUsers.length === 0) {
return (
<mdui-list>
<mdui-list-item headline="Нет пользователей" description="Пользователи не найдены">
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
<mdui-circular-progress />
);
}
@@ -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() {
<mdui-tab value="chats">Чаты</mdui-tab>
<mdui-tab value="channels">Каналы</mdui-tab>
<mdui-tab value="contacts">Контакты</mdui-tab>
<mdui-tab value="dms">ЛС</mdui-tab>
<mdui-tab-panel slot="panel" value="chats">
<mdui-list>
@@ -76,13 +76,13 @@ function ChatTabs() {
>
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
{/* DM conversations will be loaded here */}
<DMUsersList />
</mdui-list>
</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="dms">
<DMUsersList />
</mdui-tab-panel>
</mdui-tabs>
</div>
);
@@ -109,6 +109,9 @@ export function LeftPanel() {
return (
<div className="chat-list" id="chat-list">
<ChatHeader />
<div className="search-container">
<UsernameSearch />
</div>
<ChatTabs />
<BottomAppBar />
</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/chip';
import "mdui/mdui.css";
import 'mdui/components/circular-progress';
import { setColorScheme } from 'mdui/functions/setColorScheme.js';