mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement username search
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 || [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user