Merge branch 'feature/profle-panel'

This commit is contained in:
2025-10-18 23:33:10 +03:00
Unverified
36 changed files with 1936 additions and 943 deletions
+40 -4
View File
@@ -1,4 +1,40 @@
View git diff between the branch i specified and HEAD. If no branch is specified, # Code Cleanup Command
default to main. Identify code that needs to be cleaned up, like debug logs,
unused variables etc. Think twice before removing or adding code, because you ## Overview
mustn't alter the behavior. Analyze git diff between the specified branch and HEAD (defaults to main if no branch specified) and clean up code quality issues without altering functionality.
## Process
1. **Get diff**: Run `git diff <branch>..HEAD` to see changes
2. **Identify issues**: Look for code quality problems in the diff
3. **Clean up**: Remove only the identified issues
4. **Verify**: Ensure no behavioral changes
## What to Clean Up
- **Debug artifacts**: `console.log()`, `debugger`, `print()` statements
- **Unused code**: Variables, imports, functions, parameters
- **Commented code**: Dead code blocks, TODO comments (unless active)
- **Formatting**: Inconsistent spacing, trailing whitespace
- **Temporary code**: Test values, hardcoded strings meant to be dynamic
- **Redundant code**: Duplicate logic, unnecessary intermediate variables
## What NOT to Touch
- **Functional logic**: Don't change how features work
- **API interfaces**: Keep method signatures intact
- **Configuration**: Don't modify settings or constants
- **Comments**: Keep documentation and explanatory comments
- **Error handling**: Don't remove try-catch blocks or validation
## Safety Rules
- ✅ Only modify code that appears in the git diff
- ✅ Preserve all existing functionality
- ✅ Maintain code readability and structure
- ❌ Don't refactor or optimize beyond cleanup
- ❌ Don't add new features or improvements
- ❌ Don't change variable names or function signatures
## Example
```bash
# If user specifies: "/clean-up main"
git diff main
# Clean only the issues found in this diff
```
+1 -2
View File
@@ -1,6 +1,5 @@
--- ---
description: Documentation rules alwaysApply: true
alwaysApply: false
--- ---
When documenting this project, follow these rules: When documenting this project, follow these rules:
+1
View File
@@ -16,6 +16,7 @@ When working with this project, follow these rules:
- Use double quotes ("") for strings consistently. - Use double quotes ("") for strings consistently.
- Prefer functional components over class components in React. - Prefer functional components over class components in React.
- Use TypeScript strictly - avoid `any` types unless absolutely necessary. - 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 ## File Operations
- If possible, try to update files in a single edit when making multiple changes. - If possible, try to update files in a single edit when making multiple changes.
+16
View File
@@ -223,3 +223,19 @@ def list_users(current_user: User = Depends(get_current_user), db: Session = Dep
def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first() row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first()
return {"publicKey": row.public_key_b64 if row else None} return {"publicKey": row.public_key_b64 if row else None}
@router.get("/users/search")
def search_users(q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if len(q.strip()) < 2:
return {"users": []}
# Case-insensitive partial match on username
users = db.query(User).filter(
User.username.ilike(f"%{q.strip()}%"),
User.id != current_user.id # Exclude current user
).order_by(User.username.asc()).limit(20).all()
return {
"users": [convert_user(u) for u in users]
}
+45
View File
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from .account import convert_user
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse
from push_service import push_service from push_service import push_service
@@ -448,6 +449,48 @@ async def dm_history(other_user_id: int, current_user: User = Depends(get_curren
) )
@router.get("/dm/conversations")
async def get_dm_conversations(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
# Get all DM conversations where current user is involved
conversations_query = db.query(DMEnvelope).filter(
(DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id)
).order_by(DMEnvelope.timestamp.desc())
# Group by the "other user" (not current user) and get latest message
conversations = {}
for envelope in conversations_query:
other_user_id = envelope.recipient_id if envelope.sender_id == current_user.id else envelope.sender_id
if other_user_id not in conversations:
conversations[other_user_id] = envelope
# Get user info for each conversation
result = []
for other_user_id, latest_message in conversations.items():
other_user = db.query(User).filter(User.id == other_user_id).first()
if other_user:
# Calculate unread count for this conversation
unread_count = db.query(DMEnvelope).filter(
DMEnvelope.sender_id == other_user_id,
DMEnvelope.recipient_id == current_user.id,
DMEnvelope.id > getattr(latest_message, 'last_read_id', 0) # This would need to be stored somewhere
).count()
result.append({
"user": convert_user(other_user),
"lastMessage": convert_dm_envelope(latest_message),
"unreadCount": unread_count
})
# Sort by latest message timestamp
result.sort(key=lambda x: x["lastMessage"]["timestamp"], reverse=True)
return {
"status": "success",
"conversations": result
}
@router.put("/edit_message/{message_id}") @router.put("/edit_message/{message_id}")
async def edit_message( async def edit_message(
message_id: int, message_id: int,
@@ -792,6 +835,8 @@ class MessaggingSocketManager:
"type": "dmEdited", "type": "dmEdited",
"data": { "data": {
"id": env.id, "id": env.id,
"senderId": env.sender_id,
"recipientId": env.recipient_id,
"iv": env.iv_b64, "iv": env.iv_b64,
"ciphertext": env.ciphertext_b64, "ciphertext": env.ciphertext_b64,
"iv2": env.iv2_b64, "iv2": env.iv2_b64,
+26
View File
@@ -169,3 +169,29 @@ export async function deleteDmEnvelope(id: number, recipientId: number, authToke
data: { id, recipientId } 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; className?: string;
rows?: number; rows?: number;
autoComplete?: string; autoComplete?: string;
readOnly?: boolean;
} }
export function RichTextArea({ export function RichTextArea({
@@ -21,6 +22,7 @@ export function RichTextArea({
className, className,
rows = 1, rows = 1,
autoComplete = "off", autoComplete = "off",
readOnly = false
}: RichTextAreaProps) { }: RichTextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null); const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -183,10 +185,10 @@ export function RichTextArea({
value={text} value={text}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
autoComplete={autoComplete} autoComplete={readOnly ? "off" : autoComplete}
onChange={handleChange} onChange={readOnly ? undefined : handleChange}
onKeyDown={handleKeyDown} onKeyDown={readOnly ? undefined : handleKeyDown}
/> readOnly={readOnly} />
<textarea <textarea
aria-hidden aria-hidden
readOnly readOnly
+118
View File
@@ -0,0 +1,118 @@
import { useState, useEffect, useRef } from "react";
import "./css/searchBar.scss";
interface SearchBarProps {
placeholder: string;
children?: React.ReactNode;
searchQuery: string;
onQueryChange: (query: string) => void;
isExpanded: boolean;
onToggleExpanded: () => void;
leftIcon?: string | React.ReactNode;
rightIcon?: string | React.ReactNode;
}
export default function SearchBar({
placeholder,
children,
searchQuery,
onQueryChange,
isExpanded,
onToggleExpanded,
leftIcon = "search--outlined",
rightIcon = null
}: SearchBarProps) {
const [dynamicHeight, setDynamicHeight] = useState<string>("48px");
const searchContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const parentContainerRef = useRef<HTMLDivElement>(null);
// Focus input when expanded and manage height
useEffect(() => {
if (isExpanded && inputRef.current) {
inputRef.current.focus();
// Set expanded height
const leftPanel = document.getElementById('chat-list');
if (leftPanel) {
const panelHeight = leftPanel.offsetHeight;
setDynamicHeight(`${panelHeight}px`);
}
} else {
// Set collapsed height
setDynamicHeight("48px");
}
}, [isExpanded]);
function handleToggle() {
onToggleExpanded();
};
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
const query = e.target.value;
onQueryChange(query);
};
// Helper function to render icon
function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) {
if (icon === null) return null;
if (!icon) {
return defaultIcon ? <mdui-icon name={defaultIcon}></mdui-icon> : null;
} else if (typeof icon === 'string') {
return <mdui-icon name={icon}></mdui-icon>;
} else {
return icon;
}
};
return (
<div
ref={parentContainerRef}
className="search-parent"
>
<div
ref={searchContainerRef}
className={`search-bar-container ${isExpanded ? "expanded" : "collapsed"}`}
style={{ height: dynamicHeight }}
onClick={!isExpanded ? handleToggle : undefined}
>
{/* Single Search Bar Element */}
<div className="search-bar">
{/* Left Icon */}
<div className="search-icon">
{renderIcon(leftIcon, "search--outlined")}
</div>
{/* Input/Placeholder */}
<div className="search-input-container">
{isExpanded ? (
<input
ref={inputRef}
type="text"
placeholder={placeholder}
className="search-input"
value={searchQuery}
onChange={handleQueryChange}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="search-placeholder">{placeholder}</span>
)}
</div>
{/* Right Icon */}
<div className="search-clear">
{renderIcon(rightIcon)}
</div>
</div>
{/* Results Section - Only visible when expanded */}
{isExpanded && (
<div className="search-results">
{children}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,154 @@
@use "../../../css/material" as *;
$font-size: 16px;
// Search container
.search-parent {
position: relative;
height: 100%;
width: 100%;
}
// SearchBar component styles
.search-bar-container {
position: absolute;
z-index: 1001;
overflow: hidden;
// All properties animate together simultaneously
transition:
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
top 0.4s cubic-bezier(0.4, 0, 0.2, 1),
left 0.4s cubic-bezier(0.4, 0, 0.2, 1),
right 0.4s cubic-bezier(0.4, 0, 0.2, 1),
border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1);
// Initial background color for smooth transition
background-color: $color-dark-surface-container-high;
&.collapsed {
top: 8px;
left: 16px;
right: 16px;
border-radius: 24px;
// Height will be set dynamically by React (48px)
// background-color inherited from parent
}
&.expanded {
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 0;
background-color: $color-dark-surface-container;
// Height will be set dynamically by React
}
// Single search bar element
.search-bar {
display: flex;
align-items: center;
padding: 0 16px;
height: 48px;
gap: 12px;
cursor: pointer;
.search-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
mdui-icon {
color: $color-dark-on-surface-variant;
font-size: 20px;
cursor: pointer;
}
}
.search-input-container {
flex: 1;
display: flex;
align-items: center;
.search-placeholder {
color: $color-dark-on-surface-variant;
font-size: $font-size;
pointer-events: none;
}
.search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: $color-dark-on-surface;
font-size: $font-size;
padding: 8px 0;
pointer-events: auto;
&::placeholder {
color: $color-dark-on-surface-variant;
}
}
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
mdui-icon {
color: $color-dark-on-surface-variant;
font-size: 20px;
cursor: pointer;
}
}
}
// Results section
.search-results {
flex: 1;
overflow-y: auto;
.search-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px;
color: $color-dark-on-surface-variant;
mdui-circular-progress {
--mdui-circular-progress-color: $color-dark-primary;
}
}
.search-empty,
.search-hint {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
color: $color-dark-on-surface-variant;
text-align: center;
}
// Custom styling for search result images
mdui-list-item {
img[slot="icon"] {
$size: 48px;
width: $size;
height: $size;
border-radius: 50%;
object-fit: cover;
}
}
}
}
+3
View File
@@ -305,11 +305,14 @@ export interface Attachment {
// Utils // Utils
export interface DMEditPayload { export interface DMEditPayload {
id: number; id: number;
senderId: number;
recipientId: number;
iv: string; iv: string;
ciphertext: string; ciphertext: string;
iv2: string; iv2: string;
wrappedMk: string; wrappedMk: string;
salt: string; salt: string;
timestamp: string;
} }
// Requests // Requests
+1
View File
@@ -16,6 +16,7 @@ body {
background-color: $color-dark-surface; background-color: $color-dark-surface;
color: $color-dark-on-surface; color: $color-dark-on-surface;
line-height: 1.6; line-height: 1.6;
overflow: hidden;
#main-wrapper { #main-wrapper {
flex: 1; flex: 1;
+17 -1
View File
@@ -96,6 +96,7 @@
height: 100%; height: 100%;
z-index: 1000; z-index: 1000;
min-height: 0; // allow children to manage their own scrolling min-height: 0; // allow children to manage their own scrolling
position: relative; // provide positioning context for absolute children
.chat-header-left { .chat-header-left {
display: flex; display: flex;
@@ -106,7 +107,6 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 16px; padding: 16px;
overflow: hidden;
.product-name { .product-name {
flex-grow: 1; flex-grow: 1;
@@ -192,6 +192,13 @@
} }
} }
// Search container
.search-container {
position: relative;
flex-shrink: 0;
height: 48px + 8px;
}
// ChatHeader component styles // ChatHeader component styles
.chat-header-left { .chat-header-left {
.product-name { .product-name {
@@ -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;
}
+123 -98
View File
@@ -20,14 +20,38 @@
} }
.message { .message {
$status-indicator-size: 16px;
margin-bottom: 1rem; margin-bottom: 1rem;
max-width: 70%; max-width: 70%;
position: relative; position: relative;
width: fit-content; width: fit-content;
display: flex; display: flex;
align-items: flex-end; align-items: flex-start;
gap: 8px; 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 { .message-inner {
border-radius: 12px; border-radius: 12px;
position: relative; position: relative;
@@ -38,37 +62,16 @@
max-width: 100%; max-width: 100%;
display: inline-block; 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 { .message-username {
font-weight: 600; font-weight: 600;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-size: 0.9rem; font-size: 0.9rem;
transition: color 0.2s ease;
margin: 10px; margin: 10px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover { &:hover {
color: $color-dark-primary; transform: scale(1.05);
text-decoration: underline;
} }
} }
@@ -170,68 +173,54 @@
.message-status-indicator { .message-status-indicator {
display: flex; display: flex;
align-items: center; align-items: center;
width: 16px; width: $status-indicator-size;
height: 16px; height: $status-indicator-size;
.error-icon { .error-icon, .success-icon {
color: #f44336; font-size: $status-indicator-size;
font-size: 16px; width: $status-indicator-size;
} height: $status-indicator-size;
.success-icon {
color: #4caf50;
font-size: 16px;
} }
mdui-circular-progress { mdui-circular-progress {
width: 16px; width: $status-indicator-size;
height: 16px; height: $status-indicator-size;
} }
} }
} }
} }
&.received .message-inner { &.received {
background: $color-dark-surface-container; .message-inner {
color: $color-dark-on-surface; background: $color-dark-surface-container;
border-top-left-radius: 5px; color: $color-dark-on-surface;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border-top-left-radius: 5px;
border: 1px solid rgba($color-dark-outline-variant, 0.4); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative; border: 1px solid rgba($color-dark-outline-variant, 0.4);
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba($color-dark-primary, 0.05), rgba($color-dark-tertiary, 0.03));
pointer-events: none;
z-index: 0;
}
> * {
position: relative; position: relative;
z-index: 1; overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba($color-dark-primary, 0.05), rgba($color-dark-tertiary, 0.03));
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
} }
}
&.received .message-time { .message-time {
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
font-weight: 500; 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 @@
} }
} }
.message-time { &.emoji-message {
color: $color-dark-on-primary; .message-inner {
font-weight: 500; align-items: flex-end;
display: flex;
flex-direction: column;
.message-status-indicator { .message-content {
.success-icon { &.emoji-content {
color: $color-dark-on-primary; text-align: right;
filter: brightness(1.2); }
}
.error-icon {
color: #ff6b6b;
filter: brightness(1.2);
} }
} }
} }
.message-time {
color: $color-dark-on-primary;
font-weight: 500;
}
} }
}
.message-profile-pic { // Emoji message styles
img { &.emoji-message {
width: 40px; .message-inner {
height: 40px; background: transparent;
border-radius: 50%; border: none;
object-fit: cover; box-shadow: none;
border: 2px solid $color-dark-outline; padding: 0;
border-radius: 0;
&.loading { &::before {
opacity: 0.6; display: none;
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;
} }
} }
} }
+169 -222
View File
@@ -2,250 +2,197 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
// Profile styles // Profile Dialog Styles
#profile-dialog .content { .profile-dialog-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
z-index: 1000;
display: flex;
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;
&.open {
opacity: 1;
visibility: visible;
}
}
.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; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; transform: scale(0.9);
min-width: 400px; opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
.header-top { &.open {
display: flex; transform: scale(1);
flex-direction: row; opacity: 1;
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;
right: 0;
width: 28px;
height: 28px;
cursor: pointer;
}
}
mdui-text-field {
flex: 1;
}
} }
#profile-form { .profile-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
mdui-text-field {
width: 100%;
}
.dialog-actions {
display: flex;
gap: 12px;
padding-top: 16px;
border-top: 1px solid $color-dark-outline;
> * {
flex: 1;
}
}
}
}
// 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; flex: 1;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; 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;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
.username-section { .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; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; justify-content: center;
gap: 8px;
.username { .online-indicator {
margin: 0; width: 8px;
color: $color-dark-on-surface; height: 8px;
font-size: 1.1rem; border-radius: 50%;
font-weight: 600; background: $color-dark-primary;
}
.online-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-weight: 500;
&.online {
color: $success;
background-color: rgba(76, 175, 80, 0.1);
.online-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: $success;
}
}
&.offline { &.offline {
color: $color-dark-on-surface-variant; background: $color-dark-on-surface-variant;
background-color: rgba(255, 255, 255, 0.05); }
}
.offline-indicator { .status-text {
width: 8px; font-size: 0.875rem;
height: 8px; color: $color-dark-on-surface;
border-radius: 50%; }
background-color: $color-dark-on-surface-variant; }
.profile-sections {
margin: 16px;
border-radius: 24px;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
width: calc(100% - (16px * 2));
box-sizing: border-box;
.section {
background: $color-dark-surface-container-high;
border-radius: 10px;
padding: 8px 16px;
display: flex;
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;
}
} }
} }
} }
} }
}
.bio-section { .profile-dialog-fab {
label { position: absolute;
display: block; bottom: 24px;
color: $color-dark-on-surface-variant; right: 24px;
font-size: 0.85rem; z-index: 1002;
font-weight: 500; transform: translateY(100px);
margin-bottom: 0.5rem; transition: transform 0.3s ease;
}
.bio-display { &.visible {
color: $color-dark-on-surface; transform: translateY(0);
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 {
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;
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 -1
View File
@@ -6,7 +6,7 @@
@use "chat-input"; @use "chat-input";
@use "message-reactions"; @use "message-reactions";
@use "context-menu"; @use "context-menu";
@use "profile-dialog";
@use "settings-dialog"; @use "settings-dialog";
@use "animations"; @use "animations";
@use "callWindow"; @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;
}
}
+193 -76
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { import {
fetchUsers,
fetchUserPublicKey, fetchUserPublicKey,
fetchDMHistory, fetchDMHistory,
decryptDm, decryptDm,
sendDMViaWebSocket sendDMViaWebSocket,
fetchDMConversations,
type DMConversationResponse
} from "@/core/api/dmApi"; } from "@/core/api/dmApi";
import type { User, Message, DmEncryptedJSON } from "@/core/types"; import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket"; import { websocket } from "@/core/websocket";
@@ -16,8 +17,34 @@ export interface DMUser extends User {
publicKey?: string | null; 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() { export function useDM() {
const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState(); const { user, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState();
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]); const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false); const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false); const [isLoadingHistory, setIsLoadingHistory] = useState(false);
@@ -72,32 +99,53 @@ export function useDM() {
} }
}, [user.authToken]); }, [user.authToken]);
// Load users when DM tab is active // Load DM conversations when chats tab is active
const loadUsers = useCallback(async () => { const loadUsers = useCallback(async () => {
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return; if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
usersLoadedRef.current = true; usersLoadedRef.current = true;
setIsLoadingUsers(true); setIsLoadingUsers(true);
try { try {
const users = await fetchUsers(user.authToken); const conversations = await fetchDMConversations(user.authToken);
console.log("Fetched users:", users);
const dmUsersWithState: DMUser[] = users.map(user => ({ // Process conversations and decrypt last messages
...user, const dmUsersWithState: DMUser[] = await Promise.all(
unreadCount: 0, conversations.map(async (conv: DMConversationResponse) => {
lastMessage: undefined, let lastMessageContent: string | undefined = undefined;
publicKey: null
})); 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 decrypt last message for user", conv.user.id, error);
}
}
return {
...conv.user,
unreadCount: conv.unreadCount,
lastMessage: lastMessageContent,
publicKey: null
};
})
);
setDmUsersState(dmUsersWithState); setDmUsersState(dmUsersWithState);
setDmUsers(users); setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user));
// Load last messages and unread counts for visible users
// Call loadUserLastMessage directly without dependency
for (const dmUser of dmUsersWithState) {
await loadUserLastMessage(dmUser);
}
} catch (error) { } catch (error) {
console.error("Failed to load DM users:", error); console.error("Failed to load DM conversations:", error);
} finally { } finally {
setIsLoadingUsers(false); setIsLoadingUsers(false);
} }
@@ -196,7 +244,67 @@ export function useDM() {
} }
}, [user.authToken, setActiveDm, loadDMHistory]); }, [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(() => { useEffect(() => {
async function handleWebSocketMessage(e: MessageEvent) { async function handleWebSocketMessage(e: MessageEvent) {
try { try {
@@ -204,56 +312,70 @@ export function useDM() {
if (msg.type === "dmNew") { if (msg.type === "dmNew") {
const { senderId, recipientId, ...envelope } = msg.data; const { senderId, recipientId, ...envelope } = msg.data;
// If this is for the active DM conversation // Update conversation list (not active conversation - that's handled by DMPanel)
if (chat.activeDm && (senderId === chat.activeDm.userId || recipientId === chat.activeDm.userId)) { if (!user.currentUser?.id) {
try { return;
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));
}
} 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
));
// Update last message preview
try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const plaintext = await decryptDm(envelope, publicKey);
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
lastMessage: plaintext.split(/\r?\n/).slice(0, 2).join("\n"),
publicKey
}
: u
));
}
} catch (error) {
console.error("Failed to update last message preview:", error);
}
} }
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
// Update unread count and last message preview
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,
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
lastMessage: formattedMessage,
publicKey
}
: u
));
}
} 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) { } catch (error) {
console.error("Failed to handle WebSocket message:", error); console.error("Failed to handle WebSocket message:", error);
@@ -263,13 +385,7 @@ export function useDM() {
websocket.addEventListener("message", handleWebSocketMessage); websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage); return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [chat.activeDm, user.currentUser, addMessage]); }, [user.currentUser, user.authToken, reloadUserConversation]);
// Force reload users (useful for refreshing the list)
const reloadUsers = useCallback(() => {
usersLoadedRef.current = false;
loadUsers();
}, [loadUsers]);
return { return {
dmUsers, dmUsers,
@@ -277,6 +393,7 @@ export function useDM() {
isLoadingHistory, isLoadingHistory,
loadUsers, loadUsers,
reloadUsers, reloadUsers,
reloadUserConversation,
startDMConversation, startDMConversation,
sendDMMessage, sendDMMessage,
loadUserLastMessage loadUserLastMessage
+33 -2
View File
@@ -10,10 +10,20 @@ import { API_BASE_URL } from "@/core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron"; import { isElectron } from "@/core/electron/electron";
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"; export type ChatTabs = "chats" | "channels" | "contacts";
export type CallStatus = "calling" | "connecting" | "active" | "ended"; export type CallStatus = "calling" | "connecting" | "active" | "ended";
export interface ProfileDialogData {
userId?: number;
username?: string;
profilePicture?: string;
bio?: string;
memberSince?: string;
online?: boolean;
isOwnProfile: boolean;
}
interface ActiveDM { interface ActiveDM {
userId: number; userId: number;
username: string; username: string;
@@ -50,6 +60,7 @@ interface ChatState {
dmPanel: DMPanel | null; dmPanel: DMPanel | null;
pendingPanel?: MessagePanel | null; pendingPanel?: MessagePanel | null;
call: CallState; call: CallState;
profileDialog: ProfileDialogData | null;
} }
export interface UserState { export interface UserState {
@@ -94,6 +105,10 @@ interface AppState {
setUser: (token: string, user: User) => void; setUser: (token: string, user: User) => void;
logout: () => void; logout: () => void;
restoreUserFromStorage: () => Promise<void>; restoreUserFromStorage: () => Promise<void>;
// Profile dialog state
setProfileDialog: (data: ProfileDialogData | null) => void;
closeProfileDialog: () => void;
} }
export const useAppState = create<AppState>((set, get) => ({ export const useAppState = create<AppState>((set, get) => ({
@@ -115,6 +130,7 @@ export const useAppState = create<AppState>((set, get) => ({
publicChatPanel: null, publicChatPanel: null,
dmPanel: null, dmPanel: null,
pendingPanel: null, pendingPanel: null,
profileDialog: null,
call: { call: {
isActive: false, isActive: false,
status: "ended", status: "ended",
@@ -403,7 +419,7 @@ export const useAppState = create<AppState>((set, get) => ({
username: dmData.username, username: dmData.username,
publicKey: dmData.publicKey publicKey: dmData.publicKey
}, },
activeTab: "dms" activeTab: "chats"
} }
})); }));
@@ -577,5 +593,20 @@ export const useAppState = create<AppState>((set, get) => ({
isMinimized: !state.chat.call.isMinimized 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")!
);
}
+15 -5
View File
@@ -2,21 +2,32 @@ import { PRODUCT_NAME } from "@/core/config";
import useProfile from "@/pages/chat/hooks/useProfile"; import useProfile from "@/pages/chat/hooks/useProfile";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import { useState } from "react"; import { useState } from "react";
import { ProfileDialog } from "./profile/ProfileDialog"; import { useAppState } from "@/pages/chat/state";
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar"; import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
export function ChatHeader() { export function ChatHeader() {
const { profileData } = useProfile(); const { profileData } = useProfile();
const [isProfileOpen, setIsProfileOpen] = useState(false); const { setProfileDialog, user } = useAppState();
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); 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 ( return (
<> <>
<header className="chat-header-left"> <header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div> <div className="product-name">{PRODUCT_NAME}</div>
<div className="profile"> <div className="profile">
<a href="#" id="profile-open" onClick={() => setIsProfileOpen(true)}> <a href="#" id="profile-open" onClick={handleProfileClick}>
<img <img
src={profilePictureUrl} src={profilePictureUrl}
alt="" alt=""
@@ -26,7 +37,6 @@ export function ChatHeader() {
</div> </div>
</header> </header>
<MinimizedCallBar /> <MinimizedCallBar />
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
</> </>
); );
} }
+14 -29
View File
@@ -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() { 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 ( return (
<div className="chat-tabs"> <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 value="chats">
Чаты Чаты
</mdui-tab> </mdui-tab>
@@ -15,37 +25,12 @@ export function ChatTabs() {
<mdui-tab value="contacts"> <mdui-tab value="contacts">
Контакты Контакты
</mdui-tab> </mdui-tab>
<mdui-tab value="dms">
ЛС
</mdui-tab>
<mdui-tab-panel slot="panel" value="chats"> <mdui-tab-panel slot="panel" value="chats">
<mdui-list> <UnifiedChatsList />
<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>
</mdui-tab-panel> </mdui-tab-panel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel> <mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel> <mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="dms">
<mdui-list id="dm-users"></mdui-list>
</mdui-tab-panel>
</mdui-tabs> </mdui-tabs>
</div> </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>
);
}
+7 -77
View File
@@ -1,12 +1,9 @@
import { PRODUCT_NAME } from "@/core/config";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import defaultAvatar from "@/images/default-avatar.png"; import { useState } from "react";
import { useState, type FormEvent } from "react";
import { ProfileDialog } from "./profile/ProfileDialog";
import { SettingsDialog } from "./settings/SettingsDialog"; import { SettingsDialog } from "./settings/SettingsDialog";
import { DMUsersList } from "./DMUsersList"; import { UsernameSearch } from "./UsernameSearch";
import type { Tabs } from "mdui"; import { ChatTabs } from "./ChatTabs";
import type { ChatTabs } from "@/pages/chat/state"; import { ChatHeader } from "./ChatHeader";
function BottomAppBar() { function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false); 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() { export function LeftPanel() {
return ( return (
<div className="chat-list" id="chat-list"> <div className="chat-list" id="chat-list">
<ChatHeader /> <ChatHeader />
<div className="search-container">
<UsernameSearch />
</div>
<ChatTabs /> <ChatTabs />
<BottomAppBar /> <BottomAppBar />
</div> </div>
@@ -0,0 +1,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() { export function CropperDialog() {
return ( return (
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc> <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 { Message } from "./Message";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import type { Message as MessageType } from "@/core/types"; 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 { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { fetchUserProfile } from "@/core/api/profileApi";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { delay } from "@/utils/utils";
import { MaterialDialog } from "@/core/components/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types"; import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
@@ -26,9 +22,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
const { user } = useAppState(); const { user } = useAppState();
// Use prop messages (panels provide their own messages) // 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 // Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({ const [contextMenu, setContextMenu] = useState<ContextMenuState>({
@@ -48,22 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
} }
}, [deleteDialogOpen]); }, [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) { function handleContextMenu(e: React.MouseEvent, message: MessageType) {
e.preventDefault(); e.preventDefault();
@@ -154,28 +131,18 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
<Message <Message
key={message.id} key={message.id}
message={message} message={message}
isAuthor={message.username === user.currentUser?.username} isAuthor={isDm ?
onProfileClick={handleProfileClick} (message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(message.username === user.currentUser?.username)
}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick} onReactionClick={handleReactionClick}
isLoadingProfile={isLoadingProfile}
isDm={isDm} isDm={isDm}
dmRecipientPublicKey={dmRecipientPublicKey} /> dmRecipientPublicKey={dmRecipientPublicKey} />
))} ))}
{children} {children}
</div> </div>
<UserProfileDialog
isOpen={profileDialogOpen}
onOpenChange={async (value) => {
setProfileDialogOpen(value);
if (!value) {
await delay(1000);
setSelectedUserProfile(null);
}
}}
userProfile={selectedUserProfile}
/>
<MaterialDialog <MaterialDialog
headline="Удалить сообщение?" headline="Удалить сообщение?"
@@ -189,7 +156,10 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
{contextMenu.message && ( {contextMenu.message && (
<MessageContextMenu <MessageContextMenu
message={contextMenu.message} 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} onEdit={handleEdit}
onReply={handleReply} onReply={handleReply}
onDelete={handleDelete} onDelete={handleDelete}
+50 -27
View File
@@ -4,12 +4,13 @@ import defaultAvatar from "@/images/default-avatar.png";
import Quote from "@/core/components/Quote"; import Quote from "@/core/components/Quote";
import { parse } from "marked"; import { parse } from "marked";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef, useMemo } from "react";
import { getCurrentKeys } from "@/core/api/authApi"; import { getCurrentKeys } from "@/core/api/authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { getAuthHeaders } from "@/core/api/authApi"; import { getAuthHeaders } from "@/core/api/authApi";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { fetchUserProfile } from "@/core/api/profileApi";
import { ub64 } from "@/utils/utils"; import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -132,10 +133,8 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
interface MessageProps { interface MessageProps {
message: MessageType; message: MessageType;
isAuthor: boolean; isAuthor: boolean;
onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void; onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
onReactionClick?: (messageId: number, emoji: string) => void; onReactionClick?: (messageId: number, emoji: string) => void;
isLoadingProfile?: boolean;
isDm?: boolean; isDm?: boolean;
dmRecipientPublicKey?: string; dmRecipientPublicKey?: string;
} }
@@ -147,7 +146,7 @@ interface Rect {
height: number 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 [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map()); const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set()); const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
@@ -161,7 +160,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
endRect: Rect; endRect: Rect;
} | null>(null); } | null>(null);
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
const { user } = useAppState(); const { user, setProfileDialog } = useAppState();
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map()); const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope; const dmEnvelope = message.runtimeData?.dmEnvelope;
@@ -389,41 +388,65 @@ 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) { function handleContextMenu(e: React.MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onContextMenu(e, message); 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 ( return (
<> <>
<div <div
className={`message ${isAuthor ? "sent" : "received"}`} className={`message ${isAuthor ? "sent" : "received"} ${isEmojiMessage ? "emoji-message" : ""} ${isSingleEmojiMessage ? "single-emoji" : ""}`}
data-id={message.id} data-id={message.id}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<div className="message-inner"> {!isAuthor && !isDm && (
{!isAuthor && !isDm && ( <div className="message-profile-pic" onClick={handleProfileClick}>
<div className="message-profile-pic"> <img
<img src={message.profile_picture || defaultAvatar}
src={message.profile_picture || defaultAvatar} alt={message.username}
alt={message.username} onError={(e) => {
onClick={() => !isLoadingProfile && onProfileClick(message.username)} const target = e.target as HTMLImageElement;
style={{ cursor: isLoadingProfile ? "default" : "pointer" }} target.src = defaultAvatar;
className={isLoadingProfile ? "loading" : ""} }}
onError={(e) => { />
const target = e.target as HTMLImageElement; </div>
target.src = defaultAvatar; )}
}}
/>
</div>
)}
{!isAuthor && !isDm && ( <div className="message-inner">
{!isAuthor && !isDm && !isSingleEmojiMessage && (
<div <div
className={`message-username ${isLoadingProfile ? "loading" : ""}`} className="message-username"
onClick={() => !isLoadingProfile && onProfileClick(message.username)} onClick={handleProfileClick}>
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
{message.username} {message.username}
</div> </div>
)} )}
@@ -435,7 +458,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
</Quote> </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 && ( {message.files && message.files.length > 0 && (
<mdui-list className="message-attachments"> <mdui-list className="message-attachments">
@@ -3,6 +3,7 @@ import { useAppState } from "@/pages/chat/state";
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
import { ChatMessages } from "./ChatMessages"; import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper"; import { ChatInputWrapper } from "./ChatInputWrapper";
import { ProfileDialog } from "../ProfileDialog";
import { setGlobalMessageHandler } from "@/core/websocket"; import { setGlobalMessageHandler } from "@/core/websocket";
import type { Message, WebSocketMessage } from "@/core/types"; import type { Message, WebSocketMessage } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
@@ -15,7 +16,7 @@ interface MessagePanelRendererProps {
} }
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, chat } = useAppState(); const { applyPendingPanel, chat, setProfileDialog } = useAppState();
const messagePanelRef = useRef<HTMLDivElement>(null); const messagePanelRef = useRef<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null); const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const [switchIn, setSwitchIn] = useState(false); 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 ( return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}> <div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div <div
@@ -213,7 +227,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
src={panelState?.profilePicture || defaultAvatar} src={panelState?.profilePicture || defaultAvatar}
alt="Avatar" alt="Avatar"
className="chat-header-avatar" className="chat-header-avatar"
onClick={panel?.handleProfileClick} onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }} style={{ cursor: panel ? "pointer" : "default" }}
/> />
<div className="chat-header-info"> <div className="chat-header-info">
@@ -344,6 +358,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</> </>
)} )}
</div> </div>
{/* Profile Dialog */}
<ProfileDialog />
</div> </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, editDmEnvelope,
deleteDmEnvelope deleteDmEnvelope
} from "@/core/api/dmApi"; } from "@/core/api/dmApi";
import { fetchUserProfile } from "@/core/api/profileApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; 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 { export interface DMPanelData {
userId: number; userId: number;
@@ -48,8 +50,12 @@ export class DMPanel extends MessagePanel {
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await decryptDm(env, this.dmData!.publicKey); const plaintext = await decryptDm(env, this.dmData!.publicKey);
const isAuthor = env.senderId !== this.dmData!.userId; const username = formatDMUsername(
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username; env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } } // Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
let content = plaintext; let content = plaintext;
@@ -168,6 +174,7 @@ export class DMPanel extends MessagePanel {
}); });
} }
// Handle incoming WebSocket DM messages // Handle incoming WebSocket DM messages
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> { async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
if (response.type === "dmNew" && this.dmData) { 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 { updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
const messages = this.getMessages(); const messages = this.getMessages();
@@ -1,5 +1,5 @@
import type { Message, WebSocketMessage } from "@/core/types"; 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 { export interface MessagePanelState {
id: string; id: string;
@@ -47,6 +47,7 @@ export abstract class MessagePanel {
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>; protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
abstract isDm(): boolean; abstract isDm(): boolean;
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>; abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
abstract getProfile(): Promise<ProfileDialogData | null>;
// Common methods // Common methods
protected updateState(updates: Partial<MessagePanelState>): void { protected updateState(updates: Partial<MessagePanelState>): void {
@@ -349,5 +350,4 @@ export abstract class MessagePanel {
abstract handleEditMessage(messageId: number, content: string): Promise<void>; abstract handleEditMessage(messageId: number, content: string): Promise<void>;
abstract handleDeleteMessage(messageId: number): 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 { getAuthHeaders } from "@/core/api/authApi";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types"; 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 { export class PublicChatPanel extends MessagePanel {
private messagesLoaded: boolean = false; 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
};
}
} }
+1
View File
@@ -22,6 +22,7 @@ import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch'; import 'mdui/components/switch';
import 'mdui/components/chip'; import 'mdui/components/chip';
import "mdui/mdui.css"; import "mdui/mdui.css";
import 'mdui/components/circular-progress';
import { setColorScheme } from 'mdui/functions/setColorScheme.js'; import { setColorScheme } from 'mdui/functions/setColorScheme.js';