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