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