mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/profle-panel'
This commit is contained in:
@@ -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.
|
||||
# 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 <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
|
||||
```
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
description: Documentation rules
|
||||
alwaysApply: false
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When documenting this project, follow these rules:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)):
|
||||
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
|
||||
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 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,
|
||||
@@ -792,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,
|
||||
|
||||
@@ -169,3 +169,29 @@ export async function deleteDmEnvelope(id: number, recipientId: number, authToke
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
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 || [];
|
||||
}
|
||||
|
||||
@@ -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<HTMLTextAreaElement | null>(null);
|
||||
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(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} />
|
||||
<textarea
|
||||
aria-hidden
|
||||
readOnly
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -232,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;
|
||||
}
|
||||
|
||||
@@ -20,14 +20,38 @@
|
||||
}
|
||||
|
||||
.message {
|
||||
$status-indicator-size: 16px;
|
||||
|
||||
margin-bottom: 1rem;
|
||||
max-width: 70%;
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
&.received {
|
||||
.message-profile-pic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid $color-dark-outline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-inner {
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
@@ -38,37 +62,16 @@
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
.message-profile-pic {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 4px;
|
||||
margin: 10px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-username {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $color-dark-primary;
|
||||
text-decoration: underline;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,28 +173,25 @@
|
||||
.message-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: $status-indicator-size;
|
||||
height: $status-indicator-size;
|
||||
|
||||
.error-icon {
|
||||
color: #f44336;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #4caf50;
|
||||
font-size: 16px;
|
||||
.error-icon, .success-icon {
|
||||
font-size: $status-indicator-size;
|
||||
width: $status-indicator-size;
|
||||
height: $status-indicator-size;
|
||||
}
|
||||
|
||||
mdui-circular-progress {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: $status-indicator-size;
|
||||
height: $status-indicator-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.received .message-inner {
|
||||
&.received {
|
||||
.message-inner {
|
||||
background: $color-dark-surface-container;
|
||||
color: $color-dark-on-surface;
|
||||
border-top-left-radius: 5px;
|
||||
@@ -218,20 +218,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.received .message-time {
|
||||
.message-time {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-weight: 500;
|
||||
|
||||
.message-status-indicator {
|
||||
.success-icon {
|
||||
color: $color-dark-on-surface-variant;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #ff6b6b;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,36 +255,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.emoji-message {
|
||||
.message-inner {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.message-content {
|
||||
&.emoji-content {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: $color-dark-on-primary;
|
||||
font-weight: 500;
|
||||
|
||||
.message-status-indicator {
|
||||
.success-icon {
|
||||
color: $color-dark-on-primary;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #ff6b6b;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Emoji message styles
|
||||
&.emoji-message {
|
||||
.message-inner {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
|
||||
.message-profile-pic {
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid $color-dark-outline;
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
.message-content {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
|
||||
&.emoji-content {
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&.single-emoji-content {
|
||||
font-size: 4rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 4px 8px;
|
||||
margin-top: 8px;
|
||||
margin-right: 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,250 +2,197 @@
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Profile styles
|
||||
#profile-dialog .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-width: 400px;
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $color-dark-outline;
|
||||
|
||||
.profile-picture-container {
|
||||
position: relative;
|
||||
$size: 70px;
|
||||
width: $size;
|
||||
height: $size;
|
||||
flex-shrink: 0;
|
||||
|
||||
#profile-picture {
|
||||
width: $size;
|
||||
height: $size;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
// Profile Dialog Styles
|
||||
.profile-dialog-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
mdui-text-field {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#profile-form {
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px;
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
|
||||
mdui-text-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $color-dark-outline;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
&.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// User profile dialog content styles
|
||||
#user-profile-dialog .content {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
||||
.profile-picture-section {
|
||||
flex-shrink: 0;
|
||||
|
||||
.profile-picture {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid $color-dark-outline;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
.profile-dialog {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: calc(100vh - 60px);
|
||||
background: $color-dark-surface-container;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14),
|
||||
0 9px 46px 8px rgba(0, 0, 0, 0.12),
|
||||
0 11px 15px -7px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
|
||||
.username-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.username {
|
||||
margin: 0;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
&.open {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.online-status {
|
||||
.profile-dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.profile-picture-section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 16px;
|
||||
|
||||
.profile-picture {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 60px;
|
||||
object-fit: cover;
|
||||
border: 3px solid $color-dark-outline;
|
||||
}
|
||||
|
||||
.profile-picture-edit-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 60px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&.online {
|
||||
color: $success;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.username-section {
|
||||
text-align: center;
|
||||
|
||||
.username-input {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: $color-dark-on-surface;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
.online-status-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.online-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: $success;
|
||||
}
|
||||
}
|
||||
background: $color-dark-primary;
|
||||
|
||||
&.offline {
|
||||
color: $color-dark-on-surface-variant;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
|
||||
.offline-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
background: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
|
||||
.bio-section {
|
||||
label {
|
||||
display: block;
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bio-display {
|
||||
.status-text {
|
||||
font-size: 0.875rem;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
padding: 0.75rem;
|
||||
background-color: $color-dark-surface;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $color-dark-outline;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.bio-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
.profile-sections {
|
||||
margin: 16px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
.stat-label {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
mdui-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cropper Dialog Styles
|
||||
#cropper-dialog {
|
||||
.cropper-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.cropper-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $color-dark-outline;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
background: $color-dark-surface-container;
|
||||
border-radius: 8px;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
width: calc(100% - (16px * 2));
|
||||
box-sizing: border-box;
|
||||
|
||||
#cropper-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-actions {
|
||||
.section {
|
||||
background: $color-dark-surface-container-high;
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $color-dark-outline;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.content-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.label {
|
||||
font-size: small;
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: $color-dark-on-surface;
|
||||
font-size: medium;
|
||||
width: 100%;
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
cursor: text;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
caret-color: $color-dark-primary;
|
||||
|
||||
&::placeholder {
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-dialog-fab {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1002;
|
||||
transform: translateY(100px);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
@use "chat-input";
|
||||
@use "message-reactions";
|
||||
@use "context-menu";
|
||||
@use "profile-dialog";
|
||||
@use "settings-dialog";
|
||||
@use "animations";
|
||||
@use "callWindow";
|
||||
@use "profile-dialog";
|
||||
@@ -0,0 +1,51 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Cropper Dialog Styles
|
||||
#cropper-dialog {
|
||||
.cropper-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.cropper-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid $color-dark-outline;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
background: $color-dark-surface-container;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
#cropper-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.cropper-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $color-dark-outline;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUserPublicKey,
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
sendDMViaWebSocket,
|
||||
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<DMUser[]>([]);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
@@ -72,32 +99,53 @@ 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,
|
||||
publicKey: null
|
||||
}));
|
||||
const conversations = await fetchDMConversations(user.authToken);
|
||||
|
||||
setDmUsersState(dmUsersWithState);
|
||||
setDmUsers(users);
|
||||
// Process conversations and decrypt last messages
|
||||
const dmUsersWithState: DMUser[] = await Promise.all(
|
||||
conversations.map(async (conv: DMConversationResponse) => {
|
||||
let lastMessageContent: string | undefined = undefined;
|
||||
|
||||
// Load last messages and unread counts for visible users
|
||||
// Call loadUserLastMessage directly without dependency
|
||||
for (const dmUser of dmUsersWithState) {
|
||||
await loadUserLastMessage(dmUser);
|
||||
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 load DM users:", 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: DMConversationResponse) => conv.user));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM conversations:", error);
|
||||
} finally {
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
@@ -196,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 {
|
||||
@@ -204,47 +312,27 @@ 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));
|
||||
// Update conversation list (not active conversation - that's handled by DMPanel)
|
||||
if (!user.currentUser?.id) {
|
||||
return;
|
||||
}
|
||||
} 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
|
||||
));
|
||||
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
|
||||
|
||||
// Update last message preview
|
||||
// Update unread count and last message preview
|
||||
try {
|
||||
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
|
||||
if (publicKey) {
|
||||
const plaintext = await decryptDm(envelope, 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: plaintext.split(/\r?\n/).slice(0, 2).join("\n"),
|
||||
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
|
||||
lastMessage: formattedMessage,
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
@@ -253,7 +341,41 @@ export function useDM() {
|
||||
} 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);
|
||||
@@ -263,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,
|
||||
@@ -277,6 +393,7 @@ export function useDM() {
|
||||
isLoadingHistory,
|
||||
loadUsers,
|
||||
reloadUsers,
|
||||
reloadUserConversation,
|
||||
startDMConversation,
|
||||
sendDMMessage,
|
||||
loadUserLastMessage
|
||||
|
||||
@@ -10,10 +10,20 @@ 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";
|
||||
|
||||
export interface ProfileDialogData {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
profilePicture?: string;
|
||||
bio?: string;
|
||||
memberSince?: string;
|
||||
online?: boolean;
|
||||
isOwnProfile: boolean;
|
||||
}
|
||||
|
||||
interface ActiveDM {
|
||||
userId: number;
|
||||
username: string;
|
||||
@@ -50,6 +60,7 @@ interface ChatState {
|
||||
dmPanel: DMPanel | null;
|
||||
pendingPanel?: MessagePanel | null;
|
||||
call: CallState;
|
||||
profileDialog: ProfileDialogData | null;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
@@ -94,6 +105,10 @@ interface AppState {
|
||||
setUser: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
restoreUserFromStorage: () => Promise<void>;
|
||||
|
||||
// Profile dialog state
|
||||
setProfileDialog: (data: ProfileDialogData | null) => void;
|
||||
closeProfileDialog: () => void;
|
||||
}
|
||||
|
||||
export const useAppState = create<AppState>((set, get) => ({
|
||||
@@ -115,6 +130,7 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
publicChatPanel: null,
|
||||
dmPanel: null,
|
||||
pendingPanel: null,
|
||||
profileDialog: null,
|
||||
call: {
|
||||
isActive: false,
|
||||
status: "ended",
|
||||
@@ -403,7 +419,7 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
username: dmData.username,
|
||||
publicKey: dmData.publicKey
|
||||
},
|
||||
activeTab: "dms"
|
||||
activeTab: "chats"
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -577,5 +593,20 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
isMinimized: !state.chat.call.isMinimized
|
||||
}
|
||||
}
|
||||
})),
|
||||
|
||||
// Profile dialog state management
|
||||
setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
profileDialog: data
|
||||
}
|
||||
})),
|
||||
|
||||
closeProfileDialog: () => set((state) => ({
|
||||
chat: {
|
||||
...state.chat,
|
||||
profileDialog: null
|
||||
}
|
||||
}))
|
||||
}));
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import type { ProfileDialogData } from "@/pages/chat/state";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { confirm } from "mdui/functions/confirm";
|
||||
import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi";
|
||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||
|
||||
export function ProfileDialog() {
|
||||
const { chat, user, closeProfileDialog } = useAppState();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
|
||||
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle dialog open/close based on state
|
||||
useEffect(() => {
|
||||
if (chat.profileDialog && !isOpen) {
|
||||
// Fetch fresh data when opening dialog
|
||||
fetchFreshProfileData(chat.profileDialog);
|
||||
} else if (!chat.profileDialog && isOpen) {
|
||||
// Start close animation
|
||||
if (backdropRef.current && dialogRef.current) {
|
||||
backdropRef.current.classList.remove('open');
|
||||
dialogRef.current.classList.remove('open');
|
||||
|
||||
// Wait for animation to complete before closing
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300); // Match CSS transition duration
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
}, [chat.profileDialog, isOpen]);
|
||||
|
||||
const fetchFreshProfileData = async (profileData: ProfileDialogData) => {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
let freshData = profileData;
|
||||
|
||||
// If it's not the public chat and has a username, fetch fresh data
|
||||
if (profileData.username && profileData.username !== "Общий чат" && profileData.userId) {
|
||||
const userProfile = await fetchUserProfile(user.authToken, profileData.username);
|
||||
if (userProfile) {
|
||||
freshData = {
|
||||
userId: userProfile.id,
|
||||
username: userProfile.username,
|
||||
profilePicture: userProfile.profile_picture,
|
||||
bio: userProfile.bio,
|
||||
memberSince: userProfile.created_at,
|
||||
online: userProfile.online,
|
||||
isOwnProfile: profileData.isOwnProfile
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setOriginalData(freshData);
|
||||
setCurrentData(freshData);
|
||||
setIsOpen(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch fresh profile data:", error);
|
||||
// Fallback to cached data if fetch fails
|
||||
setOriginalData(profileData);
|
||||
setCurrentData(profileData);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger transition after component mounts
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Small delay to ensure DOM is ready for transition
|
||||
const timer = setTimeout(() => {
|
||||
if (backdropRef.current && dialogRef.current) {
|
||||
backdropRef.current.classList.add('open');
|
||||
dialogRef.current.classList.add('open');
|
||||
}
|
||||
}, 10);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle ESC key
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!originalData || !currentData) return false;
|
||||
|
||||
// Normalize values for comparison (handle empty strings, undefined, null)
|
||||
const normalizeValue = (value: string | undefined | null) => {
|
||||
if (value === null || value === undefined) return "";
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
return (
|
||||
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
|
||||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
|
||||
originalData.profilePicture !== currentData.profilePicture
|
||||
);
|
||||
}, [originalData, currentData]);
|
||||
|
||||
const handleClose = async () => {
|
||||
if (hasChanges) {
|
||||
try {
|
||||
await confirm({
|
||||
headline: "Несохраненные изменения",
|
||||
description: "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?",
|
||||
confirmText: "Закрыть",
|
||||
cancelText: "Отмена"
|
||||
});
|
||||
triggerCloseAnimation();
|
||||
} catch {
|
||||
// User cancelled, do nothing
|
||||
}
|
||||
} else {
|
||||
triggerCloseAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const triggerCloseAnimation = () => {
|
||||
if (backdropRef.current && dialogRef.current) {
|
||||
backdropRef.current.classList.remove('open');
|
||||
dialogRef.current.classList.remove('open');
|
||||
|
||||
// Wait for animation to complete before closing
|
||||
setTimeout(() => {
|
||||
closeProfileDialog();
|
||||
}, 300); // Match CSS transition duration
|
||||
} else {
|
||||
closeProfileDialog();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!currentData) return;
|
||||
setCurrentData({ ...currentData, username: e.target.value });
|
||||
};
|
||||
|
||||
const handleBioChange = (newBio: string) => {
|
||||
if (!currentData) return;
|
||||
setCurrentData({ ...currentData, bio: newBio });
|
||||
};
|
||||
|
||||
const handleProfilePictureClick = () => {
|
||||
if (currentData?.isOwnProfile) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
// Open cropper dialog here - for now just update the image
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const imageUrl = event.target?.result as string;
|
||||
if (currentData) {
|
||||
setCurrentData({ ...currentData, profilePicture: imageUrl });
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentData || !user.authToken || !originalData) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Update profile data
|
||||
const updateData: any = {};
|
||||
if (originalData.username !== currentData.username) {
|
||||
updateData.nickname = currentData.username;
|
||||
}
|
||||
if (originalData.bio !== currentData.bio) {
|
||||
updateData.description = currentData.bio;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await updateProfile(user.authToken, updateData);
|
||||
}
|
||||
|
||||
// Update profile picture if changed
|
||||
if (originalData.profilePicture !== currentData.profilePicture && currentData.profilePicture) {
|
||||
// Convert data URL to blob if needed
|
||||
if (currentData.profilePicture.startsWith("data:")) {
|
||||
const response = await fetch(currentData.profilePicture);
|
||||
const blob = await response.blob();
|
||||
await uploadProfilePicture(user.authToken, blob);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the original data to match current data
|
||||
setOriginalData(currentData);
|
||||
|
||||
// Close dialog with animation after successful save
|
||||
triggerCloseAnimation();
|
||||
} catch (error) {
|
||||
console.error("Failed to save profile:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen || !currentData) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="profile-dialog-backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div ref={dialogRef} className="profile-dialog">
|
||||
<div className="profile-dialog-content">
|
||||
{/* Profile Picture */}
|
||||
<div className="profile-picture-section">
|
||||
<img
|
||||
className="profile-picture"
|
||||
src={currentData.profilePicture || defaultAvatar}
|
||||
alt="Profile Picture"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
{currentData.isOwnProfile && (
|
||||
<div
|
||||
className="profile-picture-edit-overlay"
|
||||
onClick={handleProfilePictureClick}
|
||||
>
|
||||
<mdui-icon name="camera_alt--filled" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
{currentData.username && (
|
||||
<div className="username-section">
|
||||
<input
|
||||
className="username-input"
|
||||
type="text"
|
||||
value={currentData.username}
|
||||
onChange={handleUsernameChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Имя пользователя"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Online Status */}
|
||||
{currentData.online !== undefined && (
|
||||
<div className="online-status-section">
|
||||
<span className={`online-indicator ${currentData.online ? "" : "offline"}`} />
|
||||
<span className="status-text">
|
||||
{currentData.online ? "Онлайн" : "Оффлайн"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-sections">
|
||||
{/* Bio */}
|
||||
{currentData.bio !== undefined && (
|
||||
<div className="section bio">
|
||||
<mdui-icon name="info--filled" />
|
||||
<div className="content-container">
|
||||
<label className="label">О себе:</label>
|
||||
<RichTextArea
|
||||
text={currentData.bio || ""}
|
||||
onTextChange={handleBioChange}
|
||||
placeholder="Нет информации о себе"
|
||||
className="value"
|
||||
rows={1}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Since */}
|
||||
{currentData.memberSince && (
|
||||
<div className="section member-since">
|
||||
<mdui-icon name="calendar_month--filled" />
|
||||
<div className="content-container">
|
||||
<span className="label">Участник с:</span>
|
||||
<span className="value">
|
||||
{formatDate(currentData.memberSince)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save FAB */}
|
||||
{currentData.isOwnProfile && (
|
||||
<mdui-fab
|
||||
icon="check"
|
||||
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.getElementById("root")!
|
||||
);
|
||||
}
|
||||
@@ -2,21 +2,32 @@ import { PRODUCT_NAME } from "@/core/config";
|
||||
import useProfile from "@/pages/chat/hooks/useProfile";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { useState } from "react";
|
||||
import { ProfileDialog } from "./profile/ProfileDialog";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
|
||||
|
||||
export function ChatHeader() {
|
||||
const { profileData } = useProfile();
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
|
||||
const { setProfileDialog, user } = useAppState();
|
||||
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
|
||||
|
||||
const handleProfileClick = () => {
|
||||
setProfileDialog({
|
||||
userId: user.currentUser?.id,
|
||||
username: profileData?.nickname || "Пользователь",
|
||||
profilePicture: profileData?.profile_picture,
|
||||
bio: profileData?.description,
|
||||
memberSince: user.currentUser?.created_at,
|
||||
online: user.currentUser?.online,
|
||||
isOwnProfile: true
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="chat-header-left">
|
||||
<div className="product-name">{PRODUCT_NAME}</div>
|
||||
<div className="profile">
|
||||
<a href="#" id="profile-open" onClick={() => setIsProfileOpen(true)}>
|
||||
<a href="#" id="profile-open" onClick={handleProfileClick}>
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
@@ -26,7 +37,6 @@ export function ChatHeader() {
|
||||
</div>
|
||||
</header>
|
||||
<MinimizedCallBar />
|
||||
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<Tabs> & CustomEvent<{ value: string }>) {
|
||||
setActiveTab(e.detail.value as ChatTabs);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={chat.activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
|
||||
<mdui-tabs
|
||||
value={chat.activeTab}
|
||||
full-width
|
||||
onChange={handleChange}>
|
||||
<mdui-tab value="chats">
|
||||
Чаты
|
||||
</mdui-tab>
|
||||
@@ -15,37 +25,12 @@ export function ChatTabs() {
|
||||
<mdui-tab value="contacts">
|
||||
Контакты
|
||||
</mdui-tab>
|
||||
<mdui-tab value="dms">
|
||||
ЛС
|
||||
</mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="chats">
|
||||
<mdui-list>
|
||||
<mdui-list-item
|
||||
headline="Общий чат"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-1"
|
||||
onClick={async () => await switchToPublicChat("Общий чат")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
headline="Общий чат 2"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-2"
|
||||
onClick={async () => await switchToPublicChat("Общий чат 2")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</mdui-list>
|
||||
<UnifiedChatsList />
|
||||
</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">
|
||||
<mdui-list id="dm-users"></mdui-list>
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,94 +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 === "dms") {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<mdui-list>
|
||||
{dmUsers.map((user: DMUser) => (
|
||||
<mdui-list-item
|
||||
key={user.id}
|
||||
headline={user.username}
|
||||
description={user.lastMessage || "Нет сообщений"}
|
||||
onClick={() => handleUserClick(user)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img
|
||||
src={user.profile_picture || defaultAvatar}
|
||||
alt={user.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
{user.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{user.unreadCount}
|
||||
</mdui-badge>
|
||||
)}
|
||||
</mdui-list-item>
|
||||
))}
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +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 type { Tabs } from "mdui";
|
||||
import type { ChatTabs } from "@/pages/chat/state";
|
||||
import { UsernameSearch } from "./UsernameSearch";
|
||||
import { ChatTabs } from "./ChatTabs";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
|
||||
function BottomAppBar() {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
@@ -35,80 +32,13 @@ function BottomAppBar() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatTabs() {
|
||||
const { chat, setActiveTab, switchToPublicChat } = useAppState();
|
||||
const { activeTab } = chat;
|
||||
|
||||
async function handleChatClick(chatName: string) {
|
||||
await switchToPublicChat(chatName);
|
||||
}
|
||||
|
||||
function handleTabChange(e: FormEvent<Tabs>) {
|
||||
setActiveTab((e.target as Tabs).value as ChatTabs);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-tabs">
|
||||
<mdui-tabs value={activeTab} full-width onChange={handleTabChange}>
|
||||
<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>
|
||||
<mdui-list-item
|
||||
headline="Общий чат"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-1"
|
||||
onClick={() => handleChatClick("Общий чат")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item
|
||||
headline="Общий чат 2"
|
||||
description="Вы: Последнее сообщение"
|
||||
id="chat-list-chat-2"
|
||||
onClick={() => handleChatClick("Общий чат 2")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<img src={defaultAvatar} alt="" slot="icon" />
|
||||
</mdui-list-item>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ChatHeader() {
|
||||
const [isProfileOpen, setProfileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="chat-header-left">
|
||||
<div className="product-name">{PRODUCT_NAME}</div>
|
||||
<div className="profile">
|
||||
<a href="#" id="profile-open" onClick={() => setProfileOpen(true)}>
|
||||
<img src={defaultAvatar} alt="" id="preview1" />
|
||||
</a>
|
||||
</div>
|
||||
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setProfileOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftPanel() {
|
||||
return (
|
||||
<div className="chat-list" id="chat-list">
|
||||
<ChatHeader />
|
||||
<div className="search-container">
|
||||
<UsernameSearch />
|
||||
</div>
|
||||
<ChatTabs />
|
||||
<BottomAppBar />
|
||||
</div>
|
||||
|
||||
@@ -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<PublicChat[]>([
|
||||
{ id: "general", name: "Общий чат", type: "public" },
|
||||
{ id: "general2", name: "Общий чат 2", type: "public" }
|
||||
]);
|
||||
const [lastMessages, setLastMessages] = useState<Record<string, Message | undefined>>({});
|
||||
const [allChats, setAllChats] = useState<ChatItem[]>([]);
|
||||
|
||||
// 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 (
|
||||
<mdui-circular-progress />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<mdui-list>
|
||||
{allChats.map((chat) => {
|
||||
if (chat.type === "public") {
|
||||
return (
|
||||
<mdui-list-item
|
||||
key={`public-${chat.id}`}
|
||||
headline={chat.name}
|
||||
onClick={() => handlePublicChatClick(chat.name)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{formatPublicChatMessage(chat.id) && (
|
||||
<span slot="description" className="list-description">
|
||||
{formatPublicChatMessage(chat.id)}
|
||||
</span>
|
||||
)}
|
||||
<img
|
||||
src={defaultAvatar}
|
||||
alt={chat.name}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
/>
|
||||
</mdui-list-item>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<mdui-list-item
|
||||
key={`dm-${chat.id}`}
|
||||
headline={chat.username}
|
||||
onClick={() => handleDMClick(chat)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span slot="description" className="list-description">
|
||||
{chat.lastMessage || "Нет сообщений"}
|
||||
</span>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
{chat.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{chat.unreadCount}
|
||||
</mdui-badge>
|
||||
)}
|
||||
</mdui-list-item>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</mdui-list>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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}
|
||||
onClick={() => handleUserClick(searchUser)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span slot="description" className="list-description">
|
||||
{searchUser.online ? "В сети" : "Не в сети"}
|
||||
</span>
|
||||
<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,3 +1,5 @@
|
||||
import "./css/cropper-dialog.scss";
|
||||
|
||||
export function CropperDialog() {
|
||||
return (
|
||||
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import type { TextField } from "mdui/components/text-field";
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import useProfile from "@/pages/chat/hooks/useProfile";
|
||||
import { ImageCropper } from "./ImageCropper";
|
||||
import { MaterialTextField } from "@/core/components/TextField";
|
||||
|
||||
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
||||
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
|
||||
|
||||
const [username, setUsername] = useState(profileData?.nickname ?? "");
|
||||
const [description, setDescription] = useState(profileData?.description ?? "");
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [showCropper, setShowCropper] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Update form fields when profile data changes
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
setUsername(profileData.nickname || "");
|
||||
setDescription(profileData.description || "");
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const success = await updateProfileData({
|
||||
nickname: username.trim() || undefined,
|
||||
description: description.trim() || undefined
|
||||
});
|
||||
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
setSelectedImage(file);
|
||||
setShowCropper(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropComplete = async (croppedImageData: string) => {
|
||||
try {
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(croppedImageData);
|
||||
const blob = await response.blob();
|
||||
|
||||
const success = await uploadProfilePictureData(blob);
|
||||
if (success) {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing cropped image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropCancel = () => {
|
||||
setShowCropper(false);
|
||||
setSelectedImage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
|
||||
<div className="content">
|
||||
<div className="header-top">
|
||||
<div className="profile-picture-container">
|
||||
<img
|
||||
id="profile-picture"
|
||||
src={profilePictureUrl}
|
||||
alt="Ваше фото"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<mdui-button-icon
|
||||
icon="camera_alt--filled"
|
||||
id="upload-pfp-btn"
|
||||
className="upload-overlay"
|
||||
variant="filled"
|
||||
onClick={handleUploadClick}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="pfp-file-input"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
</div>
|
||||
<MaterialTextField
|
||||
id="username-field"
|
||||
label="Имя пользователя"
|
||||
variant="outlined"
|
||||
value={username}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
|
||||
autocomplete="username"
|
||||
disabled={isLoading || isUpdating} />
|
||||
</div>
|
||||
|
||||
<form id="profile-form" onSubmit={handleSubmit}>
|
||||
<MaterialTextField
|
||||
id="description-field"
|
||||
label="О себе"
|
||||
variant="outlined"
|
||||
value={description}
|
||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
|
||||
placeholder="Расскажите о себе..."
|
||||
autocomplete="none"
|
||||
disabled={isLoading || isUpdating} />
|
||||
<div className="dialog-actions">
|
||||
<mdui-button
|
||||
type="submit"
|
||||
id="profile-submit"
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
|
||||
</mdui-button>
|
||||
<mdui-button
|
||||
id="profile-dialog-close"
|
||||
variant="outlined"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Закрыть
|
||||
</mdui-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
|
||||
{/* Image Cropper Dialog */}
|
||||
<MaterialDialog
|
||||
id="cropper-dialog"
|
||||
close-on-overlay-click
|
||||
close-on-esc
|
||||
open={showCropper}
|
||||
onOpenChange={setShowCropper}
|
||||
>
|
||||
<div className="cropper-dialog-content">
|
||||
<div className="cropper-header">
|
||||
<h3>Обрезать фото профиля</h3>
|
||||
<mdui-button-icon icon="close" onClick={handleCropCancel} />
|
||||
</div>
|
||||
<div className="cropper-container">
|
||||
<ImageCropper
|
||||
imageFile={selectedImage}
|
||||
onCrop={handleCropComplete}
|
||||
onCancel={handleCropCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MaterialDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Message } from "./Message";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import type { Message as MessageType } from "@/core/types";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
import { UserProfileDialog } from "./UserProfileDialog";
|
||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "@/utils/utils";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
|
||||
@@ -26,9 +22,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
const { user } = useAppState();
|
||||
|
||||
// Use prop messages (panels provide their own messages)
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
||||
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
@@ -48,22 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
}
|
||||
}, [deleteDialogOpen]);
|
||||
|
||||
async function handleProfileClick(username: string) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
setIsLoadingProfile(true);
|
||||
try {
|
||||
const profile = await fetchUserProfile(user.authToken, username);
|
||||
if (profile) {
|
||||
setSelectedUserProfile(profile);
|
||||
setProfileDialogOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user profile:", error);
|
||||
} finally {
|
||||
setIsLoadingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
|
||||
e.preventDefault();
|
||||
@@ -154,28 +131,18 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
isAuthor={message.username === user.currentUser?.username}
|
||||
onProfileClick={handleProfileClick}
|
||||
isAuthor={isDm ?
|
||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(message.username === user.currentUser?.username)
|
||||
}
|
||||
onContextMenu={handleContextMenu}
|
||||
onReactionClick={handleReactionClick}
|
||||
isLoadingProfile={isLoadingProfile}
|
||||
isDm={isDm}
|
||||
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<UserProfileDialog
|
||||
isOpen={profileDialogOpen}
|
||||
onOpenChange={async (value) => {
|
||||
setProfileDialogOpen(value);
|
||||
if (!value) {
|
||||
await delay(1000);
|
||||
setSelectedUserProfile(null);
|
||||
}
|
||||
}}
|
||||
userProfile={selectedUserProfile}
|
||||
/>
|
||||
|
||||
<MaterialDialog
|
||||
headline="Удалить сообщение?"
|
||||
@@ -189,7 +156,10 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
{contextMenu.message && (
|
||||
<MessageContextMenu
|
||||
message={contextMenu.message}
|
||||
isAuthor={contextMenu.message.username === user.currentUser?.username}
|
||||
isAuthor={isDm ?
|
||||
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||
(contextMenu.message.username === user.currentUser?.username)
|
||||
}
|
||||
onEdit={handleEdit}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
|
||||
@@ -4,12 +4,13 @@ import defaultAvatar from "@/images/default-avatar.png";
|
||||
import Quote from "@/core/components/Quote";
|
||||
import { parse } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { getCurrentKeys } from "@/core/api/authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { useImmer } from "use-immer";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -132,10 +133,8 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
isAuthor: boolean;
|
||||
onProfileClick: (username: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => void;
|
||||
isLoadingProfile?: boolean;
|
||||
isDm?: boolean;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
@@ -147,7 +146,7 @@ interface Rect {
|
||||
height: number
|
||||
}
|
||||
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, onReactionClick, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||
@@ -161,7 +160,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
||||
endRect: Rect;
|
||||
} | null>(null);
|
||||
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
|
||||
const { user } = useAppState();
|
||||
const { user, setProfileDialog } = useAppState();
|
||||
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||
|
||||
@@ -389,28 +388,52 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
||||
}
|
||||
};
|
||||
|
||||
async function handleProfileClick() {
|
||||
if (!user.authToken || !message.username) return;
|
||||
|
||||
try {
|
||||
const userProfile = await fetchUserProfile(user.authToken, message.username);
|
||||
if (userProfile) {
|
||||
setProfileDialog({
|
||||
...userProfile,
|
||||
isOwnProfile: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user profile:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, message);
|
||||
}
|
||||
const messageText = message.content.trim();
|
||||
|
||||
const isEmojiMessage = useMemo(() => {
|
||||
const emojiRegex = /^[\s\p{Emoji}]*$/u;
|
||||
return messageText.length > 0 && emojiRegex.test(messageText);
|
||||
}, [messageText]);
|
||||
|
||||
// Check if message has only one emoji
|
||||
const isSingleEmojiMessage = useMemo(() => {
|
||||
const emojiRegex = /^[\p{Emoji}]+$/u;
|
||||
return messageText.length > 0 && emojiRegex.test(messageText) && messageText.length <= 4; // Most emojis are 1-4 characters
|
||||
}, [messageText]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`message ${isAuthor ? "sent" : "received"}`}
|
||||
className={`message ${isAuthor ? "sent" : "received"} ${isEmojiMessage ? "emoji-message" : ""} ${isSingleEmojiMessage ? "single-emoji" : ""}`}
|
||||
data-id={message.id}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="message-inner">
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-profile-pic">
|
||||
<div className="message-profile-pic" onClick={handleProfileClick}>
|
||||
<img
|
||||
src={message.profile_picture || defaultAvatar}
|
||||
alt={message.username}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
|
||||
className={isLoadingProfile ? "loading" : ""}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
@@ -419,11 +442,11 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthor && !isDm && (
|
||||
<div className="message-inner">
|
||||
{!isAuthor && !isDm && !isSingleEmojiMessage && (
|
||||
<div
|
||||
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
|
||||
className="message-username"
|
||||
onClick={handleProfileClick}>
|
||||
{message.username}
|
||||
</div>
|
||||
)}
|
||||
@@ -435,7 +458,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
||||
</Quote>
|
||||
)}
|
||||
|
||||
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
|
||||
<div className={`message-content ${isEmojiMessage ? "emoji-content" : ""} ${isSingleEmojiMessage ? "single-emoji-content" : ""}`} dangerouslySetInnerHTML={formattedMessage} />
|
||||
|
||||
{message.files && message.files.length > 0 && (
|
||||
<mdui-list className="message-attachments">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAppState } from "@/pages/chat/state";
|
||||
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { ProfileDialog } from "../ProfileDialog";
|
||||
import { setGlobalMessageHandler } from "@/core/websocket";
|
||||
import type { Message, WebSocketMessage } from "@/core/types";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
@@ -15,7 +16,7 @@ interface MessagePanelRendererProps {
|
||||
}
|
||||
|
||||
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const { applyPendingPanel, chat } = useAppState();
|
||||
const { applyPendingPanel, chat, setProfileDialog } = useAppState();
|
||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||
const [switchIn, setSwitchIn] = useState(false);
|
||||
@@ -170,6 +171,19 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleProfileClick() {
|
||||
if (!panel) return;
|
||||
|
||||
try {
|
||||
const profileData = await panel.getProfile();
|
||||
if (profileData) {
|
||||
setProfileDialog(profileData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get profile:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||
<div
|
||||
@@ -213,7 +227,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
src={panelState?.profilePicture || defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={panel?.handleProfileClick}
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: panel ? "pointer" : "default" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
@@ -344,6 +358,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile Dialog */}
|
||||
<ProfileDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { DialogProps } from "@/core/types";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
import { MaterialDialog } from "@/core/components/Dialog";
|
||||
import { formatTime } from "@/utils/utils";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
|
||||
interface UserProfileDialogProps extends DialogProps {
|
||||
userProfile: UserProfile | null;
|
||||
}
|
||||
|
||||
export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserProfileDialogProps) {
|
||||
const content = userProfile ? (
|
||||
<div className="content">
|
||||
<div className="profile-picture-section">
|
||||
<img
|
||||
className="profile-picture"
|
||||
alt="Profile Picture"
|
||||
src={userProfile.profile_picture || defaultAvatar}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<div className="username-section">
|
||||
<h4 className="username">{userProfile.username}</h4>
|
||||
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
|
||||
{userProfile.online ? (
|
||||
<>
|
||||
<span className="online-indicator"></span> Онлайн
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bio-section">
|
||||
<label>О себе:</label>
|
||||
<div className="bio-display">
|
||||
{userProfile.bio || "No bio available."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Зарегистрирован:</span>
|
||||
<span className="stat-value member-since">{formatTime(userProfile.created_at)}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Last seen:</span>
|
||||
<span className="stat-value last-seen">{formatTime(userProfile.last_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-actions">
|
||||
<mdui-button id="dm-button" variant="filled">
|
||||
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
|
||||
Send Message
|
||||
</mdui-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc id="user-profile-dialog">
|
||||
{content}
|
||||
</MaterialDialog>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope
|
||||
} from "@/core/api/dmApi";
|
||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||
import type { UserState } from "@/pages/chat/state";
|
||||
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
@@ -48,8 +50,12 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await decryptDm(env, this.dmData!.publicKey);
|
||||
const isAuthor = env.senderId !== this.dmData!.userId;
|
||||
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
|
||||
const username = formatDMUsername(
|
||||
env.senderId,
|
||||
env.recipientId,
|
||||
this.currentUser.currentUser?.id!,
|
||||
this.dmData!.username
|
||||
);
|
||||
|
||||
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
|
||||
let content = plaintext;
|
||||
@@ -168,6 +174,7 @@ export class DMPanel extends MessagePanel {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Handle incoming WebSocket DM messages
|
||||
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
|
||||
if (response.type === "dmNew" && this.dmData) {
|
||||
@@ -316,7 +323,27 @@ export class DMPanel extends MessagePanel {
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
async getProfile(): Promise<ProfileDialogData | null> {
|
||||
if (!this.dmData || !this.currentUser.authToken) return null;
|
||||
|
||||
try {
|
||||
const userProfile = await fetchUserProfile(this.currentUser.authToken, this.dmData.username);
|
||||
if (!userProfile) return null;
|
||||
|
||||
return {
|
||||
userId: userProfile.id,
|
||||
username: userProfile.username,
|
||||
profilePicture: userProfile.profile_picture,
|
||||
bio: userProfile.bio,
|
||||
memberSince: userProfile.created_at,
|
||||
online: userProfile.online,
|
||||
isOwnProfile: false
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user profile:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
|
||||
const messages = this.getMessages();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Message, WebSocketMessage } from "@/core/types";
|
||||
import type { UserState } from "@/pages/chat/state";
|
||||
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||
|
||||
export interface MessagePanelState {
|
||||
id: string;
|
||||
@@ -47,6 +47,7 @@ export abstract class MessagePanel {
|
||||
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
|
||||
abstract isDm(): boolean;
|
||||
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
|
||||
abstract getProfile(): Promise<ProfileDialogData | null>;
|
||||
|
||||
// Common methods
|
||||
protected updateState(updates: Partial<MessagePanelState>): void {
|
||||
@@ -349,5 +350,4 @@ export abstract class MessagePanel {
|
||||
|
||||
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
|
||||
abstract handleDeleteMessage(messageId: number): Promise<void>;
|
||||
abstract handleProfileClick(): void;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types";
|
||||
import type { UserState } from "@/pages/chat/state";
|
||||
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||
|
||||
export class PublicChatPanel extends MessagePanel {
|
||||
private messagesLoaded: boolean = false;
|
||||
@@ -197,5 +197,11 @@ export class PublicChatPanel extends MessagePanel {
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
async getProfile(): Promise<ProfileDialogData | null> {
|
||||
return {
|
||||
username: "Общий чат",
|
||||
bio: "Общаемся со всеми пользователями FromChat!",
|
||||
isOwnProfile: false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user