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