Merge branch 'feature/online-status'

This commit is contained in:
2025-10-19 21:23:50 +03:00
Unverified
71 changed files with 2041 additions and 888 deletions
+282 -50
View File
@@ -4,6 +4,8 @@ from pathlib import Path
import os
import re
import uuid
import asyncio
import time
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form
from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials
@@ -48,7 +50,7 @@ def convert_message(msg: Message) -> dict:
"id": reaction.user_id,
"username": reaction.user.username
})
return {
"id": msg.id,
"content": msg.content,
@@ -88,7 +90,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
"id": reaction.user_id,
"username": reaction.user.username
})
return {
"id": envelope.id,
"senderId": envelope.sender_id,
@@ -455,15 +457,15 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d
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():
@@ -475,16 +477,16 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d
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
@@ -499,22 +501,19 @@ async def edit_message(
db: Session = Depends(get_db)
):
message = db.query(Message).filter(Message.id == message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
if message.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only edit your own messages")
if not request.content.strip():
raise HTTPException(status_code=400, detail="Message content cannot be empty")
message.content = request.content.strip()
message.is_edited = True
db.commit()
db.refresh(message)
return {"status": "success", "message": convert_message(message)}
@@ -525,17 +524,17 @@ async def delete_message(
db: Session = Depends(get_db)
):
message = db.query(Message).filter(Message.id == message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
# Allow owner to delete any message
if current_user.username != OWNER_USERNAME and message.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only delete your own messages")
db.delete(message)
db.commit()
return {"status": "success", "message_id": message_id}
@@ -549,14 +548,14 @@ async def add_reaction(
message = db.query(Message).filter(Message.id == request.message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
# Check if reaction already exists
existing_reaction = db.query(Reaction).filter(
Reaction.message_id == request.message_id,
Reaction.user_id == current_user.id,
Reaction.emoji == request.emoji
).first()
if existing_reaction:
# Remove existing reaction (toggle off)
db.delete(existing_reaction)
@@ -570,12 +569,12 @@ async def add_reaction(
)
db.add(new_reaction)
action = "added"
db.commit()
# Refresh message to get updated reactions
db.refresh(message)
# Broadcast reaction update
try:
from .messaging import messagingManager
@@ -592,7 +591,7 @@ async def add_reaction(
})
except Exception:
pass
return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]}
@@ -606,18 +605,18 @@ async def add_dm_reaction(
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first()
if not envelope:
raise HTTPException(status_code=404, detail="DM envelope not found")
# Check if user is part of this DM conversation
if current_user.id not in [envelope.sender_id, envelope.recipient_id]:
raise HTTPException(status_code=403, detail="Not authorized to react to this message")
# Check if reaction already exists
existing_reaction = db.query(DMReaction).filter(
DMReaction.dm_envelope_id == request.dm_envelope_id,
DMReaction.user_id == current_user.id,
DMReaction.emoji == request.emoji
).first()
if existing_reaction:
# Remove existing reaction (toggle off)
db.delete(existing_reaction)
@@ -631,15 +630,14 @@ async def add_dm_reaction(
)
db.add(new_reaction)
action = "added"
db.commit()
# Refresh envelope to get updated reactions
db.refresh(envelope)
# Broadcast reaction update to both participants
try:
from .messaging import messagingManager
await messagingManager.broadcast({
"type": "dmReactionUpdate",
"data": {
@@ -653,7 +651,7 @@ async def add_dm_reaction(
})
except Exception:
pass
return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]}
@@ -661,11 +659,19 @@ class MessaggingSocketManager:
def __init__(self) -> None:
self.connections: list[WebSocket] = []
self.user_by_ws: dict[WebSocket, int] = {}
self.online_users: set[int] = set()
self.typing_users: dict[int, float] = {} # user_id -> timestamp
self.dm_typing_users: dict[int, dict[int, float]] = {} # user_id -> {recipient_id -> timestamp}
self.ws_subscriptions: dict[WebSocket, set[int]] = {} # websocket -> set of subscribed user_ids
self._cleanup_task = None
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException):
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
async def handle_connection(self, websocket: WebSocket, db: Session):
# Initialize subscriptions for this connection
self.ws_subscriptions[websocket] = set()
while True:
data = await websocket.receive_json()
type = data["type"]
@@ -674,9 +680,9 @@ class MessaggingSocketManager:
if data["credentials"]:
return get_current_user(
HTTPAuthorizationCredentials(
scheme=data["credentials"]["scheme"],
scheme=data["credentials"]["scheme"],
credentials=data["credentials"]["credentials"]
),
),
db
)
else:
@@ -687,22 +693,30 @@ class MessaggingSocketManager:
current_user = get_current_user_inner()
if current_user:
self.user_by_ws[websocket] = current_user.id
# Set user online in DB
current_user.online = True
current_user.last_seen = datetime.now()
db.commit()
# Add to online users
self.online_users.add(current_user.id)
# Broadcast status change
await self.broadcast_status_change(current_user.id, True, current_user.last_seen.isoformat())
else:
await websocket.send_json({
"type": "ping",
"type": "ping",
"data": {
"status": "error",
"status": "error",
"error": {
"detail": "Failed to authorize",
"detail": "Failed to authorize",
"code": 401
}
}
})
except HTTPException:
await websocket.send_json({
"type": "ping",
"type": "ping",
"data": {
"status": "error",
"status": "error",
"error": {
"detail": "Failed to authorize",
"code": 401
@@ -726,7 +740,7 @@ class MessaggingSocketManager:
if not current_user:
raise HTTPException(401)
self.user_by_ws[websocket] = current_user.id
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
response = await send_message(request, current_user, db, None, [])
@@ -795,7 +809,7 @@ class MessaggingSocketManager:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
message_id = data["data"]["message_id"]
request: EditMessageRequest = EditMessageRequest.model_validate(data["data"])
@@ -885,7 +899,7 @@ class MessaggingSocketManager:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
message_id = data["data"]["message_id"]
response = await delete_message(message_id, current_user, db)
await self.broadcast({
@@ -901,15 +915,15 @@ class MessaggingSocketManager:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
request_data = data["data"]
reaction_request = ReactionRequest(
message_id=request_data["message_id"],
emoji=request_data["emoji"]
)
response = await add_reaction(reaction_request, current_user, db)
# Broadcast reaction update
await self.broadcast({
"type": "reactionUpdate",
@@ -931,15 +945,15 @@ class MessaggingSocketManager:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
request_data = data["data"]
reaction_request = DMReactionRequest(
dm_envelope_id=request_data["dm_envelope_id"],
emoji=request_data["emoji"]
)
response = await add_dm_reaction(reaction_request, current_user, db)
# Broadcast reaction update
await self.broadcast({
"type": "dmReactionUpdate",
@@ -1040,15 +1054,144 @@ class MessaggingSocketManager:
await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "subscribeStatus":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
user_id_to_subscribe = int(data["data"]["userId"])
self.ws_subscriptions[websocket].add(user_id_to_subscribe)
# Get current status of the user
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
if target_user:
await websocket.send_json({
"type": "statusUpdate",
"data": {
"userId": user_id_to_subscribe,
"online": target_user.online,
"lastSeen": target_user.last_seen.isoformat()
}
})
else:
await websocket.send_json({
"type": "subscribeStatus",
"data": {"status": "error", "error": "User not found"}
})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "unsubscribeStatus":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
user_id_to_unsubscribe = int(data["data"]["userId"])
self.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "typing":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
self.typing_users[current_user.id] = time.time()
# Broadcast to all connected users
await self.broadcast({
"type": "typing",
"data": {
"userId": current_user.id,
"username": current_user.username
}
})
await websocket.send_json({"type": "typing", "data": {"status": "ok"}})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "stopTyping":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
if current_user.id in self.typing_users:
del self.typing_users[current_user.id]
# Broadcast to all connected users
await self.broadcast({
"type": "stopTyping",
"data": {
"userId": current_user.id,
"username": current_user.username
}
})
await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "dmTyping":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
recipient_id = int(data["data"]["recipientId"])
if current_user.id not in self.dm_typing_users:
self.dm_typing_users[current_user.id] = {}
self.dm_typing_users[current_user.id][recipient_id] = time.time()
# Send only to recipient
await self.send_to_user(recipient_id, {
"type": "dmTyping",
"data": {
"userId": current_user.id,
"username": current_user.username
}
})
await websocket.send_json({"type": "dmTyping", "data": {"status": "ok"}})
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "stopDmTyping":
try:
current_user = get_current_user_inner()
if not current_user:
raise HTTPException(401)
recipient_id = int(data["data"]["recipientId"])
if current_user.id in self.dm_typing_users and recipient_id in self.dm_typing_users[current_user.id]:
del self.dm_typing_users[current_user.id][recipient_id]
if not self.dm_typing_users[current_user.id]:
del self.dm_typing_users[current_user.id]
# Send only to recipient
await self.send_to_user(recipient_id, {
"type": "stopDmTyping",
"data": {
"userId": current_user.id,
"username": current_user.username
}
})
await websocket.send_json({"type": "stopDmTyping", "data": {"status": "ok"}})
except HTTPException as e:
await self.send_error(websocket, type, e)
else:
await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}})
async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None):
try:
await websocket.close(code=code, reason=message)
finally:
finally:
self.connections.remove(websocket)
async def connect(self, websocket: WebSocket, db: Session):
await websocket.accept()
self.connections.append(websocket)
@@ -1057,9 +1200,24 @@ class MessaggingSocketManager:
except WebSocketDisconnect as e:
logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}")
finally:
# Cleanup connection
self.connections.remove(websocket)
if websocket in self.user_by_ws:
user_id = self.user_by_ws[websocket]
# Set user offline in DB
user = db.query(User).filter(User.id == user_id).first()
if user:
user.online = False
user.last_seen = datetime.now()
db.commit()
# Remove from online users
self.online_users.discard(user_id)
# Broadcast status change
await self.broadcast_status_change(user_id, False, user.last_seen.isoformat())
del self.user_by_ws[websocket]
# Cleanup subscriptions
if websocket in self.ws_subscriptions:
del self.ws_subscriptions[websocket]
async def broadcast(self, message: dict):
for websocket in self.connections:
@@ -1070,7 +1228,81 @@ class MessaggingSocketManager:
if self.user_by_ws.get(websocket) == user_id:
await websocket.send_json(message)
async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str):
"""Broadcast status change to all connections that are subscribed to this user"""
message = {
"type": "statusUpdate",
"data": {
"userId": user_id,
"online": online,
"lastSeen": last_seen
}
}
# Send to all connections that have this user in their subscriptions
for websocket in self.connections:
if websocket in self.ws_subscriptions and user_id in self.ws_subscriptions[websocket]:
await websocket.send_json(message)
async def cleanup_stale_typing_indicators(self):
"""Periodically cleanup typing indicators that haven't been updated in 3+ seconds"""
while True:
try:
current_time = time.time()
stale_threshold = 3.0 # 3 seconds
# Cleanup public chat typing indicators
stale_public_typing = [
user_id for user_id, timestamp in self.typing_users.items()
if current_time - timestamp > stale_threshold
]
for user_id in stale_public_typing:
del self.typing_users[user_id]
# Broadcast stop typing
await self.broadcast({
"type": "stopTyping",
"data": {
"userId": user_id,
"username": "Unknown" # We don't have username here, frontend will handle
}
})
# Cleanup DM typing indicators
stale_dm_typing = []
for user_id, recipients in self.dm_typing_users.items():
for recipient_id, timestamp in list(recipients.items()):
if current_time - timestamp > stale_threshold:
stale_dm_typing.append((user_id, recipient_id))
for user_id, recipient_id in stale_dm_typing:
if user_id in self.dm_typing_users and recipient_id in self.dm_typing_users[user_id]:
del self.dm_typing_users[user_id][recipient_id]
if not self.dm_typing_users[user_id]:
del self.dm_typing_users[user_id]
# Send stop typing to recipient
await self.send_to_user(recipient_id, {
"type": "stopDmTyping",
"data": {
"userId": user_id,
"username": "Unknown" # We don't have username here, frontend will handle
}
})
# Wait 1 second before next cleanup
await asyncio.sleep(1.0)
except Exception as e:
logger.error(f"Error in typing cleanup task: {e}")
await asyncio.sleep(1.0)
def start_cleanup_task(self):
"""Start the cleanup task if not already running"""
if self._cleanup_task is None or self._cleanup_task.done():
self._cleanup_task = asyncio.create_task(self.cleanup_stale_typing_indicators())
messagingManager = MessaggingSocketManager()
# Start the cleanup task
messagingManager.start_cleanup_task()
@router.websocket("/chat/ws")
async def chat_websocket(
+8 -8
View File
@@ -35,8 +35,8 @@ async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
}
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
const payload: UploadPublicKeyRequest = {
publicKey: b64(publicKey)
const payload: UploadPublicKeyRequest = {
publicKey: b64(publicKey)
}
const headers = getAuthHeaders(token, true);
@@ -49,9 +49,9 @@ async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<vo
async function fetchBackupBlob(token: string): Promise<string | null> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET",
headers
headers
});
if (res.ok) {
const response: BackupBlob = await res.json();
@@ -83,7 +83,7 @@ export function getCurrentKeys(): UserKeyPairMemory | null {
}
function saveKeys(
publicKey: Uint8Array<ArrayBufferLike>,
publicKey: Uint8Array<ArrayBufferLike>,
privateKey: Uint8Array<ArrayBufferLike>
) {
const encodedPublicKey = b64(publicKey);
@@ -117,9 +117,9 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
saveKeys(currentPublicKey!, currentPrivateKey!);
return {
publicKey: currentPublicKey!,
privateKey: currentPrivateKey!
return {
publicKey: currentPublicKey!,
privateKey: currentPrivateKey!
};
}
+9 -9
View File
@@ -38,8 +38,8 @@ export async function fetchUserPublicKey(userId: number, token: string): Promise
}
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return [];
const data = await response.json();
@@ -73,8 +73,8 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
await request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: payload
@@ -177,8 +177,8 @@ export interface DMConversationResponse {
}
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
@@ -187,9 +187,9 @@ export async function fetchDMConversations(token: string): Promise<DMConversatio
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)
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();
+2 -2
View File
@@ -30,7 +30,7 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
description: data.bio
};
}
return null;
} catch (error) {
console.error('Error loading profile:', error);
@@ -119,7 +119,7 @@ export async function fetchUserProfile(token: string, username: string): Promise
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error fetching user profile:', error);
+16 -16
View File
@@ -39,7 +39,7 @@ function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
// Frame data can differ between sender/receiver due to encoding differences
const ivBuffer = new ArrayBuffer(12);
const view = new DataView(ivBuffer);
if (encodedFrame.getMetadata) {
try {
const metadata = encodedFrame.getMetadata();
@@ -48,14 +48,14 @@ function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
view.setUint32(0, metadata.rtpTimestamp, false); // First 4 bytes
view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes
view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV)
return ivBuffer;
}
} catch (e) {
console.error("Failed to get metadata:", e);
}
}
// Fallback: use timestamp only (no random to avoid desync)
view.setUint32(0, Date.now() & 0xFFFFFFFF, false);
view.setUint32(4, 0, false);
@@ -67,30 +67,30 @@ addEventListener("rtctransform", (event) => {
const { transformer } = event;
const { readable, writable } = transformer;
const { key, mode } = transformer.options as WorkerOptions;
const isEncrypting = mode === 'encrypt';
let frameCount = 0;
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
try {
const data = new Uint8Array(encodedFrame.data);
// Increment frame counter
frameCount++;
// Create IV using RTP timestamp from metadata (synchronized between peers)
const iv = makeIV(encodedFrame);
// Ensure IV is properly typed
const ivArray = new Uint8Array(iv);
const params: AesGcmParams = { name: 'AES-GCM', iv: ivArray };
// COMPROMISE: Encrypt most of the frame while preserving minimal codec compatibility
// This prevents most visual leakage while maintaining decodability
let headerSize = 0;
let payloadData: Uint8Array;
if (data.length > 20) {
// For video frames, preserve first 8 bytes for better codec compatibility
// This includes frame type, keyframe info, and basic header structure
@@ -100,11 +100,11 @@ addEventListener("rtctransform", (event) => {
// For small frames (likely audio), encrypt everything
payloadData = data;
}
// Encrypt the payload data
const payloadBuffer = new ArrayBuffer(payloadData.byteLength);
new Uint8Array(payloadBuffer).set(payloadData);
let encryptedPayload: ArrayBuffer;
if (isEncrypting) {
encryptedPayload = await crypto.subtle.encrypt(params, key, payloadBuffer);
@@ -116,18 +116,18 @@ addEventListener("rtctransform", (event) => {
return; // Drop the frame
}
}
// Reconstruct frame: minimal headers + encrypted payload
const encryptedArray = new Uint8Array(encryptedPayload);
const result = new Uint8Array(headerSize + encryptedArray.length);
if (headerSize > 0) {
result.set(data.slice(0, headerSize), 0); // Copy minimal headers
result.set(encryptedArray, headerSize); // Add encrypted payload
} else {
result.set(encryptedArray, 0);
}
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
encodedFrame.data = result.buffer;
controller.enqueue(encodedFrame);
+16 -16
View File
@@ -31,11 +31,11 @@ export interface EncryptedCallMessage {
export async function generateCallSessionKey(): Promise<CallSessionKey> {
// Generate session key material
const sessionKeyMaterial = randomBytes(32);
// Generate hash for emoji display (first 4 bytes of SHA-256 hash)
const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer);
const hash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
return {
key: sessionKeyMaterial,
hash
@@ -49,11 +49,11 @@ export async function generateCallSessionKey(): Promise<CallSessionKey> {
export async function rotateCallSessionKey(): Promise<CallSessionKey> {
// Generate new session key material (completely independent of current key)
const newSessionKeyMaterial = randomBytes(32);
// Generate new hash for emoji display
const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer);
const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
return {
key: newSessionKeyMaterial,
hash: newHash
@@ -68,12 +68,12 @@ export async function createCallSessionKeyFromHash(hash: string): Promise<CallSe
// For backward compatibility, generate a deterministic key from the hash
const hashBytes = ub64(hash);
const sessionKey = new Uint8Array(32);
// Repeat the hash bytes to fill 32 bytes
for (let i = 0; i < 32; i++) {
sessionKey[i] = hashBytes[i % hashBytes.length];
}
return {
key: sessionKey,
hash
@@ -85,7 +85,7 @@ export async function createCallSessionKeyFromHash(hash: string): Promise<CallSe
* This creates a deterministic but cryptographically secure key
*/
export async function deriveCallSessionKeyFromSharedSecret(
sharedSecret: Uint8Array,
sharedSecret: Uint8Array,
sessionKeyHash: string,
isInitiator: boolean
): Promise<CallSessionKey> {
@@ -93,7 +93,7 @@ export async function deriveCallSessionKeyFromSharedSecret(
// Include the session key hash and role to ensure uniqueness
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
// Import the shared secret as a raw key for HKDF
const sharedKey = await crypto.subtle.importKey(
'raw',
@@ -102,7 +102,7 @@ export async function deriveCallSessionKeyFromSharedSecret(
false,
['deriveKey']
);
// Derive the session key using HKDF
const sessionKey = await crypto.subtle.deriveKey(
{
@@ -116,10 +116,10 @@ export async function deriveCallSessionKeyFromSharedSecret(
true, // Make the key extractable so we can export it
['encrypt', 'decrypt']
);
// Export the raw key material
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
return {
key: new Uint8Array(sessionKeyMaterial),
hash: sessionKeyHash
@@ -132,7 +132,7 @@ export async function deriveCallSessionKeyFromSharedSecret(
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
const messageKey = await importAesGcmKey(sessionKey);
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
return {
iv: b64(encrypted.iv),
ciphertext: b64(encrypted.ciphertext),
@@ -158,7 +158,7 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
// Convert hash to numbers and map to emoji ranges
const hashBytes = new Uint8Array(ub64(sessionKeyHash));
const emojis: string[] = [];
// Different emoji categories for variety
const emojiSets = [
["🎵", "🎶", "🎤", "🎧", "🎼", "🎹", "🥁", "🎺", "🎸", "🎻"], // Music
@@ -166,13 +166,13 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
["🚀", "🛸", "🛰️", "🌌", "🔭", "⚙️", "🔧", "⚡", "💡", "🔬"], // Tech/Space
["🎭", "🎪", "🎨", "🎬", "📷", "🎥", "📺", "🎮", "🕹️", "🎯"] // Entertainment
];
for (let i = 0; i < 4; i++) {
const set = emojiSets[i % emojiSets.length];
const index = hashBytes[i % hashBytes.length] % set.length;
emojis.push(set[index]);
}
return emojis;
}
@@ -214,7 +214,7 @@ export async function createSharedSecretAndDeriveSessionKey(
// Create shared secret using ECDH
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
// Derive the session key from the shared secret
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
}
+8 -8
View File
@@ -69,10 +69,10 @@ export class CallSignalingHandler {
const { fromUsername } = data;
const fromUserId = message.fromUserId;
const state = this.getState();
// First, create the peer connection in WebRTC service
await WebRTC.handleIncomingCall(fromUserId, fromUsername);
// Then show incoming call UI
state.receiveCall(fromUserId, fromUsername);
}
@@ -96,12 +96,12 @@ export class CallSignalingHandler {
private handleCallReject(data: CallRejectData) {
const state = this.getState();
const { fromUserId } = data;
// Clean up WebRTC connection first
if (fromUserId) {
WebRTC.cleanupCall(fromUserId);
}
// End the call
state.endCall();
}
@@ -121,12 +121,12 @@ export class CallSignalingHandler {
private handleCallEnd(data: CallEndData) {
const state = this.getState();
const { fromUserId } = data;
// Clean up WebRTC connection first
if (fromUserId) {
WebRTC.cleanupCall(fromUserId);
}
// End the call
state.endCall();
}
@@ -144,7 +144,7 @@ export class CallSignalingHandler {
private handleVideoToggle(message: CallSignalingMessage, data: CallVideoToggleData) {
const state = this.getState();
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
// Update Zustand state (for UI)
state.setRemoteVideoEnabled(data.enabled);
@@ -157,7 +157,7 @@ export class CallSignalingHandler {
private handleScreenShareToggle(message: CallSignalingMessage, data: CallScreenShareToggleData) {
const state = this.getState();
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
// Update Zustand state (for UI)
state.setRemoteScreenSharing(data.enabled);
+57 -57
View File
@@ -69,7 +69,7 @@ export class WebRTCCall {
private set sessionKey(value: Uint8Array | null) {
this._sessionKey = value;
}
// -------------------
// Core initialization
@@ -85,12 +85,12 @@ export class WebRTCCall {
*/
async initialize(): Promise<void> {
const iceServers = await this.getIceServers();
// Create peer connection with proper ICE servers
this.peerConnection = new RTCPeerConnection({
iceServers
});
this.setupEventListeners();
}
@@ -102,7 +102,7 @@ export class WebRTCCall {
const response = await fetch("/api/webrtc/ice", {
headers: getAuthHeaders(getAuthToken()!)
});
if (response.ok) {
const data = await response.json() as IceServersResponse;
return data.iceServers || [];
@@ -112,7 +112,7 @@ export class WebRTCCall {
} catch (error) {
console.warn("Failed to fetch ICE servers:", error);
}
// Fallback to STUN only if backend fails
return DEFAULT_ICE_SERVERS;
}
@@ -161,7 +161,7 @@ export class WebRTCCall {
}
this.isNegotiating = true;
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
@@ -184,7 +184,7 @@ export class WebRTCCall {
const [remoteStream] = event.streams;
if (remoteStream) {
const track = event.track;
// Apply E2EE transform to all tracks - video now uses header-preserving encryption
if (this.sessionKey && window.RTCRtpScriptTransform) {
try {
@@ -198,12 +198,12 @@ export class WebRTCCall {
console.error("Failed to apply E2EE to received track:", error);
}
}
// Determine stream type based on track kind and signaling state
if (track.kind === "video") {
let isScreenShare = false;
let isVideo = false;
if (this.isRemoteScreenSharing && this.isRemoteVideoEnabled) {
// Both active - route based on which one we haven't received yet
// Simple logic: if we haven't received video yet, this is video
@@ -225,7 +225,7 @@ export class WebRTCCall {
isVideo = true;
this.receivedVideoTrackCount++;
}
if (isScreenShare) {
if (callbacks.onRemoteScreenShare) {
callbacks.onRemoteScreenShare(this.remoteUserId, remoteStream);
@@ -253,7 +253,7 @@ export class WebRTCCall {
// Clean up only on permanent failures
// Don't end on "disconnected" - ICE can recover from temporary disconnections
if (this.peerConnection.connectionState === "failed" ||
if (this.peerConnection.connectionState === "failed" ||
this.peerConnection.connectionState === "closed") {
// Only send end call message if we're not already cleaning up
if (!this.isEnding) {
@@ -284,32 +284,32 @@ export class WebRTCCall {
audioTrack.stop();
this.localStream.removeTrack(audioTrack);
}
// Create a silent audio track using Web Audio API
const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
const audioContext = new AudioContextClass();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
// Set gain to 0 (silent)
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
// Connect nodes
oscillator.connect(gainNode);
// Create a MediaStreamDestination to get a MediaStream
const destination = audioContext.createMediaStreamDestination();
gainNode.connect(destination);
// Start the oscillator (but it's silent due to gain = 0)
oscillator.start();
// Add the silent track to maintain WebRTC connection
const silentTrack = destination.stream.getAudioTracks()[0];
if (silentTrack) {
this.localStream.addTrack(silentTrack);
}
this.isMuted = true;
return true; // Muted
} else {
@@ -318,15 +318,15 @@ export class WebRTCCall {
.then(newStream => {
// Remove any existing audio tracks from the stream
this.localStream!.getAudioTracks().forEach(track => track.stop());
// Get the new active track
const newAudioTrack = newStream.getAudioTracks()[0];
// Replace the track in the peer connection
const sender = this.peerConnection.getSenders().find(s =>
const sender = this.peerConnection.getSenders().find(s =>
s.track && s.track.kind === 'audio'
);
if (sender) {
// Replace the track in the existing sender
sender.replaceTrack(newAudioTrack);
@@ -334,10 +334,10 @@ export class WebRTCCall {
// Add the track to the peer connection if no sender exists
this.peerConnection.addTrack(newAudioTrack, this.localStream!);
}
// Add the track to the local stream
this.localStream!.addTrack(newAudioTrack);
this.isMuted = false;
})
.catch(error => {
@@ -471,10 +471,10 @@ export class WebRTCCall {
// Add screen share track to peer connection
const videoTrack = screenStream.getVideoTracks()[0];
// Handle when user stops sharing via browser UI
videoTrack.addEventListener("ended", async () => {
// Clean up screen share state
if (this.screenShareStream) {
this.screenShareStream.getTracks().forEach(t => t.stop());
@@ -484,12 +484,12 @@ export class WebRTCCall {
// Remove screen share track from peer connection
const senders = this.peerConnection.getSenders();
const screenSender = senders.find(sender =>
sender.track && sender.track.kind === 'video' &&
const screenSender = senders.find(sender =>
sender.track && sender.track.kind === 'video' &&
sender.track.readyState === 'ended' &&
this.transformedSenders.has(sender)
);
if (screenSender) {
await this.peerConnection.removeTrack(screenSender);
this.transformedSenders.delete(screenSender);
@@ -534,7 +534,7 @@ export class WebRTCCall {
try {
const key = await importAesGcmKey(this.sessionKey);
const sender = this.peerConnection.getSenders().find(s => s.track === videoTrack);
if (sender && !this.transformedSenders.has(sender)) {
sender.transform = new RTCRtpScriptTransform(new E2EEWorker(), { key, mode: "encrypt", sessionId: this.sessionId });
this.transformedSenders.add(sender);
@@ -604,14 +604,14 @@ export class WebRTCCall {
*/
async setSessionKey(keyBytes: Uint8Array): Promise<void> {
this.sessionKey = keyBytes;
await this.applyE2EETransforms();
// Start key rotation timer (rotate every 10 minutes for long calls)
if (this.keyRotationTimer) {
clearInterval(this.keyRotationTimer);
}
this.keyRotationTimer = setInterval(async () => {
await this.rotateSessionKey();
}, KEY_ROTATION_INTERVAL);
@@ -622,14 +622,14 @@ export class WebRTCCall {
*/
private async rotateSessionKey(): Promise<void> {
if (!this.sessionKey) return;
try {
// Generate new session key
const newSessionKey = await rotateCallSessionKey();
// Update the call with new session key
this.sessionKey = newSessionKey.key;
// Reapply E2EE transforms with new key
await this.applyE2EETransforms();
} catch (error) {
@@ -645,9 +645,9 @@ export class WebRTCCall {
if (!this.sessionKey || !window.RTCRtpScriptTransform) {
return;
}
const key = await importAesGcmKey(this.sessionKey);
// Apply to receivers that don't already have transforms
const receivers = this.peerConnection.getReceivers();
for (const receiver of receivers) {
@@ -656,7 +656,7 @@ export class WebRTCCall {
this.transformedReceivers.add(receiver);
}
}
// Apply to senders that don't already have transforms
const senders = this.peerConnection.getSenders();
for (const sender of senders) {
@@ -678,9 +678,9 @@ export class WebRTCCall {
if (!sessionKey || !window.RTCRtpScriptTransform) {
return;
}
const key = await importAesGcmKey(sessionKey);
// Apply to receivers that don't already have transforms
const receivers = this.peerConnection.getReceivers();
for (const receiver of receivers) {
@@ -689,7 +689,7 @@ export class WebRTCCall {
this.transformedReceivers.add(receiver);
}
}
// Apply to senders that don't already have transforms
const senders = this.peerConnection.getSenders();
for (const sender of senders) {
@@ -735,7 +735,7 @@ export class WebRTCCall {
if (this.keyRotationTimer) {
clearInterval(this.keyRotationTimer);
}
// Close peer connection
if (this.peerConnection) {
this.peerConnection.close();
@@ -833,8 +833,8 @@ export async function initiateCall(userId: number, username: string): Promise<bo
type: "call_invite",
fromUserId: 0, // Will be set by server
toUserId: userId,
data: {
fromUsername: username
data: {
fromUsername: username
}
});
@@ -886,14 +886,14 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi
console.error("setSessionKey: No call found for user", userId);
return;
}
await call.setSessionKey(keyBytes);
}
export async function receiveWrappedSessionKey(
fromUserId: number,
wrappedPayload: WrappedSessionKeyPayload,
fromUserId: number,
wrappedPayload: WrappedSessionKeyPayload,
sessionKeyHash?: string
): Promise<void> {
try {
@@ -906,14 +906,14 @@ export async function receiveWrappedSessionKey(
console.error("Missing wrapped payload or session key hash");
return;
}
// Unwrap the session key from the encrypted payload
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
salt: wrappedPayload.salt,
iv2: wrappedPayload.iv2,
wrapped: wrappedPayload.wrapped
});
// Use the unwrapped session key directly (both sides should have the same key)
await setSessionKey(fromUserId, unwrappedSessionKey);
} catch (e) {
@@ -973,7 +973,7 @@ export async function endCall(userId: number): Promise<void> {
const call = calls.get(userId);
if (call && !call.isEnding) {
call.isEnding = true;
// Send call end message
await sendSignalingMessage({
type: "call_end",
@@ -1009,7 +1009,7 @@ export async function onRemoteAccepted(userId: number): Promise<void> {
// Small delay to ensure remote peer finishes processing the accept
// This prevents race conditions where our offer arrives before they're ready
await delay(NEGOTIATION_DELAY);
// Create offer
const offer = await call.peerConnection.createOffer();
await call.peerConnection.setLocalDescription(offer);
@@ -1029,7 +1029,7 @@ export async function onRemoteAccepted(userId: number): Promise<void> {
export async function handleCallOffer(userId: number, offer: RTCSessionDescriptionInit): Promise<void> {
let call = calls.get(userId);
// Handle race condition - offer might arrive before peer connection is created
if (!call) {
call = await createPeerConnection(userId);
@@ -1085,23 +1085,23 @@ export async function handleCallAnswer(userId: number, answer: RTCSessionDescrip
try {
await call.peerConnection.setRemoteDescription(answer);
// Reset negotiating flag
call.isNegotiating = false;
// Attach transforms on initiator side if session key is available
// If not available yet, setSessionKey will apply them when it arrives
if (call.sessionKey) {
await call.createE2EETransform(call.sessionKey, call.sessionId);
}
// Check if there are new receivers with tracks that haven't been notified yet
// This handles the case where tracks exist but the track event hasn't fired yet
const receivers = call.peerConnection.getReceivers();
for (const receiver of receivers) {
if (receiver.track) {
const track = receiver.track;
// Find the stream for this track
const transceiver = call.peerConnection.getTransceivers().find(t => t.receiver === receiver);
if (transceiver && transceiver.receiver.track) {
+3 -3
View File
@@ -12,7 +12,7 @@ interface SearchBarProps {
rightIcon?: string | React.ReactNode;
}
export default function SearchBar({
export default function SearchBar({
placeholder,
children,
searchQuery,
@@ -66,11 +66,11 @@ export default function SearchBar({
};
return (
<div
<div
ref={parentContainerRef}
className="search-parent"
>
<div
<div
ref={searchContainerRef}
className={`search-bar-container ${isExpanded ? "expanded" : "collapsed"}`}
style={{ height: dynamicHeight }}
+2 -2
View File
@@ -3,7 +3,7 @@ import type { TextField } from "mdui/components/text-field";
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
return <mdui-text-field
autocomplete="off"
return <mdui-text-field
autocomplete="off"
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
}
@@ -14,16 +14,16 @@ $font-size: 16px;
position: absolute;
z-index: 1001;
overflow: hidden;
// All properties animate together simultaneously
transition:
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;
@@ -143,7 +143,7 @@ $font-size: 16px;
mdui-list-item {
img[slot="icon"] {
$size: 48px;
width: $size;
height: $size;
border-radius: 50%;
+152
View File
@@ -0,0 +1,152 @@
/**
* @fileoverview Online status manager for real-time user status tracking
* @description Handles subscription to user online statuses via WebSocket
* @author Cursor
* @version 1.0.0
*/
import { request } from "./websocket";
import type {
StatusUpdateWebSocketMessage,
SubscribeStatusWebSocketMessage,
UnsubscribeStatusWebSocketMessage
} from "./types";
import { useAppState } from "@/pages/chat/state";
export interface UserStatus {
online: boolean;
lastSeen: string;
}
/**
* Manages online status subscriptions and updates
*/
export class OnlineStatusManager {
private subscribedUsers: Set<number> = new Set();
private statusCache: Map<number, UserStatus> = new Map();
private authToken: string | null = null;
/**
* Set the authentication token for WebSocket requests
*/
setAuthToken(token: string | null): void {
this.authToken = token;
}
/**
* Subscribe to a user's online status
*/
async subscribe(userId: number): Promise<void> {
if (!this.authToken || this.subscribedUsers.has(userId)) {
return;
}
try {
const message: SubscribeStatusWebSocketMessage = {
type: "subscribeStatus",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
userId
}
};
await request(message);
this.subscribedUsers.add(userId);
} catch (error) {
console.error(`Failed to subscribe to user ${userId} status:`, error);
}
}
/**
* Unsubscribe from a user's online status
*/
async unsubscribe(userId: number): Promise<void> {
if (!this.authToken || !this.subscribedUsers.has(userId)) {
return;
}
try {
const message: UnsubscribeStatusWebSocketMessage = {
type: "unsubscribeStatus",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
userId
}
};
await request(message);
this.subscribedUsers.delete(userId);
this.statusCache.delete(userId);
} catch (error) {
console.error(`Failed to unsubscribe from user ${userId} status:`, error);
}
}
/**
* Handle incoming status update from WebSocket
*/
handleStatusUpdate(message: StatusUpdateWebSocketMessage): void {
const { userId, online, lastSeen } = message.data;
this.statusCache.set(userId, { online, lastSeen });
// Update the global state
const { updateOnlineStatus } = useAppState.getState();
updateOnlineStatus(userId, online, lastSeen);
}
/**
* Get cached status for a user
*/
getStatus(userId: number): UserStatus | undefined {
return this.statusCache.get(userId);
}
/**
* Get all cached statuses
*/
getAllStatuses(): Map<number, UserStatus> {
return new Map(this.statusCache);
}
/**
* Check if subscribed to a user's status
*/
isSubscribed(userId: number): boolean {
return this.subscribedUsers.has(userId);
}
/**
* Get all subscribed user IDs
*/
getSubscribedUsers(): Set<number> {
return new Set(this.subscribedUsers);
}
/**
* Unsubscribe from all users and clear cache
*/
async unsubscribeAll(): Promise<void> {
const unsubscribePromises = Array.from(this.subscribedUsers).map(userId =>
this.unsubscribe(userId)
);
await Promise.all(unsubscribePromises);
this.subscribedUsers.clear();
this.statusCache.clear();
}
/**
* Cleanup when component unmounts
*/
cleanup(): void {
this.unsubscribeAll();
}
}
// Global instance
export const onlineStatusManager = new OnlineStatusManager();
@@ -108,8 +108,8 @@ async function showMessageNotification(message: any): Promise<void> {
try {
await showNotification({
title: `New message from ${message.username}`,
body: message.content.length > 100
? message.content.substring(0, 100) + "..."
body: message.content.length > 100
? message.content.substring(0, 100) + "..."
: message.content,
icon: message.profile_picture || "/logo.png",
tag: `message_${message.id}`,
@@ -155,7 +155,7 @@ export async function initialize(): Promise<boolean> {
try {
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
console.log("Service Worker registered successfully");
const permission = await Notification.requestPermission();
if (permission === "granted") {
await subscribeToWebPush();
@@ -200,7 +200,7 @@ export async function showNotification(payload: NotificationPayload): Promise<bo
return false;
}
}
// For web browsers, notifications are handled by the service worker
// when push messages are received from the server
return false;
@@ -240,7 +240,7 @@ export async function startElectronReceiver(): Promise<void> {
}
isElectronReceiverRunning = true;
// Add our own message listener to the existing WebSocket
messageListener = (event: MessageEvent) => {
try {
@@ -250,7 +250,7 @@ export async function startElectronReceiver(): Promise<void> {
console.error('Failed to parse WebSocket message:', error);
}
};
websocket.addEventListener('message', messageListener);
}
@@ -260,7 +260,7 @@ export function stopElectronReceiver(): void {
}
isElectronReceiverRunning = false;
// Remove our message listener
if (messageListener) {
websocket.removeEventListener('message', messageListener);
@@ -33,7 +33,7 @@ self.addEventListener("push", function(event: ExtendableEvent) {
const pushEvent = event as PushEvent;
if (pushEvent.data) {
const data: NotificationPayload = pushEvent.data.json();
const options: NotificationOptions = {
body: data.body,
icon: data.icon || "/logo.png",
+100 -10
View File
@@ -221,17 +221,17 @@ export interface DmFile {
path: string;
}
export interface DmEditedPayload {
id: number;
iv: string;
ciphertext: string;
timestamp: string
export interface DmEditedPayload {
id: number;
iv: string;
ciphertext: string;
timestamp: string
}
export interface DmDeletedPayload {
id: number;
senderId: number;
recipientId: number
export interface DmDeletedPayload {
id: number;
senderId: number;
recipientId: number
}
export interface FetchDMResponse {
@@ -461,7 +461,7 @@ export interface CallSignalingMessage extends WebSocketMessage {
data: CallSignalingMessageData;
}
export type CallSignalingMessageData =
export type CallSignalingMessageData =
| CallInviteMessageData
| CallAcceptData
| CallRejectData
@@ -530,4 +530,94 @@ export interface CallVideoToggleMessage extends CallSignalingMessage {
export interface CallScreenShareToggleMessage extends CallSignalingMessage {
type: "call_screen_share_toggle";
data: CallScreenShareToggleData;
}
// -----------
// Online Status & Typing WebSocket Messages
// -----------
export interface StatusUpdateWebSocketMessage extends WebSocketMessage {
type: "statusUpdate";
data: {
userId: number;
online: boolean;
lastSeen: string;
};
}
export interface SubscribeStatusWebSocketMessage extends WebSocketMessage {
type: "subscribeStatus";
credentials: WebSocketCredentials;
data: {
userId: number;
};
}
export interface UnsubscribeStatusWebSocketMessage extends WebSocketMessage {
type: "unsubscribeStatus";
credentials: WebSocketCredentials;
data: {
userId: number;
};
}
export interface TypingWebSocketMessage extends WebSocketMessage {
type: "typing";
data: {
userId: number;
username: string;
};
}
export interface StopTypingWebSocketMessage extends WebSocketMessage {
type: "stopTyping";
data: {
userId: number;
username: string;
};
}
export interface DmTypingWebSocketMessage extends WebSocketMessage {
type: "dmTyping";
data: {
userId: number;
username: string;
};
}
export interface StopDmTypingWebSocketMessage extends WebSocketMessage {
type: "stopDmTyping";
data: {
userId: number;
username: string;
};
}
// Request types for sending typing/status messages
export interface TypingRequest extends WebSocketMessage {
type: "typing";
credentials: WebSocketCredentials;
data: {};
}
export interface StopTypingRequest extends WebSocketMessage {
type: "stopTyping";
credentials: WebSocketCredentials;
data: {};
}
export interface DmTypingRequest extends WebSocketMessage {
type: "dmTyping";
credentials: WebSocketCredentials;
data: {
recipientId: number;
};
}
export interface StopDmTypingRequest extends WebSocketMessage {
type: "stopDmTyping";
credentials: WebSocketCredentials;
data: {
recipientId: number;
};
}
+232
View File
@@ -0,0 +1,232 @@
/**
* @fileoverview Typing indicator manager for real-time typing status
* @description Handles typing indicators for public chat and DMs via WebSocket
* @author Cursor
* @version 1.0.0
*/
import { request } from "./websocket";
import type {
TypingWebSocketMessage,
StopTypingWebSocketMessage,
DmTypingWebSocketMessage,
StopDmTypingWebSocketMessage,
TypingRequest,
StopTypingRequest,
DmTypingRequest,
StopDmTypingRequest
} from "./types";
import { useAppState } from "@/pages/chat/state";
/**
* Manages typing indicators for public chat and DMs
*/
export class TypingManager {
private authToken: string | null = null;
private typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
private readonly TYPING_TIMEOUT = 3000; // 3 seconds
/**
* Set the authentication token for WebSocket requests
*/
setAuthToken(token: string | null): void {
this.authToken = token;
}
/**
* Send typing indicator for public chat
*/
async sendTyping(): Promise<void> {
if (!this.authToken) return;
try {
const message: TypingRequest = {
type: "typing",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
await request(message);
this.scheduleStopTyping("public");
} catch (error) {
console.error("Failed to send typing indicator:", error);
}
}
/**
* Send stop typing indicator for public chat
*/
async sendStopTyping(): Promise<void> {
if (!this.authToken) return;
try {
const message: StopTypingRequest = {
type: "stopTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
await request(message);
this.clearStopTypingTimeout("public");
} catch (error) {
console.error("Failed to send stop typing indicator:", error);
}
}
/**
* Send typing indicator for DM
*/
async sendDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return;
try {
const message: DmTypingRequest = {
type: "dmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
await request(message);
this.scheduleStopDmTyping(recipientId);
} catch (error) {
console.error("Failed to send DM typing indicator:", error);
}
}
/**
* Send stop typing indicator for DM
*/
async sendStopDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return;
try {
const message: StopDmTypingRequest = {
type: "stopDmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
await request(message);
this.clearStopTypingTimeout(`dm_${recipientId}`);
} catch (error) {
console.error("Failed to send stop DM typing indicator:", error);
}
}
/**
* Handle incoming typing indicator from WebSocket
*/
handleTyping(message: TypingWebSocketMessage): void {
const { addTypingUser } = useAppState.getState();
addTypingUser(message.data.userId, message.data.username);
}
/**
* Handle incoming stop typing indicator from WebSocket
*/
handleStopTyping(message: StopTypingWebSocketMessage): void {
const { removeTypingUser } = useAppState.getState();
removeTypingUser(message.data.userId);
}
/**
* Handle incoming DM typing indicator from WebSocket
*/
handleDmTyping(message: DmTypingWebSocketMessage): void {
const { setDmTypingUser } = useAppState.getState();
setDmTypingUser(message.data.userId, true);
}
/**
* Handle incoming stop DM typing indicator from WebSocket
*/
handleStopDmTyping(message: StopDmTypingWebSocketMessage): void {
const { setDmTypingUser } = useAppState.getState();
setDmTypingUser(message.data.userId, false);
}
/**
* Schedule automatic stop typing after timeout
*/
private scheduleStopTyping(context: string): void {
this.clearStopTypingTimeout(context);
const timeout = setTimeout(async () => {
if (context === "public") {
await this.sendStopTyping();
}
this.typingTimeouts.delete(context);
}, this.TYPING_TIMEOUT);
this.typingTimeouts.set(context, timeout);
}
/**
* Schedule automatic stop DM typing after timeout
*/
private scheduleStopDmTyping(recipientId: number): void {
const context = `dm_${recipientId}`;
this.clearStopTypingTimeout(context);
const timeout = setTimeout(async () => {
await this.sendStopDmTyping(recipientId);
this.typingTimeouts.delete(context);
}, this.TYPING_TIMEOUT);
this.typingTimeouts.set(context, timeout);
}
/**
* Clear stop typing timeout
*/
private clearStopTypingTimeout(context: string): void {
const timeout = this.typingTimeouts.get(context);
if (timeout) {
clearTimeout(timeout);
this.typingTimeouts.delete(context);
}
}
/**
* Immediately stop typing for public chat (called when message is sent)
*/
async stopTypingOnMessage(): Promise<void> {
this.clearStopTypingTimeout("public");
await this.sendStopTyping();
}
/**
* Immediately stop DM typing (called when message is sent)
*/
async stopDmTypingOnMessage(recipientId: number): Promise<void> {
this.clearStopTypingTimeout(`dm_${recipientId}`);
await this.sendStopDmTyping(recipientId);
}
/**
* Cleanup all timeouts
*/
cleanup(): void {
this.typingTimeouts.forEach(timeout => clearTimeout(timeout));
this.typingTimeouts.clear();
}
}
// Global instance
export const typingManager = new TypingManager();
+18 -3
View File
@@ -9,6 +9,8 @@ import { API_WS_BASE_URL } from "./config";
import type { WebSocketMessage } from "./types";
import { delay } from "@/utils/utils";
import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager";
/**
* Creates a new WebSocket connection to the chat server
@@ -85,7 +87,7 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
* If it fails, tries again in an endless loop until the connection is established
* again.
*
*
* @private
*/
async function onError() {
@@ -110,12 +112,25 @@ async function onError() {
websocket.addEventListener("message", (e) => {
try {
const response: WebSocketMessage<any> = JSON.parse(e.data);
// Handle call signaling messages
if (callSignalingHandler && response.type === "call_signaling" && response.data) {
callSignalingHandler.handleWebSocketMessage(response.data);
}
// Handle status and typing messages
if (response.type === "statusUpdate") {
onlineStatusManager.handleStatusUpdate(response as any);
} else if (response.type === "typing") {
typingManager.handleTyping(response as any);
} else if (response.type === "stopTyping") {
typingManager.handleStopTyping(response as any);
} else if (response.type === "dmTyping") {
typingManager.handleDmTyping(response as any);
} else if (response.type === "stopDmTyping") {
typingManager.handleStopDmTyping(response as any);
}
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
+8 -8
View File
@@ -1,15 +1,15 @@
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease forwards;
}
+4 -4
View File
@@ -14,7 +14,7 @@
background-color: #C6F6D5;
color: #22543D;
}
&.alert-danger {
background-color: #FED7D7;
color: #742A2A;
@@ -22,7 +22,7 @@
}
.link {
color: $color-dark-primary;
color: $color-dark-primary;
font-weight: 600;
}
@@ -38,11 +38,11 @@ button, input {
font-size: 1.2rem;
font-weight: 600;
}
mdui-text-field {
width: 100%;
}
.dialog-actions {
display: flex;
gap: 0.75rem;
+4 -4
View File
@@ -54,10 +54,10 @@ $color-dark-surface-primary-container-lightened: color.adjust($color-dark-primar
$color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%);
// custom colors
$color-1: rgb(82, 109, 246);
$color-2: rgb(65, 11, 113);
$color-4: rgb(95, 26, 198);
$color-3: rgb(49, 71, 179);
$color-1: rgb(82, 109, 246);
$color-2: rgb(65, 11, 113);
$color-4: rgb(95, 26, 198);
$color-3: rgb(49, 71, 179);
// Light
$color-light-primary: rgb(31 101 134);
$color-light-surface-tint: rgb(31 101 134);
+2 -2
View File
@@ -9,13 +9,13 @@ interface ProtectedRouteProps {
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user } = useAppState();
const navigate = useNavigate();
useEffect(() => {
if (!user.authToken) {
navigate("/login");
return;
}
}, [user.authToken, user.currentUser, navigate]);
return <>{children}</>;
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
return (
<div className="auth-header">
<h2>
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
{title}
</h2>
<p>{subtitle}</p>
+13 -13
View File
@@ -33,19 +33,19 @@ export default function LoginPage() {
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
<div className="auth-body">
<AlertsContainer alerts={alerts} />
<form
onSubmit={async (e) => {
e.preventDefault();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
if (!username || !password) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
try {
const request: LoginRequest = {
username: username,
@@ -59,12 +59,12 @@ export default function LoginPage() {
},
body: JSON.stringify(request)
});
if (response.ok) {
const data: LoginResponse = await response.json();
// Store the JWT token first
setUser(data.token, data.user);
// Setup keys with the token we just received
try {
await ensureKeysOnLogin(password, data.token);
@@ -73,19 +73,19 @@ export default function LoginPage() {
}
navigate("/chat");
// Initialize notifications
try {
if (isSupported()) {
const initialized = await initialize();
if (initialized) {
await subscribe(data.token);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
}
console.log("Notifications enabled");
} else {
console.log("Notification permission denied");
@@ -113,7 +113,7 @@ export default function LoginPage() {
autocomplete="username"
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="login-password"
@@ -128,13 +128,13 @@ export default function LoginPage() {
<mdui-button type="submit">Войти</mdui-button>
</form>
<div className="text-center">
<p>
Ещё нет аккаунта?
Ещё нет аккаунта?
<a
href="#"
className="link"
className="link"
onClick={() => navigate("/register")}>
Зарегистрируйтесь
</a>
+29 -29
View File
@@ -32,41 +32,41 @@ export default function RegisterPage() {
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
<div className="auth-body">
<AlertsContainer alerts={alerts} />
<form onSubmit={async (e) => {
e.preventDefault();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
const confirmPassword = confirmPasswordElement.current!.value.trim();
if (!username || !password || !confirmPassword) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
if (password !== confirmPassword) {
showAlert("danger", "Пароли не совпадают");
return;
}
if (username.length < 3 || username.length > 20) {
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
return;
}
if (password.length < 5 || password.length > 50) {
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
return;
}
try {
const request: RegisterRequest = {
username: username,
password: password,
confirm_password: confirmPassword
}
const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST',
headers: {
@@ -74,12 +74,12 @@ export default function RegisterPage() {
},
body: JSON.stringify(request)
});
if (response.ok) {
const data: LoginResponse = await response.json();
// Store the JWT token first
setUser(data.token, data.user);
// Setup keys with the token we just received
try {
await ensureKeysOnLogin(password, data.token);
@@ -97,9 +97,9 @@ export default function RegisterPage() {
}
}}>
<MaterialTextField
label="Имя пользователя"
id="register-username"
name="username"
label="Имя пользователя"
id="register-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
@@ -108,22 +108,22 @@ export default function RegisterPage() {
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="register-password"
name="password"
variant="outlined"
type="password"
label="Пароль"
id="register-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={passwordElement} />
<MaterialTextField
label="Подтвердите пароль"
id="register-confirm-password"
name="confirm_password"
variant="outlined"
type="password"
label="Подтвердите пароль"
id="register-confirm-password"
name="confirm_password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
@@ -132,14 +132,14 @@ export default function RegisterPage() {
<mdui-button type="submit">Зарегистрироваться</mdui-button>
</form>
<div className="text-center">
<p>
Уже есть аккаунт?
<a
href="#"
id="login-link"
className="link"
Уже есть аккаунт?
<a
href="#"
id="login-link"
className="link"
onClick={() => navigate("/login")}>
Войдите
</a>
+2 -2
View File
@@ -18,7 +18,7 @@
max-width: 450px;
overflow: hidden;
}
.auth-header {
margin: 0;
padding: 16px;
@@ -36,7 +36,7 @@
justify-content: center;
}
}
.auth-body {
padding: 25px;
padding-bottom: 16px;
+18 -18
View File
@@ -9,10 +9,10 @@
display: flex;
flex-direction: column;
user-select: none;
// Base transition for all properties
transition: all 0.4s $transition;
// Disable all transitions while dragging for immediate feedback
&.dragging {
transition: none !important;
@@ -100,7 +100,7 @@
.call-header {
padding: 12px;
min-height: auto;
.call-header-info {
.username {
font-size: 14px;
@@ -170,7 +170,7 @@
// Dynamic gradients based on call state
&.gradient-calling {
border-color: rgba(255, 193, 7, 0.5);
&::before {
content: '';
position: absolute;
@@ -178,9 +178,9 @@
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.12) 0%,
rgba(255, 152, 0, 0.12) 50%,
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.12) 0%,
rgba(255, 152, 0, 0.12) 50%,
rgba(255, 193, 7, 0.12) 100%);
border-radius: inherit;
animation: pulse-gradient 2s ease-in-out infinite;
@@ -190,7 +190,7 @@
&.gradient-connecting {
border-color: rgba(33, 150, 243, 0.5);
&::before {
content: '';
position: absolute;
@@ -198,9 +198,9 @@
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(33, 150, 243, 0.12) 0%,
rgba(63, 81, 181, 0.12) 50%,
background: linear-gradient(135deg,
rgba(33, 150, 243, 0.12) 0%,
rgba(63, 81, 181, 0.12) 50%,
rgba(33, 150, 243, 0.12) 100%);
border-radius: inherit;
animation: connecting-gradient 1.5s ease-in-out infinite;
@@ -210,7 +210,7 @@
&.gradient-active {
border-color: rgba(76, 175, 80, 0.5);
&::before {
content: '';
position: absolute;
@@ -218,9 +218,9 @@
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(76, 175, 80, 0.12) 0%,
rgba(56, 142, 60, 0.12) 50%,
background: linear-gradient(135deg,
rgba(76, 175, 80, 0.12) 0%,
rgba(56, 142, 60, 0.12) 50%,
rgba(76, 175, 80, 0.12) 100%);
border-radius: inherit;
animation: active-gradient 3s ease-in-out infinite;
@@ -288,7 +288,7 @@
font-size: 24px;
display: inline-block;
animation: emoji-pulse 2s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
@@ -355,7 +355,7 @@
overflow-y: auto;
overflow-x: hidden;
padding: 5px;
// Custom scrollbar
&::-webkit-scrollbar {
width: 6px;
@@ -369,7 +369,7 @@
&::-webkit-scrollbar-thumb {
background: rgba($color-dark-primary, 0.5);
border-radius: 3px;
&:hover {
background: rgba($color-dark-primary, 0.7);
}
+12 -10
View File
@@ -19,12 +19,12 @@
display: flex;
align-items: flex-start;
gap: 16px;
mdui-icon {
align-self: center;
box-sizing: content-box;
}
.reply-cancel {
margin-left: auto;
}
@@ -45,13 +45,13 @@
display: flex;
flex-direction: row;
align-items: center;
.buttons, .left-buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.left-buttons {
.emoji-btn {
margin: 10px;
@@ -59,13 +59,13 @@
transition: color 0.2s ease;
flex-shrink: 0;
align-self: flex-end;
&:hover {
color: $color-dark-primary;
}
}
}
.message-input {
flex: 1;
padding: 20px 0;
@@ -81,7 +81,7 @@
font-size: 13pt;
height: 100%;
width: 100%;
&::placeholder {
color: $color-dark-on-surface-variant;
opacity: 0.7;
@@ -104,7 +104,7 @@
transition: all 0.25s ease;
align-self: flex-end;
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
&:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba($color-dark-primary, 0.6);
@@ -115,6 +115,8 @@
}
}
// Typing indicator styles
// Emoji Menu Styles
.emoji-menu {
$transition: cubic-bezier(0.4, 0, 0.2, 1);
@@ -154,7 +156,7 @@
overflow-y: hidden;
scroll-behavior: smooth;
padding: 8px;
&::-webkit-scrollbar {
height: 4px;
}
@@ -229,7 +231,7 @@
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 6px;
}
+32 -32
View File
@@ -12,7 +12,7 @@
transition: all 0.15s ease;
backdrop-filter: blur(8px);
transform: translateY(0);
&.closing {
opacity: 0;
transform: scale(0.8);
@@ -32,50 +32,50 @@
.context-menu-wrapper {
position: relative;
display: block;
// Animation states
&.entering {
opacity: 0;
transform: scale(0.8);
animation: contextMenuEnter 0.2s ease forwards;
}
&.entering-left {
opacity: 0;
transform: translateX(-20px) scale(0.8);
animation: contextMenuEnterLeft 0.2s ease forwards;
}
&.entering-up {
opacity: 0;
transform: translateY(20px) scale(0.8);
animation: contextMenuEnterUp 0.2s ease forwards;
}
&.entering-up-left {
opacity: 0;
transform: translateX(-20px) translateY(20px) scale(0.8);
animation: contextMenuEnterUpLeft 0.2s ease forwards;
}
&.closing {
opacity: 1;
transform: scale(1);
animation: contextMenuClose 0.2s ease forwards;
}
&.closing-left {
opacity: 1;
transform: translateX(0) scale(1);
animation: contextMenuCloseLeft 0.2s ease forwards;
}
&.closing-up {
opacity: 1;
transform: translateY(0) scale(1);
animation: contextMenuCloseUp 0.2s ease forwards;
}
&.closing-up-left {
opacity: 1;
transform: translateX(0) translateY(0) scale(1);
@@ -90,39 +90,39 @@
padding: 0.5rem 0;
min-width: 160px;
z-index: 1000;
&.entering {
animation: fadeInDown 0.2s ease forwards;
}
&.entering-left {
animation: fadeInLeft 0.2s ease forwards;
}
&.entering-up {
animation: fadeInUp 0.2s ease forwards;
}
&.entering-up-left {
animation: fadeInUpLeft 0.2s ease forwards;
}
&.closing {
animation: fadeOutUp 0.2s ease forwards;
}
&.closing-left {
animation: fadeOutRight 0.2s ease forwards;
}
&.closing-up {
animation: fadeOutDown 0.2s ease forwards;
}
&.closing-up-left {
animation: fadeOutDownRight 0.2s ease forwards;
}
.context-menu-item {
display: flex;
align-items: center;
@@ -132,11 +132,11 @@
color: $color-dark-on-surface;
font-size: 0.9rem;
transition: background-color 0.2s ease;
&:hover {
background-color: $color-dark-surface-container;
}
.material-symbols {
font-size: 1.1rem;
color: $color-dark-on-surface-variant;
@@ -158,31 +158,31 @@
justify-content: center;
margin-bottom: 10px;
transition: width 0.3s ease-out, height 0.3s ease-out;
&.left {
left: 0;
transform: translateX(0);
}
&.right {
right: 0;
transform: translateX(0);
}
&.expanded {
padding: 0;
overflow: hidden;
width: 320px;
height: 400px;
border-radius: 16px;
// Default: expand downward from the reaction bar's bottom edge
position: absolute;
bottom: auto;
top: 0;
left: 0;
transform: translateY(0);
&.expand-upward {
// Expand upward from the reaction bar's top edge
bottom: 100%;
@@ -203,7 +203,7 @@
align-items: center;
gap: 4px;
transition: opacity 0.3s ease-out;
&.faded {
opacity: 0;
}
@@ -221,13 +221,13 @@
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
font-size: 18px;
&:hover {
background: var(--mdui-color-surface-container-high);
transform: scale(1.3);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
@@ -245,25 +245,25 @@
background: var(--mdui-color-surface);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
&:hover {
background: var(--mdui-color-surface-container-high);
border-color: var(--mdui-color-primary);
transform: scale(1.1);
box-shadow: var(--mdui-elevation-1);
}
&:active {
transform: scale(0.95);
transition: transform 0.1s ease;
}
.material-symbols {
font-size: 18px;
color: var(--mdui-color-on-surface);
transition: transform 0.2s ease;
}
&:hover .material-symbols {
transform: rotate(90deg);
}
+2 -2
View File
@@ -6,7 +6,7 @@
height: 100%;
background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%);
position: relative;
&::before {
content: '';
position: fixed;
@@ -14,7 +14,7 @@
left: 0;
right: 0;
bottom: 0;
background:
background:
radial-gradient(circle at 20% 80%, rgba($color-dark-primary, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.15) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.1) 0%, transparent 50%);
+6 -7
View File
@@ -111,7 +111,7 @@
.product-name {
flex-grow: 1;
}
.profile {
font-size: 24px;
display: flex;
@@ -125,7 +125,7 @@
border: solid 2px $color-dark-on-surface-variant;
padding: 5px;
border-radius: 10px;
&:hover {
background-color: rgba(255, 255, 255, 0.241);
}
@@ -156,7 +156,6 @@
height: 45px;
border-radius: 20%;
object-fit: cover;
margin-right: 1rem;
}
mdui-tabs {
@@ -210,18 +209,18 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.profile {
a {
display: flex;
align-items: center;
text-decoration: none;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 40px;
height: 40px;
@@ -230,7 +229,7 @@
border: 2px solid rgba($color-dark-primary, 0.4);
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 0 25px rgba($color-dark-primary, 0.5);
border-color: rgba($color-dark-primary, 0.6);
@@ -26,21 +26,21 @@
font-size: 1px;
min-height: 28px;
animation: reactionFadeIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.removing {
animation: reactionFadeOut 0.2s ease forwards;
}
&:hover {
background-color: $color-dark-surface-container-high;
transform: scale(1.05);
}
&.reacted {
background-color: $color-dark-primary-container;
border-color: $color-dark-primary;
color: $color-dark-on-primary-container;
&:hover {
background-color: color.adjust($color-dark-primary-container, $lightness: 20%);
}
+9 -9
View File
@@ -37,11 +37,11 @@
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 100%;
height: 100%;
@@ -69,7 +69,7 @@
margin: 10px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
@@ -102,7 +102,7 @@
a {
text-decoration: none;
}
.attachement-image {
max-width: 200px;
border-radius: 8px;
@@ -199,7 +199,7 @@
border: 1px solid rgba($color-dark-outline-variant, 0.4);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
@@ -211,13 +211,13 @@
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
}
.message-time {
color: $color-dark-on-surface-variant;
font-weight: 500;
@@ -236,7 +236,7 @@
border: 1px solid rgba($color-dark-primary, 0.5);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
@@ -248,7 +248,7 @@
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
@@ -20,7 +20,7 @@
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
&.open {
opacity: 1;
visibility: visible;
@@ -42,26 +42,26 @@
transform: scale(0.9);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
&.open {
transform: scale(1);
opacity: 1;
}
.profile-dialog-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
.profile-picture-section {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 16px;
.profile-picture {
width: 120px;
height: 120px;
@@ -69,7 +69,7 @@
object-fit: cover;
border: 3px solid $color-dark-outline;
}
.profile-picture-edit-overlay {
position: absolute;
top: 0;
@@ -84,16 +84,16 @@
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
.username-section {
text-align: center;
.username-input {
background: none;
border: none;
@@ -114,24 +114,24 @@
align-items: center;
justify-content: center;
gap: 8px;
.online-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color-dark-primary;
&.offline {
background: $color-dark-on-surface-variant;
}
}
.status-text {
font-size: 0.875rem;
color: $color-dark-on-surface;
}
}
.profile-sections {
margin: 16px;
border-radius: 24px;
@@ -161,7 +161,7 @@
font-size: small;
color: $color-dark-on-surface-variant;
}
.value {
color: $color-dark-on-surface;
font-size: medium;
@@ -182,7 +182,7 @@
}
}
}
.profile-dialog-fab {
position: absolute;
bottom: 24px;
@@ -190,7 +190,7 @@
z-index: 1002;
transform: translateY(100px);
transition: transform 0.3s ease;
&.visible {
transform: translateY(0);
}
+3 -12
View File
@@ -43,15 +43,6 @@
}
}
.online-status {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: $success;
margin-right: 5px;
}
a {
display: flex;
flex-direction: row;
@@ -98,11 +89,11 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
backdrop-filter: blur(20px);
.file-overlay-wrapper {
@@ -40,7 +40,7 @@
flex: 1;
overflow-y: auto;
position: relative;
.settings-panel {
display: flex;
flex-direction: column;
@@ -53,43 +53,43 @@
top: 0;
left: 0;
width: 100%;
&.active {
opacity: 1;
visibility: visible;
transform: translateY(0);
position: relative;
}
h3 {
margin: 0 0 16px 0;
color: $color-dark-on-surface;
}
mdui-text-field,
mdui-select,
mdui-switch,
mdui-button {
margin-bottom: 8px;
}
mdui-switch {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid $color-dark-outline;
&:last-child {
border-bottom: none;
}
}
p {
margin: 8px 0;
color: $color-dark-on-surface-variant;
}
mdui-linear-progress {
margin: 16px 0;
}
@@ -0,0 +1,109 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Unified typing indicator styles (used for both public chat and DMs)
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
color: $color-dark-primary;
.typing-dots {
display: flex;
gap: 2px;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: $color-dark-primary;
animation: typing-dot 1.4s infinite ease-in-out;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
.typing-text {
font-size: 0.875rem;
font-weight: 500;
}
}
// Online status display (used in DMs when not typing)
.online-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: $color-dark-on-surface-variant;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.online {
background: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
}
&.offline {
background: $color-dark-on-surface-variant;
opacity: 0.6;
}
}
.status-text {
font-weight: 500;
font-size: 0.75rem;
opacity: 0.8;
}
}
// Online indicator for profile pictures (positioned at bottom right)
.online-indicator {
position: absolute;
bottom: 0px;
right: 0px;
z-index: 10;
pointer-events: none;
transform: none;
.indicator-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid $color-dark-surface;
box-sizing: border-box;
display: block;
background: #4caf50;
position: relative;
transform: none;
}
}
// Ensure the icon container allows absolute positioning
mdui-list-item [slot="icon"] {
position: relative;
display: inline-block;
}
// Typing dot animation
@keyframes typing-dot {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
+2 -1
View File
@@ -9,4 +9,5 @@
@use "settings-dialog";
@use "animations";
@use "callWindow";
@use "profile-dialog";
@use "profile-dialog";
@use "typing-indicators";
+19 -19
View File
@@ -14,21 +14,21 @@ let globalLocalScreenShareRef = createRef<HTMLVideoElement>();
let globalRemoteScreenShareRef = createRef<HTMLVideoElement>();
export default function useCall() {
const {
chat,
startCall,
endCall,
const {
chat,
startCall,
endCall,
setCallStatus,
toggleMute,
toggleVideo,
toggleMute,
toggleVideo,
toggleScreenShare,
setCallEncryption,
setCallSessionKeyHash,
setCallSessionKeyHash,
setRemoteVideoEnabled,
setRemoteScreenSharing,
user
user
} = useAppState();
const remoteAudioRef = globalRemoteAudioRef;
const localVideoRef = globalLocalVideoRef;
const remoteVideoRef = globalRemoteVideoRef;
@@ -37,12 +37,12 @@ export default function useCall() {
useEffect(() => {
// Initialize call signaling handler
const signalingHandler = new CallSignalingHandler(() => ({
const signalingHandler = new CallSignalingHandler(() => ({
receiveCall: (userId: number, username: string) => {
// Use the receiveCall function from state
const state = useAppState.getState();
state.receiveCall(userId, username);
},
},
endCall,
setCallSessionKeyHash,
setRemoteVideoEnabled,
@@ -195,11 +195,11 @@ export default function useCall() {
async function requestAudioPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
video: false
});
// Stop the stream immediately as we just needed permission
stream.getTracks().forEach(track => track.stop());
return true;
@@ -211,7 +211,7 @@ export default function useCall() {
async function initiateCall(userId: number, username: string) {
const hasPermission = await requestAudioPermissions();
if (!hasPermission) {
return;
}
@@ -221,7 +221,7 @@ export default function useCall() {
// Generate call session key and emojis
sessionKey = await generateCallSessionKey();
const emojis = generateCallEmojis(sessionKey.hash);
// Start the call in state
startCall(userId, username);
setCallStatus("calling");
@@ -234,11 +234,11 @@ export default function useCall() {
// Initiate WebRTC call
const success = await WebRTC.initiateCall(userId, username);
if (success && sessionKey) {
// Set the session key for ourselves (initiator)
await WebRTC.setSessionKey(userId, sessionKey.key);
// Send session key hash to the receiver for visual verification
await WebRTC.sendCallSessionKey(userId, sessionKey.hash);
// Also wrap and send the actual session key for E2EE media
@@ -255,7 +255,7 @@ export default function useCall() {
setCallStatus("connecting");
const success = await WebRTC.acceptCall(chat.call.remoteUserId);
if (!success) {
endCall();
}
+47 -47
View File
@@ -1,9 +1,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "@/pages/chat/state";
import {
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
import {
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
fetchDMConversations,
type DMConversationResponse
@@ -19,9 +19,9 @@ export interface DMUser extends User {
// Utility function for consistent username formatting in DM messages
export function formatDMUsername(
senderId: number,
_recipientId: number,
currentUserId: number,
senderId: number,
_recipientId: number,
currentUserId: number,
otherUsername: string
): string {
const isFromCurrentUser = senderId === currentUserId;
@@ -30,15 +30,15 @@ export function formatDMUsername(
// Utility function for consistent message content formatting
export function formatDMMessageContent(
content: string,
senderId: number,
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) + "..."
const truncatedContent = content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
return prefix + truncatedContent;
}
@@ -66,7 +66,7 @@ export function useDM() {
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
try {
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
console.log(lastPlaintext);
@@ -84,10 +84,10 @@ export function useDM() {
}
// Update user state
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
unreadCount,
publicKey
@@ -102,24 +102,24 @@ export function useDM() {
// Load DM conversations when chats tab is active
const loadUsers = useCallback(async () => {
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
usersLoadedRef.current = true;
setIsLoadingUsers(true);
try {
const conversations = await fetchDMConversations(user.authToken);
// Process conversations and decrypt last messages
const dmUsersWithState: DMUser[] = await Promise.all(
conversations.map(async (conv: DMConversationResponse) => {
let lastMessageContent: string | undefined = undefined;
if (conv.lastMessage) {
try {
// Get the public key for the other user
const otherUserId = conv.lastMessage.senderId === user.currentUser?.id
? conv.lastMessage.recipientId
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
@@ -131,7 +131,7 @@ export function useDM() {
console.error("Failed to decrypt last message for user", conv.user.id, error);
}
}
return {
...conv.user,
unreadCount: conv.unreadCount,
@@ -140,10 +140,10 @@ export function useDM() {
};
})
);
setDmUsersState(dmUsersWithState);
setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user));
} catch (error) {
console.error("Failed to load DM conversations:", error);
} finally {
@@ -159,7 +159,7 @@ export function useDM() {
// Load DM history for active conversation
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
if (!user.authToken || isLoadingHistory) return;
setIsLoadingHistory(true);
try {
const messages = await fetchDMHistory(userId, user.authToken, 50);
@@ -171,7 +171,7 @@ export function useDM() {
const text = await decryptDm(env, publicKey);
const isAuthor = env.senderId !== userId;
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
decryptedMessages.push({
id: env.id,
content: text,
@@ -196,7 +196,7 @@ export function useDM() {
if (maxIncomingId > 0) {
setLastReadId(userId, maxIncomingId);
// Clear unread count
setDmUsersState(prev => prev.map(u =>
setDmUsersState(prev => prev.map(u =>
u.id === userId ? { ...u, unreadCount: 0 } : u
));
}
@@ -257,17 +257,17 @@ export function useDM() {
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
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
@@ -279,7 +279,7 @@ export function useDM() {
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) {
@@ -311,13 +311,13 @@ export function useDM() {
const msg = JSON.parse(e.data);
if (msg.type === "dmNew") {
const { senderId, recipientId, ...envelope } = msg.data;
// Update conversation list (not active conversation - that's handled by DMPanel)
if (!user.currentUser?.id) {
return;
}
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
// Update unread count and last message preview
try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
@@ -326,11 +326,11 @@ export function useDM() {
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,
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
lastMessage: formattedMessage,
publicKey
@@ -343,7 +343,7 @@ export function useDM() {
}
} else if (msg.type === "dmEdited") {
const { id, senderId, recipientId, ...envelope } = msg.data;
// Update last message preview for conversation list
if (!user.currentUser?.id) {
return;
@@ -356,10 +356,10 @@ export function useDM() {
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,
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
lastMessage: formattedMessage,
publicKey
}
@@ -371,7 +371,7 @@ export function useDM() {
}
} 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;
+108 -39
View File
@@ -9,6 +9,8 @@ import { restoreKeys } from "@/core/api/authApi";
import { API_BASE_URL } from "@/core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager";
export type ChatTabs = "chats" | "channels" | "contacts";
@@ -25,7 +27,7 @@ export interface ProfileDialogData {
}
interface ActiveDM {
userId: number;
userId: number;
username: string;
publicKey: string | null
}
@@ -61,6 +63,9 @@ interface ChatState {
pendingPanel?: MessagePanel | null;
call: CallState;
profileDialog: ProfileDialogData | null;
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
typingUsers: Map<number, string>; // userId -> username
dmTypingUsers: Map<number, boolean>;
}
export interface UserState {
@@ -84,7 +89,7 @@ interface AppState {
applyPendingPanel: () => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
// Call state
startCall: (userId: number, username: string) => void;
endCall: () => void;
@@ -99,16 +104,22 @@ interface AppState {
setRemoteVideoEnabled: (enabled: boolean) => void;
setRemoteScreenSharing: (enabled: boolean) => void;
toggleCallMinimized: () => void;
// User state
user: UserState;
setUser: (token: string, user: User) => void;
logout: () => void;
restoreUserFromStorage: () => Promise<void>;
// Profile dialog state
setProfileDialog: (data: ProfileDialogData | null) => void;
closeProfileDialog: () => void;
// Online status and typing state
updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void;
addTypingUser: (userId: number, username: string) => void;
removeTypingUser: (userId: number) => void;
setDmTypingUser: (userId: number, isTyping: boolean) => void;
}
export const useAppState = create<AppState>((set, get) => ({
@@ -146,7 +157,10 @@ export const useAppState = create<AppState>((set, get) => ({
isRemoteVideoEnabled: false,
isSharingScreen: false,
isRemoteScreenSharing: false
}
},
onlineStatuses: new Map(),
typingUsers: new Map(),
dmTypingUsers: new Map()
},
addMessage: (message: Message) => set((state) => {
// Check if message already exists to prevent duplicates
@@ -154,7 +168,7 @@ export const useAppState = create<AppState>((set, get) => ({
if (messageExists) {
return state; // Return unchanged state if message already exists
}
return {
chat: {
...state.chat,
@@ -165,7 +179,7 @@ export const useAppState = create<AppState>((set, get) => ({
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
chat: {
...state.chat,
messages: state.chat.messages.map(msg =>
messages: state.chat.messages.map(msg =>
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
)
}
@@ -206,7 +220,7 @@ export const useAppState = create<AppState>((set, get) => ({
activeDm: dm
}
})),
// User state
user: {
currentUser: null,
@@ -220,6 +234,10 @@ export const useAppState = create<AppState>((set, get) => ({
}
}));
// Initialize managers with auth token
onlineStatusManager.setAuthToken(token);
typingManager.setAuthToken(token);
// Store credentials in localStorage
try {
localStorage.setItem('authToken', token);
@@ -250,6 +268,12 @@ export const useAppState = create<AppState>((set, get) => ({
console.error('Failed to clear localStorage:', error);
}
// Cleanup managers
onlineStatusManager.setAuthToken(null);
typingManager.setAuthToken(null);
onlineStatusManager.cleanup();
typingManager.cleanup();
set(() => ({
user: {
currentUser: null,
@@ -260,7 +284,7 @@ export const useAppState = create<AppState>((set, get) => ({
restoreUserFromStorage: async () => {
try {
const token = localStorage.getItem('authToken');
if (token) {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
headers: getAuthHeaders(token)
@@ -277,6 +301,10 @@ export const useAppState = create<AppState>((set, get) => ({
}
}));
// Initialize managers with auth token
onlineStatusManager.setAuthToken(token);
typingManager.setAuthToken(token);
try {
request({
type: "ping",
@@ -296,7 +324,7 @@ export const useAppState = create<AppState>((set, get) => ({
const initialized = await initialize();
if (initialized) {
await subscribe(token);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
@@ -317,7 +345,7 @@ export const useAppState = create<AppState>((set, get) => ({
localStorage.removeItem('currentUser');
}
},
// Panel management
setActivePanel: (panel: MessagePanel | null) => set((state) => ({
chat: {
@@ -349,15 +377,15 @@ export const useAppState = create<AppState>((set, get) => ({
pendingPanel: null
}
})),
switchToPublicChat: async (chatName: string) => {
const { user, chat } = get();
if (!user.authToken) return;
// Start chat switching animation
chat.setIsSwitching(true);
// Create or get public chat panel
let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) {
@@ -368,10 +396,10 @@ export const useAppState = create<AppState>((set, get) => ({
// Reset messages for the new chat
publicChatPanel.clearMessages();
}
// Activate panel
await publicChatPanel.activate();
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
@@ -380,19 +408,19 @@ export const useAppState = create<AppState>((set, get) => ({
activeTab: "chats"
}
}));
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
},
switchToDM: async (dmData: DMPanelData) => {
const { user, chat } = get();
if (!user.authToken) return;
// Start chat switching animation
chat.setIsSwitching(true);
// Create or get DM panel
let dmPanel = chat.dmPanel;
if (!dmPanel) {
@@ -402,13 +430,13 @@ export const useAppState = create<AppState>((set, get) => ({
// Reset messages for the new DM
dmPanel.clearMessages();
}
// Set DM data
dmPanel.setDMData(dmData);
// Activate panel
await dmPanel.activate();
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
@@ -422,7 +450,7 @@ export const useAppState = create<AppState>((set, get) => ({
activeTab: "chats"
}
}));
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
},
@@ -449,7 +477,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
endCall: () => set((state) => ({
chat: {
...state.chat,
@@ -471,7 +499,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setCallStatus: (status: CallStatus) => set((state) => ({
chat: {
...state.chat,
@@ -482,7 +510,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
toggleMute: () => set((state) => ({
chat: {
...state.chat,
@@ -492,7 +520,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
toggleCallMinimize: () => set((state) => ({
chat: {
...state.chat,
@@ -524,7 +552,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({
chat: {
...state.chat,
@@ -535,7 +563,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({
chat: {
...state.chat,
@@ -545,7 +573,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
toggleVideo: () => set((state) => ({
chat: {
...state.chat,
@@ -555,7 +583,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
toggleScreenShare: () => set((state) => ({
chat: {
...state.chat,
@@ -565,7 +593,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({
chat: {
...state.chat,
@@ -575,7 +603,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
setRemoteScreenSharing: (enabled: boolean) => set((state) => ({
chat: {
...state.chat,
@@ -594,7 +622,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
})),
// Profile dialog state management
setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({
chat: {
@@ -602,11 +630,52 @@ export const useAppState = create<AppState>((set, get) => ({
profileDialog: data
}
})),
closeProfileDialog: () => set((state) => ({
chat: {
...state.chat,
profileDialog: null
}
}))
})),
// Online status and typing state management
updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({
chat: {
...state.chat,
onlineStatuses: new Map(state.chat.onlineStatuses).set(userId, { online, lastSeen })
}
})),
addTypingUser: (userId: number, username: string) => set((state) => ({
chat: {
...state.chat,
typingUsers: new Map(state.chat.typingUsers).set(userId, username)
}
})),
removeTypingUser: (userId: number) => set((state) => {
const newTypingUsers = new Map(state.chat.typingUsers);
newTypingUsers.delete(userId);
return {
chat: {
...state.chat,
typingUsers: newTypingUsers
}
};
}),
setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => {
const newDmTypingUsers = new Map(state.chat.dmTypingUsers);
if (isTyping) {
newDmTypingUsers.set(userId, true);
} else {
newDmTypingUsers.delete(userId);
}
return {
chat: {
...state.chat,
dmTypingUsers: newDmTypingUsers
}
};
})
}));
+27 -13
View File
@@ -6,6 +6,8 @@ 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";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineStatus } from "./right/OnlineStatus";
export function ProfileDialog() {
const { chat, user, closeProfileDialog } = useAppState();
@@ -27,7 +29,7 @@ export function ProfileDialog() {
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);
@@ -100,15 +102,30 @@ export function ProfileDialog() {
}
}, [isOpen]);
// Subscribe to user's online status when dialog opens
useEffect(() => {
if (isOpen && currentData?.userId && !currentData.isOwnProfile) {
// Subscribe to the user's status
onlineStatusManager.subscribe(currentData.userId);
// Cleanup function to unsubscribe when dialog closes
return () => {
if (currentData.userId) {
onlineStatusManager.unsubscribe(currentData.userId);
}
};
}
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
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) ||
@@ -138,7 +155,7 @@ export function ProfileDialog() {
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.remove('open');
dialogRef.current.classList.remove('open');
// Wait for animation to complete before closing
setTimeout(() => {
closeProfileDialog();
@@ -215,7 +232,7 @@ export function ProfileDialog() {
// Update the original data to match current data
setOriginalData(currentData);
// Close dialog with animation after successful save
triggerCloseAnimation();
} catch (error) {
@@ -236,7 +253,7 @@ export function ProfileDialog() {
if (!isOpen || !currentData) return null;
return createPortal(
<div
<div
ref={backdropRef}
className="profile-dialog-backdrop"
onClick={handleBackdropClick}
@@ -245,7 +262,7 @@ export function ProfileDialog() {
<div className="profile-dialog-content">
{/* Profile Picture */}
<div className="profile-picture-section">
<img
<img
className="profile-picture"
src={currentData.profilePicture || defaultAvatar}
alt="Profile Picture"
@@ -255,7 +272,7 @@ export function ProfileDialog() {
}}
/>
{currentData.isOwnProfile && (
<div
<div
className="profile-picture-edit-overlay"
onClick={handleProfilePictureClick}
>
@@ -279,12 +296,9 @@ export function ProfileDialog() {
)}
{/* Online Status */}
{currentData.online !== undefined && (
{currentData?.userId && (
<div className="online-status-section">
<span className={`online-indicator ${currentData.online ? "" : "offline"}`} />
<span className="status-text">
{currentData.online ? "Онлайн" : "Оффлайн"}
</span>
<OnlineStatus userId={currentData.userId} />
</div>
)}
@@ -29,7 +29,7 @@ export function ChatHeader() {
<div className="profile">
<a href="#" id="profile-open" onClick={handleProfileClick}>
<img
src={profilePictureUrl}
src={profilePictureUrl}
alt=""
id="preview1"
onError={() => setProfilePictureUrl(defaultAvatar)} />
+2 -2
View File
@@ -12,8 +12,8 @@ export function ChatTabs() {
return (
<div className="chat-tabs">
<mdui-tabs
value={chat.activeTab}
<mdui-tabs
value={chat.activeTab}
full-width
onChange={handleChange}>
<mdui-tab value="chats">
@@ -19,9 +19,9 @@ function BottomAppBar() {
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
<div style={{ flexGrow: 1 }}></div>
<mdui-button-icon
icon="logout--filled"
id="logout-btn"
<mdui-button-icon
icon="logout--filled"
id="logout-btn"
onClick={handleLogout}
title="Выйти"
></mdui-button-icon>
@@ -6,6 +6,8 @@ import { getAuthHeaders } from "@/core/api/authApi";
import { fetchUserPublicKey } from "@/core/api/dmApi";
import type { Message } from "@/core/types";
import { websocket } from "@/core/websocket";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "../right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png";
interface PublicChat {
@@ -31,7 +33,7 @@ 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" }
@@ -52,7 +54,7 @@ export function UnifiedChatsList() {
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
@@ -102,7 +104,7 @@ export function UnifiedChatsList() {
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
@@ -128,7 +130,7 @@ export function UnifiedChatsList() {
} else if (msg.type === "messageDeleted") {
const deletedMessageId = msg.data?.message_id;
let needsReload = false;
setLastMessages(prev => {
const updated = { ...prev };
publicChats.forEach(chat => {
@@ -139,7 +141,7 @@ export function UnifiedChatsList() {
});
return updated;
});
if (needsReload) {
loadLastMessages();
}
@@ -153,6 +155,23 @@ export function UnifiedChatsList() {
return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [publicChats, loadLastMessages]);
// Subscribe to online status for all DM users
useEffect(() => {
const dmUsers = allChats.filter(chat => chat.type === "dm") as DMConversation[];
// Subscribe to all DM users
dmUsers.forEach(dmUser => {
onlineStatusManager.subscribe(dmUser.id);
});
// Cleanup function to unsubscribe from all users
return () => {
dmUsers.forEach(dmUser => {
onlineStatusManager.unsubscribe(dmUser.id);
});
};
}, [allChats]);
const formatPublicChatMessage = (chatId: string): string => {
const lastMessage = lastMessages[chatId];
if (!lastMessage) {
@@ -161,12 +180,12 @@ export function UnifiedChatsList() {
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) + "..."
const content = lastMessage.content.length > maxContentLength
? lastMessage.content.substring(0, maxContentLength) + "..."
: lastMessage.content;
return prefix + content;
};
@@ -179,7 +198,7 @@ export function UnifiedChatsList() {
if (!dmConversation.publicKey) {
const authToken = useAppState.getState().user.authToken;
if (!authToken) return;
const publicKey = await fetchUserPublicKey(dmConversation.id, authToken);
if (publicKey) {
dmConversation.publicKey = publicKey;
@@ -188,7 +207,7 @@ export function UnifiedChatsList() {
return;
}
}
await switchToDM({
userId: dmConversation.id,
username: dmConversation.username,
@@ -220,9 +239,9 @@ export function UnifiedChatsList() {
{formatPublicChatMessage(chat.id)}
</span>
)}
<img
src={defaultAvatar}
alt={chat.name}
<img
src={defaultAvatar}
alt={chat.name}
slot="icon"
style={{
width: "40px",
@@ -244,20 +263,23 @@ export function UnifiedChatsList() {
<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;
}}
/>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img
src={chat.profile_picture || defaultAvatar}
alt={chat.username}
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover",
display: "block"
}}
onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar;
}}
/>
<OnlineIndicator userId={chat.id} />
</div>
{chat.unreadCount > 0 && (
<mdui-badge slot="end-icon">
{chat.unreadCount}
@@ -2,6 +2,8 @@ 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 { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "../right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png";
import SearchBar from "@/core/components/SearchBar";
@@ -51,6 +53,21 @@ export function UsernameSearch() {
};
}, [searchQuery, user.authToken]);
// Subscribe to online status for all search results
useEffect(() => {
// Subscribe to all search results
searchResults.forEach(searchUser => {
onlineStatusManager.subscribe(searchUser.id);
});
// Cleanup function to unsubscribe from all users
return () => {
searchResults.forEach(searchUser => {
onlineStatusManager.unsubscribe(searchUser.id);
});
};
}, [searchResults]);
async function handleUserClick(searchUser: SearchUser) {
if (!user.authToken) return;
@@ -133,17 +150,23 @@ export function UsernameSearch() {
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;
}}
/>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img
src={searchUser.profile_picture || defaultAvatar}
alt={searchUser.username}
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover",
display: "block"
}}
onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar;
}}
/>
<OnlineIndicator userId={searchUser.id} />
</div>
</mdui-list-item>
))}
</mdui-list>
@@ -55,13 +55,13 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if click is within crop area
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
@@ -77,16 +77,16 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
const y = e.clientY - rect.top;
const newX = Math.max(
0,
0,
Math.min(
x - dragStart.x,
x - dragStart.x,
imageRef.current.naturalWidth - cropArea.width
)
);
const newY = Math.max(
0,
0,
Math.min(
y - dragStart.y,
y - dragStart.y,
imageRef.current.naturalHeight - cropArea.height
)
);
@@ -162,7 +162,7 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
ref={canvasRef}
width={400}
height={400}
style={{
style={{
cursor: isDragging ? 'grabbing' : 'grab',
border: '1px solid #ccc',
maxWidth: '100%',
@@ -33,22 +33,22 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const initialized = await initialize();
if (initialized) {
await subscribe(user.authToken);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
}
setPushNotificationsEnabled(true);
}
} else {
await unsubscribe();
// For Electron, stop the notification receiver
if (isElectron) {
stopElectronReceiver();
}
// Call API to unsubscribe on server (for web browsers)
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
@@ -71,63 +71,63 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
</div>
<div id="settings-menu">
<mdui-list>
<mdui-list-item
icon="notifications--filled"
rounded
<mdui-list-item
icon="notifications--filled"
rounded
active={activePanel === "notifications-settings"}
onClick={() => handlePanelChange("notifications-settings")}
style={{ cursor: "pointer" }}
>
Уведомления
</mdui-list-item>
<mdui-list-item
icon="palette--filled"
rounded
<mdui-list-item
icon="palette--filled"
rounded
active={activePanel === "appearance-settings"}
onClick={() => handlePanelChange("appearance-settings")}
style={{ cursor: "pointer" }}
>
Внешний вид
</mdui-list-item>
<mdui-list-item
icon="security--filled"
rounded
<mdui-list-item
icon="security--filled"
rounded
active={activePanel === "security-settings"}
onClick={() => handlePanelChange("security-settings")}
style={{ cursor: "pointer" }}
>
Безопасность
</mdui-list-item>
<mdui-list-item
icon="language--filled"
rounded
<mdui-list-item
icon="language--filled"
rounded
active={activePanel === "language-settings"}
onClick={() => handlePanelChange("language-settings")}
style={{ cursor: "pointer" }}
>
Язык
</mdui-list-item>
<mdui-list-item
icon="storage--filled"
rounded
<mdui-list-item
icon="storage--filled"
rounded
active={activePanel === "storage-settings"}
onClick={() => handlePanelChange("storage-settings")}
style={{ cursor: "pointer" }}
>
Хранилище
</mdui-list-item>
<mdui-list-item
icon="help--filled"
rounded
<mdui-list-item
icon="help--filled"
rounded
active={activePanel === "help-settings"}
onClick={() => handlePanelChange("help-settings")}
style={{ cursor: "pointer" }}
>
Помощь
</mdui-list-item>
<mdui-list-item
icon="info--filled"
rounded
<mdui-list-item
icon="info--filled"
rounded
active={activePanel === "about-settings"}
onClick={() => handlePanelChange("about-settings")}
style={{ cursor: "pointer" }}
@@ -139,7 +139,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3>
{pushSupported && (
<mdui-switch
<mdui-switch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
>
@@ -151,7 +151,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<mdui-switch>Уведомления о статусе</mdui-switch>
<mdui-switch checked>Email уведомления</mdui-switch>
</div>
<div id="appearance-settings" className={`settings-panel ${activePanel === "appearance-settings" ? "active" : ""}`}>
<h3>Внешний вид</h3>
<mdui-select label="Тема" variant="outlined">
@@ -165,14 +165,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<mdui-menu-item value="large">Большой</mdui-menu-item>
</mdui-select>
</div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<h3>Безопасность</h3>
<mdui-button variant="outlined">Изменить пароль</mdui-button>
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
<mdui-switch>Автоматический выход</mdui-switch>
</div>
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
<h3>Язык</h3>
<mdui-select label="Выберите язык" variant="outlined">
@@ -181,21 +181,21 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<mdui-menu-item value="es">Español</mdui-menu-item>
</mdui-select>
</div>
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
<h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value={25}></mdui-linear-progress>
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div>
<div id="help-settings" className={`settings-panel ${activePanel === "help-settings" ? "active" : ""}`}>
<h3>Помощь</h3>
<mdui-button variant="outlined">Руководство пользователя</mdui-button>
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button>
<mdui-button variant="outlined">FAQ</mdui-button>
</div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
<h3>О приложении</h3>
<p>Версия: 1.0.0</p>
@@ -20,22 +20,26 @@ interface ChatInputWrapperProps {
onCloseEdit?: () => void;
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
onTyping?: () => void;
onStopTyping?: () => void;
}
export function ChatInputWrapper(
{
onSendMessage,
onSaveEdit,
replyTo,
replyToVisible,
{
onSendMessage,
onSaveEdit,
replyTo,
replyToVisible,
onClearReply,
onCloseReply,
editingMessage,
editVisible = false,
onClearEdit,
onCloseReply,
editingMessage,
editVisible = false,
onClearEdit,
onCloseEdit,
onProvideFileAdder,
messagePanelRef
messagePanelRef,
onTyping,
onStopTyping
}: ChatInputWrapperProps
) {
const [message, setMessage] = useState("");
@@ -73,7 +77,7 @@ export function ChatInputWrapper(
if (chatInputWrapperRef.current && messagePanelRef?.current) {
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
const panelRect = messagePanelRef.current.getBoundingClientRect();
// Position menu 10px from message panel edge and 10px above the chat input
// The animation will start 30px below this position
setEmojiMenuPosition({
@@ -91,6 +95,17 @@ export function ChatInputWrapper(
setMessage(prev => prev + emoji);
};
function handleTyping() {
if (onTyping) {
onTyping();
}
};
function handleMessageChange(value: string) {
setMessage(value);
handleTyping();
};
async function handleSubmit(e: React.FormEvent | Event) {
e.preventDefault();
const hasText = Boolean(message.trim());
@@ -111,6 +126,8 @@ export function ChatInputWrapper(
setMessage("");
setAttachmentsVisible(false);
if (onClearReply) onClearReply();
// Stop typing indicator when message is sent
if (onStopTyping) onStopTyping();
}
}
};
@@ -190,13 +207,13 @@ export function ChatInputWrapper(
className="emoji-btn" />
</div>
<RichTextArea
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
text={message}
rows={1}
onTextChange={(value) => setMessage(value)}
onTextChange={handleMessageChange}
onEnter={handleSubmit} />
<div className="buttons">
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
@@ -211,7 +228,7 @@ export function ChatInputWrapper(
<div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
</MaterialDialog>
<EmojiMenu
isOpen={emojiMenuOpen}
onClose={() => setEmojiMenuOpen(false)}
@@ -20,9 +20,9 @@ interface ChatMessagesProps {
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { user } = useAppState();
// Use prop messages (panels provide their own messages)
// Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
@@ -89,13 +89,13 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
async function handleReactionClick(messageId: number, emoji: string) {
if (!user.authToken) return;
try {
if (isDm) {
// For DM messages, we need to find the dm_envelope_id from the message
const message = messages.find(m => m.id === messageId);
const dmEnvelopeId = message?.runtimeData?.dmEnvelope?.id;
if (dmEnvelopeId) {
await request<AddDmReactionRequest["data"]>({
type: "addDmReaction",
@@ -131,8 +131,8 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
<Message
key={message.id}
message={message}
isAuthor={isDm ?
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
isAuthor={isDm ?
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(message.username === user.currentUser?.username)
}
onContextMenu={handleContextMenu}
@@ -142,7 +142,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
))}
{children}
</div>
<MaterialDialog
headline="Удалить сообщение?"
@@ -151,13 +151,13 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
<mdui-button slot="action" variant="tonal" onClick={() => setDeleteDialogOpen(false)}>Отменить</mdui-button>
<mdui-button slot="action" variant="filled" onClick={confirmDelete}>Удалить</mdui-button>
</MaterialDialog>
{/* Context Menu */}
{contextMenu.message && (
<MessageContextMenu
message={contextMenu.message}
isAuthor={isDm ?
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
isAuthor={isDm ?
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(contextMenu.message.username === user.currentUser?.username)
}
onEdit={handleEdit}
+11 -11
View File
@@ -38,13 +38,13 @@ export function EmojiMenu(props: EmojiMenuProps) {
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
// Find which category is currently visible
for (const [categoryName, element] of categoryRefs.current) {
if (element) {
const rect = element.getBoundingClientRect();
const containerRect = scrollRef.current.getBoundingClientRect();
// Check if category header is in view
if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) {
if (activeCategory !== categoryName) {
@@ -60,9 +60,9 @@ export function EmojiMenu(props: EmojiMenuProps) {
function scrollToCategory(categoryName: string) {
const element = categoryRefs.current.get(categoryName);
if (element && scrollRef.current) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
@@ -72,7 +72,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
if (tabElement && tabsRef.current) {
const tabsRect = tabsRef.current.getBoundingClientRect();
const tabRect = tabElement.getBoundingClientRect();
// Check if tab is outside the visible area
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
tabElement.scrollIntoView({
@@ -116,7 +116,7 @@ export function EmojiMenu(props: EmojiMenuProps) {
return (
<div
<div
ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
style={mode === "standalone" && position ? {
@@ -146,17 +146,17 @@ export function EmojiMenu(props: EmojiMenuProps) {
))}
</div>
</div>
<div
<div
ref={scrollRef}
className="emoji-grid"
onScroll={handleScroll}
>
{EMOJI_CATEGORIES.map((category) => {
const emojis = category.name === "recent" ? recentEmojis : category.emojis;
return (
<div
<div
key={category.name}
ref={(el) => {
if (el) categoryRefs.current.set(category.name, el);
+30 -28
View File
@@ -84,7 +84,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
// Update existing reactions and add new ones
setVisibleReactions(prev => {
const updated = [...prev];
// Update existing reactions
uniqueReactions.forEach(reaction => {
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
@@ -97,7 +97,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
}
}
});
return updated;
});
}, [reactions]);
@@ -112,7 +112,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
{visibleReactions.map((reaction, index) => {
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
const isAnimating = animatingReactions.has(reaction.emoji);
return (
<button
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
@@ -140,9 +140,9 @@ interface MessageProps {
}
interface Rect {
left: number;
top: number;
width: number;
left: number;
top: number;
width: number;
height: number
}
@@ -200,12 +200,12 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
console.warn("Conditions not met")
return null;
}
// Check if already decrypted
if (decryptedFiles.has(file.path)) {
return decryptedFiles.get(file.path) || null;
}
try {
// no-op decrypt indicator removed from UI
// Fetch encrypted file
@@ -213,32 +213,32 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
headers: getAuthHeaders(user.authToken!)
});
if (!response.ok) throw new Error("Failed to fetch file");
const encryptedData = await response.arrayBuffer();
// Get current user's keys
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Derive shared secret with the recipient's public key
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
// Derive wrapping key using the salt from the DM envelope
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Unwrap the message key
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
// Decrypt the file using the message key
const iv = new Uint8Array(encryptedData, 0, 12);
const ciphertext = new Uint8Array(encryptedData, 12);
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
// Create blob URL for download
const blob = new Blob([decrypted.buffer as ArrayBuffer]);
const url = URL.createObjectURL(blob);
updateDecryptedFiles(draft => {
draft.set(file.path, url);
});
@@ -390,12 +390,14 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
async function handleProfileClick() {
if (!user.authToken || !message.username) return;
try {
const userProfile = await fetchUserProfile(user.authToken, message.username);
if (userProfile) {
setProfileDialog({
...userProfile,
userId: userProfile.id,
memberSince: userProfile.created_at,
isOwnProfile: false
});
}
@@ -421,10 +423,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const emojiRegex = /^[\p{Emoji}]+$/u;
return messageText.length > 0 && emojiRegex.test(messageText) && messageText.length <= 4; // Most emojis are 1-4 characters
}, [messageText]);
return (
<>
<div
<div
className={`message ${isAuthor ? "sent" : "received"} ${isEmojiMessage ? "emoji-message" : ""} ${isSingleEmojiMessage ? "single-emoji" : ""}`}
data-id={message.id}
onContextMenu={handleContextMenu}
@@ -444,7 +446,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
<div className="message-inner">
{!isAuthor && !isDm && !isSingleEmojiMessage && (
<div
<div
className="message-username"
onClick={handleProfileClick}>
{message.username}
@@ -474,11 +476,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
<div className="attachment" key={idx}>
{isImage ? (
<div className="image-wrapper">
<img
<img
ref={(el) => {
if (el) imageRefs.current.set(file.path, el);
}}
src={imageSrc}
src={imageSrc}
alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)}
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
@@ -491,8 +493,8 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
)}
</div>
) : (
<a
href="#"
<a
href="#"
onClick={async (e) => {
e.preventDefault();
await downloadFile(file);
@@ -512,7 +514,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
</mdui-list>
)}
<Reactions
<Reactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
@@ -521,11 +523,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
)}
{isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator">
{message.runtimeData.sendingState.status === 'sending' && (
@@ -545,7 +547,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && createPortal(
<div
<div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
onClick={closeFullscreen}>
<img
@@ -21,12 +21,12 @@ export interface ContextMenuState {
position: Size2D;
}
export function MessageContextMenu({
message,
isAuthor,
onEdit,
onReply,
onDelete,
export function MessageContextMenu({
message,
isAuthor,
onEdit,
onReply,
onDelete,
onRetry,
onReactionClick,
position,
@@ -42,7 +42,7 @@ export function MessageContextMenu({
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
const [expandUpward, setExpandUpward] = useState(false);
const [contextMenuHeight, setContextMenuHeight] = useState<number | null>(null);
// Refs for measuring actual dimensions
const wrapperRef = useRef<HTMLDivElement>(null);
const reactionBarRef = useRef<HTMLDivElement>(null);
@@ -92,13 +92,13 @@ export function MessageContextMenu({
y = viewportHeight - sharedRect.height;
animation = 'entering-up';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
setReactionBarPosition(reactionPosition);
}
});
return () => cancelAnimationFrame(frameId);
}
}, [isOpen, position, isAuthor]);
@@ -146,7 +146,7 @@ export function MessageContextMenu({
// Set appropriate closing animation based on opening animation
const closingAnimation = animationClass.replace('entering', 'closing');
setAnimationClass(closingAnimation);
// Wait for animation to complete before calling onOpenChange
setTimeout(() => {
onOpenChange(false);
@@ -229,7 +229,7 @@ export function MessageContextMenu({
// Measure the actual dimensions of the reaction bar content
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
const wrapperRect = wrapperRef.current.getBoundingClientRect();
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
setContextMenuHeight(wrapperRect.height);
@@ -257,7 +257,7 @@ export function MessageContextMenu({
}
return isOpen && (
<div
<div
ref={wrapperRef}
className={`context-menu-wrapper ${animationClass}`}
style={{
@@ -267,7 +267,7 @@ export function MessageContextMenu({
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}>
{/* Reaction Bar */}
<div
ref={reactionBarRef}
@@ -303,8 +303,8 @@ export function MessageContextMenu({
</button>
</div>
) : (
<div
ref={emojiMenuRef}
<div
ref={emojiMenuRef}
className="emoji-menu-wrapper">
<EmojiMenu
isOpen={true}
@@ -317,12 +317,12 @@ export function MessageContextMenu({
</div>
{/* Context Menu */}
<div
<div
ref={contextMenuRef}
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
{actions.map((action, i) => (
action.show && (
<div
<div
className="context-menu-item"
onClick={action.onClick}
key={i}
@@ -1,20 +1,49 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { useAppState } from "@/pages/chat/state";
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
import { ProfileDialog } from "../ProfileDialog";
import { ProfileDialog } from "@/pages/chat/ui/ProfileDialog";
import { setGlobalMessageHandler } from "@/core/websocket";
import type { Message, WebSocketMessage } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png";
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
import type { DMPanel } from "./panels/DMPanel";
import { DMPanel } from "./panels/DMPanel";
import useCall from "@/pages/chat/hooks/useCall";
import { TypingIndicator } from "./TypingIndicator";
import { OnlineStatus } from "./OnlineStatus";
import { typingManager } from "@/core/typingManager";
import { PublicChatPanel } from "./panels/PublicChatPanel";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
}
function ChatHeaderText({ panel }: { panel: MessagePanel | null }) {
const { chat, user } = useAppState();
const otherTypingUsers = useMemo(() => {
return Array
.from(chat.typingUsers.entries())
.filter(([userId, username]) => userId !== user.currentUser?.id && username)
.map(([, username]) => username!);
}, [chat.typingUsers, user.currentUser?.id]);
let content: ReactNode;
if (panel instanceof DMPanel) {
const recipientId = panel.getRecipientId()!;
const isTyping = chat.dmTypingUsers.get(recipientId);
content = isTyping ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
} else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) {
content = <TypingIndicator typingUsers={otherTypingUsers} />;
} else {
return null;
}
return <div>{content}</div>;
}
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, chat, setProfileDialog } = useAppState();
const messagePanelRef = useRef<HTMLDivElement>(null);
@@ -61,12 +90,12 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
useEffect(() => {
if (panel) {
setPanelState(panel.getState());
// Store the handler for cleanup
panel.onStateChange = (newState: MessagePanelState) => {
setPanelState(newState);
};
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler((message: WebSocketMessage<any>) => panel.handleWebSocketMessage(message));
@@ -75,7 +104,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
setPanelState(null);
setGlobalMessageHandler(null);
}
return () => {
if (panel) {
if (panel.onStateChange) {
@@ -93,11 +122,11 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
useEffect(() => {
if (chat.isSwitching) {
setSwitchOut(true);
// Use animation event listeners instead of hardcoded delays
function handleAnimationEnd(event: Event) {
const animationEvent = event as AnimationEvent;
if (animationEvent.animationName === 'fadeOutUp') {
// Apply pending panel exactly at the boundary between animations
applyPendingPanel();
@@ -109,10 +138,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
chat.setIsSwitching(false);
}
};
// Add event listener to document to catch all animation events
document.addEventListener('animationend', handleAnimationEnd);
// Cleanup function
return () => {
document.removeEventListener('animationend', handleAnimationEnd);
@@ -123,9 +152,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
// Load messages when panel changes and animation is not running
useEffect(() => {
if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return;
const panelState = chat.activePanel.getState();
if (panelState.messages.length === 0 && !panelState.isLoading) {
chat.activePanel.loadMessages();
}
@@ -151,7 +180,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const id = requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "end" });
});
return () => cancelAnimationFrame(id);
}
@@ -164,7 +193,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const dmPanel = panel as DMPanel;
const userId = dmPanel.getDMUserId();
const username = dmPanel.getDMUsername();
if (userId && username) {
initiateCall(userId, username);
}
@@ -173,7 +202,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
async function handleProfileClick() {
if (!panel) return;
try {
const profileData = await panel.getProfile();
if (profileData) {
@@ -186,9 +215,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div
<div
ref={messagePanelRef}
className="chat-main"
className="chat-main"
id="chat-inner"
onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return;
@@ -223,9 +252,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
dragCounterRef.current = 0;
} : undefined}>
<div className="chat-header">
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
<img
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }}
@@ -233,14 +262,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<p>
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
{panelState ? (
panelState.online ? "Online" : "Offline"
) : (
"Выберите чат, чтобы начать переписку"
)}
</p>
<ChatHeaderText panel={panel} />
</div>
{panel?.isDm() && (
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
@@ -250,10 +272,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
{panelState?.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
@@ -261,9 +283,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div>
</div>
) : panelState && panel ? (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
@@ -288,10 +310,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</ChatMessages>
) : (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
@@ -302,10 +324,10 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
{panel && (
<>
<AnimatedOpacity
visible={isDragging}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
<AnimatedOpacity
visible={isDragging}
className="file-overlay"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}>
<div className="file-overlay-wrapper">
<div className="file-overlay-inner">
@@ -314,12 +336,13 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div>
</div>
</AnimatedOpacity>
<ChatInputWrapper
<ChatInputWrapper
onSendMessage={(text, files) => {
panel.handleSendMessage(text, replyTo?.id, files);
setReplyTo(null);
}}
}}
onSaveEdit={(content) => {
if (editMessage) {
panel.handleEditMessage(editMessage.id, content);
@@ -354,11 +377,27 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
}}
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
messagePanelRef={messagePanelRef}
onTyping={() => {
if (panel.isDm()) {
const dmPanel = panel as DMPanel;
dmPanel.handleTyping();
} else {
typingManager.sendTyping();
}
}}
onStopTyping={() => {
if (panel.isDm()) {
const dmPanel = panel as DMPanel;
typingManager.stopDmTypingOnMessage(dmPanel.getRecipientId()!);
} else {
typingManager.stopTypingOnMessage();
}
}}
/>
</>
)}
</div>
{/* Profile Dialog */}
<ProfileDialog />
</div>
@@ -0,0 +1,29 @@
/**
* @fileoverview Online indicator component for profile pictures
* @description Shows a small dot at the bottom right of profile pictures to indicate online status
* @author Cursor
* @version 1.0.0
*/
import { useAppState } from "@/pages/chat/state";
interface OnlineIndicatorProps {
userId: number;
className?: string;
}
export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) {
const { chat } = useAppState();
const status = chat.onlineStatuses.get(userId);
// Only show indicator when user is online
if (!status || !status.online) {
return null;
}
return (
<div className={`online-indicator ${className}`}>
<div className="indicator-dot online"></div>
</div>
);
}
@@ -0,0 +1,53 @@
/**
* @fileoverview Online status component for showing user online status
* @description Displays online/offline status with last seen timestamp
* @author Cursor
* @version 1.0.0
*/
import { useAppState } from "@/pages/chat/state";
interface OnlineStatusProps {
userId: number;
showLastSeen?: boolean;
}
export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) {
const { chat, user } = useAppState();
const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : chat.onlineStatuses.get(userId);
function formatLastSeen(lastSeen: string): string {
const date = new Date(lastSeen);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) {
return "только что";
} else if (diffMins < 60) {
return `${diffMins} мин. назад`;
} else if (diffHours < 24) {
return `${diffHours} ч. назад`;
} else if (diffDays < 7) {
return `${diffDays} дн. назад`;
} else {
return date.toLocaleDateString();
}
}
return (
<div className="online-status">
<div className={`status-dot ${status?.online ? "online" : "offline"}`}></div>
<span className="status-text">
{!status ? "Загрузка..." : status?.online ? "В сети" : "Не в сети"}
</span>
{showLastSeen && status && !status.online && (
<span className="last-seen">
{formatLastSeen(status.lastSeen)}
</span>
)}
</div>
);
}
@@ -3,6 +3,6 @@ import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() {
const { chat } = useAppState();
return <MessagePanelRenderer panel={chat.activePanel} />
}
@@ -0,0 +1,36 @@
/**
* @fileoverview Typing indicator component for showing who is typing
* @description Displays a list of users who are currently typing
* @author Cursor
* @version 1.0.0
*/
import { useMemo } from "react";
interface TypingIndicatorProps {
typingUsers: string[]; // Array of usernames who are typing
}
export function TypingIndicator({ typingUsers }: TypingIndicatorProps) {
// Format the typing text based on number of users
const typingText = useMemo(() => {
switch (typingUsers.length) {
case 0: return "печатает...";
case 1: return `${typingUsers[0]} печатает...`;
case 2: return `${typingUsers[0]} и ${typingUsers[1]} печатают...`;
default: return `${typingUsers[0]}, ${typingUsers[1]} и еще ${typingUsers.length - 2} печатают...`;
}
}, [typingUsers]);
return (
<div className="typing-indicator">
<div className="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
<span className="typing-text">{typingText}</span>
</div>
);
}
@@ -8,11 +8,11 @@ import { id } from "@/utils/utils";
export function CallWindow() {
const { chat, toggleCallMinimize, user } = useAppState();
const { call } = chat;
const {
acceptCall,
rejectCall,
remoteAudioRef,
endCall,
const {
acceptCall,
rejectCall,
remoteAudioRef,
endCall,
toggleMute,
toggleVideo,
toggleScreenShare,
@@ -42,7 +42,7 @@ export function CallWindow() {
useEffect(() => {
let interval: NodeJS.Timeout;
if (call.status === "active" && call.startTime) {
interval = setInterval(() => {
setCallDuration(Math.floor((Date.now() - call.startTime!) / 1000));
@@ -185,7 +185,7 @@ export function CallWindow() {
autoPlay
playsInline
controls />
{shouldRender && (
<div
className={`call-window ${(call.isActive ? call.isMinimized : wasMinimized) ? "minimized" : "maximized"} ${isDragging ? "dragging" : ""} ${getGradientClass()} ${isVisible ? "visible" : "hidden"}`}
@@ -209,13 +209,13 @@ export function CallWindow() {
>
<div className="call-header">
<div className="window-controls">
<mdui-button-icon
onClick={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn"
<mdui-button-icon
onClick={toggleCallMinimize}
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
className="window-control-btn"
/>
</div>
<div className="call-header-info">
<h3 className="username">{remoteUsername}</h3>
<p className="status">{getStatusText()}</p>
@@ -235,7 +235,7 @@ export function CallWindow() {
{/* Main screen share area - takes most space when active */}
<div className="screen-share-area">
{/* Local screen share */}
<div
<div
className="video-tile screen-share-tile local-screen-share"
style={{ display: call.isSharingScreen ? "flex" : "none" }}>
<video
@@ -246,9 +246,9 @@ export function CallWindow() {
muted />
<div className="tile-label">Your Screen</div>
</div>
{/* Remote screen share */}
<div
<div
className="video-tile screen-share-tile remote-screen-share"
style={{ display: call.isRemoteScreenSharing ? "flex" : "none" }}>
<video
@@ -46,7 +46,7 @@ export function MinimizedCallBar() {
<span className="status">{getStatusText()}</span>
</div>
</div>
<div className="call-actions" onClick={(e) => e.stopPropagation()}>
{call.status === "calling" && !call.isInitiator ? (
<mdui-button-icon onClick={endCall} icon="call_end" />
@@ -1,7 +1,7 @@
import { MessagePanel } from "./MessagePanel";
import {
fetchDMHistory,
decryptDm,
import {
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
sendDmWithFiles,
editDmEnvelope,
@@ -11,6 +11,8 @@ import { fetchUserProfile } from "@/core/api/profileApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager";
export interface DMPanelData {
userId: number;
@@ -34,13 +36,25 @@ export class DMPanel extends MessagePanel {
return true;
}
getRecipientId(): number | null {
return this.dmData?.userId || null;
}
async activate(): Promise<void> {
// Don't load messages immediately during activation to prevent animation freeze
// Messages will be loaded after the animation completes
// Subscribe to recipient's online status
if (this.dmData?.userId) {
onlineStatusManager.subscribe(this.dmData.userId);
}
}
deactivate(): void {
// DM doesn't need special cleanup
// Unsubscribe from recipient's online status
if (this.dmData?.userId) {
onlineStatusManager.unsubscribe(this.dmData.userId);
}
}
clearMessages(): void {
@@ -51,9 +65,9 @@ export class DMPanel extends MessagePanel {
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await decryptDm(env, this.dmData!.publicKey);
const username = formatDMUsername(
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
env.senderId,
env.recipientId,
this.currentUser.currentUser?.id!,
this.dmData!.username
);
@@ -132,10 +146,10 @@ export class DMPanel extends MessagePanel {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try {
const payload: DmEncryptedJSON = {
type: "text",
data: {
content: content.trim(),
const payload: DmEncryptedJSON = {
type: "text",
data: {
content: content.trim(),
reply_to_id: replyToId ?? undefined
}
}
@@ -179,12 +193,12 @@ export class DMPanel extends MessagePanel {
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
if (response.type === "dmNew" && this.dmData) {
const envelope = response.data;
// If this is for the active DM conversation
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
try {
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
// Check if this is a confirmation of a message we sent
const isOurMessage = envelope.senderId !== this.dmData.userId;
if (isOurMessage) {
@@ -197,7 +211,7 @@ export class DMPanel extends MessagePanel {
}
}
}
this.addMessage(dmMsg);
// Update last read if it's from the other user
@@ -214,17 +228,17 @@ export class DMPanel extends MessagePanel {
try {
// Decrypt new content in-place
const plaintext = await decryptDm(
{
id,
senderId: 0,
recipientId: 0,
iv,
ciphertext,
salt,
iv2,
wrappedMk,
timestamp: new Date().toISOString()
},
{
id,
senderId: 0,
recipientId: 0,
iv,
ciphertext,
salt,
iv2,
wrappedMk,
timestamp: new Date().toISOString()
},
this.dmData.publicKey
);
let content = plaintext;
@@ -254,6 +268,11 @@ export class DMPanel extends MessagePanel {
// Reset for DM switching
reset(): void {
// Unsubscribe from current recipient's status before switching
if (this.dmData?.userId) {
onlineStatusManager.unsubscribe(this.dmData.userId);
}
this.dmData = null;
this.messagesLoaded = false;
this.clearMessages();
@@ -280,6 +299,13 @@ export class DMPanel extends MessagePanel {
return this.dmData?.username || null;
}
// Handle typing in DM
handleTyping(): void {
if (this.dmData?.userId) {
typingManager.sendDmTyping(this.dmData.userId);
}
}
// Helper functions for localStorage
private getLastReadId(userId: number): number {
try {
@@ -298,10 +324,10 @@ export class DMPanel extends MessagePanel {
async handleDeleteMessage(messageId: number): Promise<void> {
if (!this.currentUser.authToken || !this.dmData) return;
// Remove message immediately from UI
this.deleteMessageImmediately(messageId);
// Fire and forget server deletion; UI already updated
await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken);
}
@@ -322,14 +348,14 @@ export class DMPanel extends MessagePanel {
console.error("Failed to edit DM:", e);
});
}
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,
@@ -347,10 +373,10 @@ export class DMPanel extends MessagePanel {
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
const messages = this.getMessages();
const messageIndex = messages.findIndex(msg =>
const messageIndex = messages.findIndex(msg =>
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
);
if (messageIndex !== -1) {
const updatedMessage = { ...messages[messageIndex] };
updatedMessage.reactions = reactions;
@@ -89,7 +89,7 @@ export abstract class MessagePanel {
protected updateMessageReactions(messageId: number, reactions: any[]): void {
this.updateState({
messages: this.state.messages.map(msg =>
messages: this.state.messages.map(msg =>
msg.id === messageId ? { ...msg, reactions } : msg
)
});
@@ -125,7 +125,7 @@ export abstract class MessagePanel {
}
// ========== PUBLIC API ==========
// Event handlers
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
this.sendMessageWithImmediateDisplay(content, replyToId, files);
@@ -136,10 +136,10 @@ export abstract class MessagePanel {
if (!message?.runtimeData?.sendingState?.retryData) return;
const { content, replyToId, files } = message.runtimeData.sendingState.retryData;
// Create new temp ID for retry
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
// Update status back to sending and create new temp message
const retryMessage: Message = {
...message,
@@ -184,7 +184,7 @@ export abstract class MessagePanel {
// Clear the timeout since we're handling the failure immediately
clearTimeout(timeoutId);
this.pendingMessages.delete(tempId);
// Update message to failed state directly
this.updateState({
messages: this.state.messages.map(msg => {
@@ -211,7 +211,7 @@ export abstract class MessagePanel {
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingMessages.delete(tempId);
// Replace temporary message with confirmed one
this.updateState({
messages: this.state.messages.map(msg => {
@@ -249,7 +249,7 @@ export abstract class MessagePanel {
}
// ========== PRIVATE METHODS ==========
// Create and display message immediately with sending state
private async sendMessageWithImmediateDisplay(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!content.trim() && files.length === 0) return;
@@ -326,7 +326,7 @@ export abstract class MessagePanel {
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingMessages.delete(tempId);
// Update message to failed state
this.updateState({
messages: this.state.messages.map(msg => {
@@ -70,7 +70,7 @@ export class PublicChatPanel extends MessagePanel {
if (files.length === 0) {
const response = await request({
data: {
content: content.trim(),
content: content.trim(),
reply_to_id: replyToId ?? null
},
credentials: {
@@ -86,7 +86,7 @@ export class PublicChatPanel extends MessagePanel {
const form = new FormData();
form.append("payload", JSON.stringify({
content: content.trim(),
reply_to_id: replyToId ?? null
reply_to_id: replyToId ?? null
} satisfies SendMessageRequest["data"]));
for (const f of files) form.append("files", f, f.name);
const res = await fetch(`${API_BASE_URL}/send_message`, {
@@ -119,7 +119,7 @@ export class PublicChatPanel extends MessagePanel {
case 'newMessage':
if (response.data) {
const newMsg = response.data;
// Check if this is a confirmation of a message we sent
const isOurMessage = newMsg.username === this.currentUser.currentUser?.username;
if (isOurMessage) {
@@ -132,7 +132,7 @@ export class PublicChatPanel extends MessagePanel {
}
}
}
this.addMessage(newMsg);
}
break;
@@ -185,18 +185,18 @@ export class PublicChatPanel extends MessagePanel {
async handleDeleteMessage(id: number): Promise<void> {
// Remove message immediately from UI
this.deleteMessageImmediately(id);
// Fire and forget server deletion; UI already updated
await request({
type: "deleteMessage",
data: { message_id: id },
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken!
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken!
}
});
}
async getProfile(): Promise<ProfileDialogData | null> {
return {
username: "Общий чат",
@@ -13,7 +13,7 @@ export default function DownloadAppPage() {
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<mdui-button>Скачать на GitHub</mdui-button>
</a>
<p>
Если возникнут сложности или есть вопросы, нажмите кнопку!
</p>
+20 -20
View File
@@ -68,13 +68,13 @@ export default function HomePage() {
Безопасный мессенджер с открытым исходным кодом
</h2>
<p className="hero-description">
FromChat это полностью открытый мессенджер с end-to-end шифрованием,
FromChat это полностью открытый мессенджер с end-to-end шифрованием,
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
</p>
<div className="hero-actions">
{openBtn}
{!isMobile && <mdui-button
variant="outlined"
variant="outlined"
onClick={() => navigate("/register")}
>
Зарегистрироваться
@@ -126,22 +126,22 @@ export default function HomePage() {
</div>
<h4>End-to-End Шифрование</h4>
<p>
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
Только вы и получатель можете прочитать сообщения.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="code" />
</div>
<h4>100% открытый код</h4>
<p>
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
внести изменения или развернуть свой сервер.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="attach_file" />
@@ -152,36 +152,36 @@ export default function HomePage() {
В общем чате шифрования нет, так как ваши сообщения могут читать все пользователи FromChat.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="notifications" />
</div>
<h4>Уведомления</h4>
<p>
Получайте push-уведомления в браузере и настольном приложении.
Получайте push-уведомления в браузере и настольном приложении.
Никогда не пропустите важное сообщение.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="edit" />
</div>
<h4>Редактирование</h4>
<p>
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
для лучшего контекста общения.
</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<mdui-icon name="computer" />
</div>
<h4>Кроссплатформенность</h4>
<p>
Работает в браузере и как настольное приложение для Windows,
Работает в браузере и как настольное приложение для Windows,
macOS и Linux. Единый интерфейс везде.
</p>
</div>
@@ -194,15 +194,15 @@ export default function HomePage() {
<div className="download-content">
<h3>Скачайте приложение</h3>
<p>
Для лучшего опыта используйте настольное приложение с поддержкой
Для лучшего опыта используйте настольное приложение с поддержкой
уведомлений и автономной работы.
</p>
<div className="download-buttons">
{!isMobile ? (
<>
<a
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
target="_blank"
<a
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
target="_blank"
rel="noopener noreferrer"
>
<mdui-button variant="filled">
@@ -239,13 +239,13 @@ export default function HomePage() {
</mdui-button>
) : (
<>
<mdui-button
variant="filled"
<mdui-button
variant="filled"
onClick={() => navigate("/register")}>
Создать аккаунт
</mdui-button>
<mdui-button
variant="outlined"
<mdui-button
variant="outlined"
onClick={() => navigate("/login")}>
Войти
</mdui-button>
+81 -81
View File
@@ -6,7 +6,7 @@
color: $color-dark-on-background;
font-family: 'Montserrat', sans-serif;
position: relative;
&::before {
content: '';
position: fixed;
@@ -14,27 +14,27 @@
left: 0;
right: 0;
bottom: 0;
background:
background:
radial-gradient(circle at 20% 80%, rgba($color-dark-primary, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.3) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.2) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
// Cascaded styles for all child elements
* {
position: relative;
z-index: 1;
}
// Container styles
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
// Header styles
.homepage-header {
padding: 1rem 0;
@@ -46,12 +46,12 @@
top: 0;
z-index: 1000;
transition: all 0.3s ease;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.logo {
h1 {
font-size: 2rem;
@@ -63,12 +63,12 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.tagline {
font-size: 0.9rem;
}
}
.header-nav {
display: flex;
align-items: center;
@@ -80,7 +80,7 @@
}
}
}
// Hero section
.hero {
padding: 4rem 0;
@@ -88,11 +88,11 @@
align-items: center;
min-height: 80vh;
margin-top: 0;
.hero-content {
flex: 1;
max-width: 600px;
.hero-title {
font-size: 3.5rem;
font-weight: 800;
@@ -105,36 +105,36 @@
text-shadow: 0 0 30px rgba($color-dark-primary, 0.5);
animation: neonGlow 3s ease-in-out infinite alternate;
}
.hero-description {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.hero-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
}
.hero-visual {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
.chat-preview {
perspective: 1000px;
.chat-window {
background: rgba($color-dark-surface-container, 0.95);
border-radius: 20px;
padding: 1.5rem;
box-shadow:
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.5),
0 0 20px rgba($color-dark-primary, 0.3),
inset 0 1px 0 rgba($color-dark-primary, 0.2);
@@ -143,7 +143,7 @@
max-width: 400px;
width: 100%;
border: 1px solid rgba($color-dark-primary, 0.3);
.chat-header {
display: flex;
justify-content: space-between;
@@ -151,12 +151,12 @@
padding-bottom: 1rem;
border-bottom: 1px solid rgba($color-dark-primary, 0.3);
margin-bottom: 1rem;
.chat-title {
font-weight: 600;
font-size: 1.1rem;
}
.online-indicator {
color: $color-dark-primary;
font-size: 0.8rem;
@@ -164,27 +164,27 @@
animation: pulse 2s ease-in-out infinite;
}
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 1rem;
.message {
display: flex;
gap: 0.75rem;
align-items: flex-start;
&.sent {
flex-direction: row-reverse;
.message-content {
background: linear-gradient(135deg, $color-dark-primary, $color-dark-primary-container);
color: $color-dark-on-primary;
box-shadow: 0 0 15px rgba($color-dark-primary, 0.3);
}
}
&.received {
.message-content {
background: rgba($color-dark-surface-variant, 0.8);
@@ -192,7 +192,7 @@
border: 1px solid rgba($color-dark-outline-variant, 0.3);
}
}
.message-avatar {
width: 32px;
height: 32px;
@@ -207,18 +207,18 @@
flex-shrink: 0;
box-shadow: 0 0 10px rgba($color-dark-primary, 0.4);
}
.message-content {
max-width: 70%;
padding: 0.75rem 1rem;
border-radius: 18px;
position: relative;
.message-text {
font-size: 0.9rem;
line-height: 1.4;
}
.message-time {
font-size: 0.75rem;
opacity: 0.7;
@@ -231,7 +231,7 @@
}
}
}
// Features section
.features {
padding: 6rem 0;
@@ -239,7 +239,7 @@
backdrop-filter: blur(20px);
border-top: 1px solid rgba($color-dark-primary, 0.2);
border-bottom: 1px solid rgba($color-dark-primary, 0.2);
.section-title {
text-align: center;
font-size: 2.5rem;
@@ -251,12 +251,12 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
.feature-card {
background: rgba($color-dark-surface-container, 0.6);
backdrop-filter: blur(20px);
@@ -266,7 +266,7 @@
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
@@ -279,24 +279,24 @@
transition: opacity 0.3s ease;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
&:hover {
transform: translateY(-5px);
box-shadow:
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.3),
0 0 30px rgba($color-dark-primary, 0.2);
border-color: rgba($color-dark-primary, 0.5);
&::before {
opacity: 1;
}
}
.feature-icon {
width: 60px;
height: 60px;
@@ -309,14 +309,14 @@
border: 1px solid rgba($color-dark-primary, 0.4);
box-shadow: 0 0 15px rgba($color-dark-primary, 0.2);
user-select: none;
mdui-icon {
font-size: 1.5rem;
color: $color-dark-primary;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.8);
}
}
h4 {
font-size: 1.25rem;
font-weight: 600;
@@ -324,7 +324,7 @@
color: $color-dark-on-surface;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.3);
}
p {
line-height: 1.6;
opacity: 0.9;
@@ -333,16 +333,16 @@
}
}
}
// Download section
.download {
padding: 6rem 0;
.download-content {
text-align: center;
max-width: 600px;
margin: 0 auto;
h3 {
font-size: 2.5rem;
font-weight: 700;
@@ -353,14 +353,14 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
p {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.download-buttons {
display: flex;
gap: 1rem;
@@ -369,19 +369,19 @@
}
}
}
// CTA section
.cta {
padding: 6rem 0;
background: rgba($color-dark-surface-container, 0.3);
backdrop-filter: blur(20px);
border-top: 1px solid rgba($color-dark-primary, 0.2);
.cta-content {
text-align: center;
max-width: 600px;
margin: 0 auto;
h3 {
font-size: 2.5rem;
font-weight: 700;
@@ -392,14 +392,14 @@
background-clip: text;
text-shadow: 0 0 20px rgba($color-dark-primary, 0.5);
}
p {
font-size: 1.25rem;
line-height: 1.6;
margin-bottom: 2.5rem;
opacity: 0.9;
}
.cta-actions {
display: flex;
gap: 1rem;
@@ -408,7 +408,7 @@
}
}
}
// Footer
.homepage-footer {
background: rgba($color-dark-surface-container, 0.8);
@@ -416,13 +416,13 @@
padding: 3rem 0 1rem;
border-top: 1px solid rgba($color-dark-primary, 0.3);
box-shadow: 0 -4px 20px rgba($color-dark-primary, 0.1);
.footer-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
.footer-section {
h4 {
font-size: 1.1rem;
@@ -431,13 +431,13 @@
color: $color-dark-on-surface;
text-shadow: 0 0 10px rgba($color-dark-primary, 0.3);
}
p {
opacity: 0.8;
line-height: 1.6;
color: $color-dark-on-surface-variant;
}
a {
color: $color-dark-on-surface;
text-decoration: none;
@@ -447,7 +447,7 @@
transition: all 0.3s ease;
padding: 0.25rem 0;
border-radius: 4px;
&:hover {
opacity: 1;
color: $color-dark-primary;
@@ -457,12 +457,12 @@
}
}
}
.footer-bottom {
text-align: center;
padding-top: 2rem;
border-top: 1px solid rgba($color-dark-primary, 0.3);
p {
opacity: 0.7;
margin: 0;
@@ -503,13 +503,13 @@
max-width: 1200px;
border-radius: 20px;
border: 1px solid rgba($color-dark-primary, 0.3);
box-shadow:
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 20px rgba($color-dark-primary, 0.2);
backdrop-filter: blur(30px);
background: rgba($color-dark-surface-container, 0.9);
}
.hero {
margin-top: 6rem;
}
@@ -534,32 +534,32 @@
.container {
padding: 0 1rem;
}
.homepage-header {
.header-content {
flex-direction: column;
gap: 1rem;
}
}
.hero {
flex-direction: column;
text-align: center;
padding: 2rem 0;
.hero-content {
.hero-title {
font-size: 2.5rem;
}
.hero-description {
font-size: 1.1rem;
}
}
.hero-visual {
padding: 1rem;
.chat-preview {
.chat-window {
transform: none;
@@ -568,47 +568,47 @@
}
}
}
.features {
.features-grid {
grid-template-columns: 1fr;
.feature-card {
padding: 1.5rem;
}
}
.section-title {
font-size: 2rem;
}
}
.download {
.download-content {
h3 {
font-size: 2rem;
}
.download-buttons {
flex-direction: column;
align-items: center;
}
}
}
.cta {
.cta-content {
h3 {
font-size: 2rem;
}
.cta-actions {
flex-direction: column;
align-items: center;
}
}
}
.homepage-footer {
.footer-content {
grid-template-columns: 1fr;
@@ -625,19 +625,19 @@
.hero-title {
font-size: 2rem;
}
.hero-description {
font-size: 1rem;
}
}
}
.features {
.section-title {
font-size: 1.75rem;
}
}
.download {
.download-content {
h3 {
@@ -645,7 +645,7 @@
}
}
}
.cta {
.cta-content {
h3 {
@@ -14,14 +14,14 @@ export default function NotFoundPage() {
К сожалению, запрашиваемая страница не существует или была перемещена.
</p>
<div className="not-found-actions">
<mdui-button
variant="filled"
<mdui-button
variant="filled"
onClick={() => navigate("/")}
>
На главную
</mdui-button>
<mdui-button
variant="outlined"
<mdui-button
variant="outlined"
onClick={() => navigate(-1)}
>
Назад
+3 -3
View File
@@ -69,15 +69,15 @@
gap: 2rem;
padding: 2rem;
}
.error-code {
font-size: 4rem;
}
.not-found-content h1 {
font-size: 2rem;
}
.not-found-actions {
justify-content: center;
}
+1 -1
View File
@@ -18,7 +18,7 @@ export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayB
const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial;
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info;
const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8);
return new Uint8Array(bits);
+2 -2
View File
@@ -33,9 +33,9 @@ function showNotification(message: string, type: NotificationType): void {
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: opacity 0.3s ease;
`;
document.body.appendChild(notification);
// Fade out and remove
setTimeout(() => {
notification.style.opacity = '0';
+1 -1
View File
@@ -47,7 +47,7 @@ export function id<T extends Element = HTMLElement>(id: string): T {
/**
* Runs the specified callback after `click` or `touchstart` event is triggered.
*
*
* @param action The action to perform after interaction
* @returns A function to clean up the event listeners.
*/