Merge pull request #9 from CarterPerez-dev/project/secure-p2p-messaging
in progress debugging checkpoint
This commit is contained in:
commit
3889bdd1f0
|
|
@ -26,6 +26,7 @@ wheels/
|
|||
.installed.cfg
|
||||
*.egg
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ from app.schemas.rooms import (
|
|||
RoomAPIResponse,
|
||||
RoomListResponse,
|
||||
)
|
||||
from app.schemas.websocket import RoomCreatedWS
|
||||
from app.core.enums import RoomType
|
||||
from app.models.Base import get_session
|
||||
from app.core.surreal_manager import surreal_db
|
||||
from app.core.websocket_manager import connection_manager
|
||||
from app.services.auth_service import auth_service
|
||||
|
||||
|
||||
|
|
@ -41,6 +43,17 @@ async def create_room(
|
|||
"""
|
||||
Create a new chat room
|
||||
"""
|
||||
creator = await auth_service.get_user_by_id(
|
||||
session,
|
||||
UUID(request.creator_id),
|
||||
)
|
||||
|
||||
if not creator:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_404_NOT_FOUND,
|
||||
detail = "Creator not found",
|
||||
)
|
||||
|
||||
participant = await auth_service.get_user_by_id(
|
||||
session,
|
||||
UUID(request.participant_id),
|
||||
|
|
@ -55,9 +68,9 @@ async def create_room(
|
|||
now = datetime.now(UTC)
|
||||
|
||||
room_data = {
|
||||
"name": participant.display_name,
|
||||
"name": None,
|
||||
"room_type": request.room_type.value,
|
||||
"created_by": request.participant_id,
|
||||
"created_by": request.creator_id,
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
"is_ephemeral": request.room_type == RoomType.EPHEMERAL,
|
||||
|
|
@ -65,25 +78,53 @@ async def create_room(
|
|||
|
||||
room = await surreal_db.create_room(room_data)
|
||||
|
||||
await surreal_db.add_room_participant(room.id, request.creator_id, "owner")
|
||||
await surreal_db.add_room_participant(room.id, request.participant_id, "member")
|
||||
|
||||
logger.info(
|
||||
"Created room %s with participant %s",
|
||||
"Created room %s with creator %s and participant %s",
|
||||
room.id,
|
||||
request.creator_id,
|
||||
request.participant_id,
|
||||
)
|
||||
|
||||
participants_list = [
|
||||
ParticipantResponse(
|
||||
user_id = str(creator.id),
|
||||
username = creator.username,
|
||||
display_name = creator.display_name,
|
||||
role = "owner",
|
||||
joined_at = now.isoformat(),
|
||||
),
|
||||
ParticipantResponse(
|
||||
user_id = str(participant.id),
|
||||
username = participant.username,
|
||||
display_name = participant.display_name,
|
||||
role = "member",
|
||||
joined_at = now.isoformat(),
|
||||
)
|
||||
]
|
||||
|
||||
room_ws_notification = RoomCreatedWS(
|
||||
room_id = room.id,
|
||||
room_type = room.room_type.value,
|
||||
name = creator.display_name,
|
||||
participants = [p.model_dump() for p in participants_list],
|
||||
is_encrypted = True,
|
||||
created_at = room.created_at.isoformat(),
|
||||
updated_at = room.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
await connection_manager.send_message(
|
||||
UUID(request.participant_id),
|
||||
room_ws_notification.model_dump(mode = "json"),
|
||||
)
|
||||
|
||||
return RoomAPIResponse(
|
||||
id = room.id,
|
||||
type = RoomType(room.room_type),
|
||||
name = participant.display_name,
|
||||
participants = [
|
||||
ParticipantResponse(
|
||||
user_id = str(participant.id),
|
||||
username = participant.username,
|
||||
display_name = participant.display_name,
|
||||
role = "member",
|
||||
joined_at = now.isoformat(),
|
||||
)
|
||||
],
|
||||
participants = participants_list,
|
||||
unread_count = 0,
|
||||
is_encrypted = True,
|
||||
created_at = room.created_at.isoformat(),
|
||||
|
|
@ -92,11 +133,75 @@ async def create_room(
|
|||
|
||||
|
||||
@router.get("", status_code = status.HTTP_200_OK)
|
||||
async def list_rooms() -> RoomListResponse:
|
||||
async def list_rooms(
|
||||
user_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RoomListResponse:
|
||||
"""
|
||||
List all rooms for the current user
|
||||
List all rooms for the specified user
|
||||
"""
|
||||
return RoomListResponse(rooms = [])
|
||||
logger.info("list_rooms called for user_id: %s", user_id)
|
||||
|
||||
user = await auth_service.get_user_by_id(session, UUID(user_id))
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_404_NOT_FOUND,
|
||||
detail = "User not found",
|
||||
)
|
||||
|
||||
room_data_list = await surreal_db.get_rooms_for_user(user_id)
|
||||
logger.info("SurrealDB returned %d rooms for user %s", len(room_data_list), user_id)
|
||||
logger.info("Room data: %s", room_data_list)
|
||||
|
||||
rooms: list[RoomAPIResponse] = []
|
||||
|
||||
for room_data in room_data_list:
|
||||
if not room_data:
|
||||
continue
|
||||
|
||||
room_id = str(room_data.get("id", ""))
|
||||
participants_data = await surreal_db.get_room_participants(room_id)
|
||||
|
||||
participants: list[ParticipantResponse] = []
|
||||
|
||||
for p_data in participants_data:
|
||||
p_user_id = p_data.get("user_id")
|
||||
if not p_user_id:
|
||||
continue
|
||||
|
||||
p_user = await auth_service.get_user_by_id(session, UUID(p_user_id))
|
||||
if p_user:
|
||||
participants.append(
|
||||
ParticipantResponse(
|
||||
user_id = str(p_user.id),
|
||||
username = p_user.username,
|
||||
display_name = p_user.display_name,
|
||||
role = p_data.get("role", "member"),
|
||||
joined_at = str(p_data.get("joined_at", "")),
|
||||
)
|
||||
)
|
||||
|
||||
other_participant = next(
|
||||
(p for p in participants if p.user_id != user_id),
|
||||
None
|
||||
)
|
||||
room_name = other_participant.display_name if other_participant else None
|
||||
|
||||
rooms.append(
|
||||
RoomAPIResponse(
|
||||
id = room_id,
|
||||
type = RoomType(room_data.get("room_type", "direct")),
|
||||
name = room_name,
|
||||
participants = participants,
|
||||
unread_count = 0,
|
||||
is_encrypted = True,
|
||||
created_at = str(room_data.get("created_at", "")),
|
||||
updated_at = str(room_data.get("updated_at", "")),
|
||||
)
|
||||
)
|
||||
|
||||
return RoomListResponse(rooms = rooms)
|
||||
|
||||
|
||||
@router.get("/{room_id}", status_code = status.HTTP_200_OK)
|
||||
|
|
@ -110,6 +215,22 @@ async def get_room(room_id: str) -> RoomAPIResponse:
|
|||
)
|
||||
|
||||
|
||||
@router.get("/{room_id}/messages", status_code = status.HTTP_200_OK)
|
||||
async def get_room_messages(
|
||||
room_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Get messages for a specific room
|
||||
"""
|
||||
messages = await surreal_db.get_room_messages(room_id, limit, offset)
|
||||
return {
|
||||
"messages": [msg.model_dump(mode = "json") for msg in messages],
|
||||
"has_more": len(messages) == limit
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{room_id}", status_code = status.HTTP_204_NO_CONTENT)
|
||||
async def delete_room(room_id: str) -> None:
|
||||
"""
|
||||
|
|
@ -119,3 +240,5 @@ async def delete_room(room_id: str) -> None:
|
|||
status_code = status.HTTP_404_NOT_FOUND,
|
||||
detail = "Room not found",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ class SurrealDBManager:
|
|||
if self._connected:
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"SurrealDB connecting: URL=%s, NS=%s, DB=%s",
|
||||
settings.SURREAL_URL,
|
||||
settings.SURREAL_NAMESPACE,
|
||||
settings.SURREAL_DATABASE,
|
||||
)
|
||||
|
||||
self.db = AsyncSurreal(settings.SURREAL_URL)
|
||||
await self.db.connect()
|
||||
|
||||
|
|
@ -59,7 +66,11 @@ class SurrealDBManager:
|
|||
)
|
||||
|
||||
self._connected = True
|
||||
logger.info("Connected to SurrealDB at %s", settings.SURREAL_URL)
|
||||
logger.warning(
|
||||
"SurrealDB connected successfully to %s/%s",
|
||||
settings.SURREAL_NAMESPACE,
|
||||
settings.SURREAL_DATABASE,
|
||||
)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""
|
||||
|
|
@ -75,7 +86,62 @@ class SurrealDBManager:
|
|||
Ensure connection is established
|
||||
"""
|
||||
if not self._connected:
|
||||
logger.warning("ensure_connected: Connection was lost, reconnecting...")
|
||||
await self.connect()
|
||||
else:
|
||||
logger.warning(
|
||||
"ensure_connected: Already connected (id=%s)",
|
||||
id(self.db)
|
||||
)
|
||||
|
||||
async def _debug_connection_state(self) -> dict[str, Any]:
|
||||
"""
|
||||
Debug helper to check current connection state
|
||||
"""
|
||||
try:
|
||||
info_result = await self.db.query("INFO FOR DB")
|
||||
rooms_count = await self.db.query("SELECT count() FROM rooms GROUP ALL")
|
||||
participants_count = await self.db.query(
|
||||
"SELECT count() FROM room_participants GROUP ALL"
|
||||
)
|
||||
return {
|
||||
"db_info": info_result,
|
||||
"rooms_count": rooms_count,
|
||||
"participants_count": participants_count,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def _extract_query_result(self, result: Any) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Extract query results from SurrealDB v2 response format
|
||||
|
||||
SDK v2 can return:
|
||||
- Direct list of dicts: [{'field': 'value'}, ...]
|
||||
- Wrapped result: [{'result': [...]}]
|
||||
- Empty list: []
|
||||
"""
|
||||
if not result:
|
||||
return []
|
||||
|
||||
if not isinstance(result, list):
|
||||
return []
|
||||
|
||||
if len(result) == 0:
|
||||
return []
|
||||
|
||||
first_item = result[0]
|
||||
|
||||
if isinstance(first_item, dict):
|
||||
if "result" in first_item:
|
||||
return first_item["result"] or []
|
||||
return result
|
||||
|
||||
if isinstance(first_item, str):
|
||||
logger.warning("Query returned string instead of data: %s", first_item)
|
||||
return []
|
||||
|
||||
return result
|
||||
|
||||
async def create_message(
|
||||
self,
|
||||
|
|
@ -86,7 +152,9 @@ class SurrealDBManager:
|
|||
Create a new message in SurrealDB
|
||||
"""
|
||||
await self.ensure_connected()
|
||||
logger.warning("create_message - data: %s", message_data)
|
||||
result = await self.db.create("messages", message_data)
|
||||
logger.warning("create_message - result: %s", result)
|
||||
result["id"] = str(result["id"])
|
||||
return MessageResponse(**result)
|
||||
|
||||
|
|
@ -100,6 +168,11 @@ class SurrealDBManager:
|
|||
Get messages for a specific room with pagination
|
||||
"""
|
||||
await self.ensure_connected()
|
||||
logger.warning("get_room_messages - room_id: %s", room_id)
|
||||
|
||||
all_messages = await self.db.query("SELECT * FROM messages")
|
||||
logger.warning("get_room_messages - ALL messages in DB: %s", all_messages)
|
||||
|
||||
query = """
|
||||
SELECT * FROM messages
|
||||
WHERE room_id = $room_id
|
||||
|
|
@ -115,7 +188,9 @@ class SurrealDBManager:
|
|||
"offset": offset,
|
||||
}
|
||||
)
|
||||
messages = result[0]["result"] if result else []
|
||||
logger.warning("get_room_messages - RAW result: %s", result)
|
||||
messages = self._extract_query_result(result)
|
||||
logger.warning("get_room_messages - extracted messages: %s", messages)
|
||||
return [MessageResponse(**msg) for msg in messages]
|
||||
|
||||
async def create_room(self, room_data: dict[str, Any]) -> RoomResponse:
|
||||
|
|
@ -123,9 +198,130 @@ class SurrealDBManager:
|
|||
Create a new chat room
|
||||
"""
|
||||
await self.ensure_connected()
|
||||
result = await self.db.create("rooms", room_data)
|
||||
result["id"] = str(result["id"])
|
||||
return RoomResponse(**result)
|
||||
query = "CREATE rooms CONTENT $data"
|
||||
logger.warning("create_room - executing query with data: %s", room_data)
|
||||
|
||||
result = await self.db.query(query, {"data": room_data})
|
||||
logger.warning("create_room - RAW result type: %s", type(result))
|
||||
logger.warning("create_room - RAW result: %s", result)
|
||||
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
first = result[0]
|
||||
logger.info("create_room - first element type: %s, value: %s", type(first), first)
|
||||
|
||||
if isinstance(first, dict):
|
||||
if "result" in first and first["result"]:
|
||||
room = first["result"][0] if isinstance(first["result"], list) else first["result"]
|
||||
elif "id" in first:
|
||||
room = first
|
||||
else:
|
||||
logger.error("create_room - unexpected dict structure: %s", first)
|
||||
raise ValueError(f"Unexpected result structure: {first}")
|
||||
elif isinstance(first, list) and len(first) > 0:
|
||||
room = first[0]
|
||||
else:
|
||||
logger.error("create_room - unexpected first element: %s", first)
|
||||
raise ValueError(f"Unexpected result: {first}")
|
||||
|
||||
room["id"] = str(room["id"])
|
||||
logger.info("create_room - final room: %s", room)
|
||||
return RoomResponse(**room)
|
||||
|
||||
logger.error("create_room - no results returned: %s", result)
|
||||
raise ValueError(f"Failed to create room, result: {result}")
|
||||
|
||||
async def add_room_participant(
|
||||
self,
|
||||
room_id: str,
|
||||
user_id: str,
|
||||
role: str = "member",
|
||||
) -> None:
|
||||
"""
|
||||
Add a participant to a room
|
||||
"""
|
||||
await self.ensure_connected()
|
||||
from datetime import UTC, datetime
|
||||
|
||||
query = """
|
||||
CREATE room_participants CONTENT {
|
||||
room_id: $room_id,
|
||||
user_id: $user_id,
|
||||
role: $role,
|
||||
joined_at: $joined_at
|
||||
} RETURN AFTER
|
||||
"""
|
||||
params = {
|
||||
"room_id": room_id,
|
||||
"user_id": user_id,
|
||||
"role": role,
|
||||
"joined_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
logger.warning("add_room_participant - params: %s", params)
|
||||
|
||||
result = await self.db.query(query, params)
|
||||
logger.warning("add_room_participant result: %s", result)
|
||||
|
||||
verify = await self.db.query("SELECT * FROM room_participants")
|
||||
logger.warning("VERIFY ALL room_participants: %s", verify)
|
||||
|
||||
debug_state = await self._debug_connection_state()
|
||||
logger.warning("add_room_participant - DB STATE after insert: %s", debug_state)
|
||||
|
||||
async def get_rooms_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get all rooms a user is a participant of
|
||||
"""
|
||||
await self.ensure_connected()
|
||||
|
||||
debug_state = await self._debug_connection_state()
|
||||
logger.warning("get_rooms_for_user - DB STATE: %s", debug_state)
|
||||
|
||||
participants_query = """
|
||||
SELECT room_id FROM room_participants WHERE user_id = $user_id
|
||||
"""
|
||||
logger.warning("get_rooms_for_user - fetching participants for user_id: %s", user_id)
|
||||
result = await self.db.query(participants_query, {"user_id": user_id})
|
||||
logger.warning("get_rooms_for_user - RAW result: %s", result)
|
||||
participant_data = self._extract_query_result(result)
|
||||
logger.warning("Participant data after extract: %s", participant_data)
|
||||
|
||||
if not participant_data:
|
||||
logger.warning("No room_participants found for user %s", user_id)
|
||||
return []
|
||||
|
||||
room_ids = [p["room_id"] for p in participant_data if p.get("room_id")]
|
||||
logger.info("Room IDs to fetch: %s", room_ids)
|
||||
|
||||
if not room_ids:
|
||||
logger.warning("No valid room_ids for user %s", user_id)
|
||||
return []
|
||||
|
||||
rooms: list[dict[str, Any]] = []
|
||||
for room_id in room_ids:
|
||||
try:
|
||||
room = await self.db.select(room_id)
|
||||
if room:
|
||||
if isinstance(room, list):
|
||||
rooms.extend(room)
|
||||
else:
|
||||
rooms.append(room)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch room %s: %s", room_id, e)
|
||||
|
||||
logger.info("Fetched %d rooms", len(rooms))
|
||||
return rooms
|
||||
|
||||
async def get_room_participants(self, room_id: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get all participants of a room
|
||||
"""
|
||||
await self.ensure_connected()
|
||||
query = """
|
||||
SELECT * FROM room_participants
|
||||
WHERE room_id = $room_id OR room_id = type::string($room_id)
|
||||
"""
|
||||
result = await self.db.query(query, {"room_id": room_id})
|
||||
return self._extract_query_result(result)
|
||||
|
||||
async def get_user_rooms(self, user_id: str) -> list[RoomResponse]:
|
||||
"""
|
||||
|
|
@ -136,9 +332,11 @@ class SurrealDBManager:
|
|||
SELECT ->member_of->rooms.* AS rooms
|
||||
FROM $user_id
|
||||
"""
|
||||
result = await self.db.query(query, {"user_id": f"users:{user_id}"})
|
||||
rooms = result[0]["result"][0]["rooms"] if result else []
|
||||
return [RoomResponse(**room) for room in rooms]
|
||||
result = await self.db.query(query, {"user_id": f"users:`{user_id}`"})
|
||||
data = self._extract_query_result(result)
|
||||
if data and isinstance(data[0], dict) and data[0].get("rooms"):
|
||||
return [RoomResponse(**room) for room in data[0]["rooms"]]
|
||||
return []
|
||||
|
||||
async def update_presence(
|
||||
self,
|
||||
|
|
@ -151,7 +349,7 @@ class SurrealDBManager:
|
|||
"""
|
||||
await self.ensure_connected()
|
||||
await self.db.merge(
|
||||
f"presence:{user_id}",
|
||||
f"presence:`{user_id}`",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"status": status,
|
||||
|
|
@ -170,8 +368,8 @@ class SurrealDBManager:
|
|||
FROM $room_id
|
||||
WHERE status = '{PresenceStatus.ONLINE.value}'
|
||||
"""
|
||||
result = await self.db.query(query, {"room_id": f"rooms:{room_id}"})
|
||||
presence_list = result[0]["result"] if result else []
|
||||
result = await self.db.query(query, {"room_id": f"rooms:`{room_id}`"})
|
||||
presence_list = self._extract_query_result(result)
|
||||
return [PresenceResponse(**p) for p in presence_list]
|
||||
|
||||
async def live_messages(
|
||||
|
|
@ -194,6 +392,26 @@ class SurrealDBManager:
|
|||
self.live_queries[room_id] = live_id
|
||||
return live_id
|
||||
|
||||
async def live_messages_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
callback: Callable[[LiveMessageUpdate],
|
||||
None],
|
||||
) -> str:
|
||||
"""
|
||||
Subscribe to live message updates where user is the recipient
|
||||
"""
|
||||
await self.ensure_connected()
|
||||
query = f"LIVE SELECT * FROM messages WHERE recipient_id = '{user_id}'"
|
||||
|
||||
def wrapper(data: dict[str, Any]) -> None:
|
||||
update = LiveMessageUpdate(**data)
|
||||
callback(update)
|
||||
|
||||
live_id = await self.db.live(query, wrapper)
|
||||
self.live_queries[f"user_{user_id}"] = live_id
|
||||
return live_id
|
||||
|
||||
async def live_presence(
|
||||
self,
|
||||
room_id: str,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,20 @@ class ConnectionManager:
|
|||
len(self.active_connections[user_id])
|
||||
)
|
||||
|
||||
await presence_service.set_user_online(user_id)
|
||||
try:
|
||||
await presence_service.set_user_online(user_id)
|
||||
except Exception as e:
|
||||
logger.error("Failed to set user %s online: %s", user_id, e)
|
||||
await self._send_error(
|
||||
websocket,
|
||||
"database_error",
|
||||
"Failed to initialize connection"
|
||||
)
|
||||
self.active_connections[user_id].remove(websocket)
|
||||
if not self.active_connections[user_id]:
|
||||
del self.active_connections[user_id]
|
||||
await websocket.close()
|
||||
return False
|
||||
|
||||
self.heartbeat_tasks[user_id] = asyncio.create_task(
|
||||
self._heartbeat_loop(websocket,
|
||||
|
|
@ -199,8 +212,8 @@ class ConnectionManager:
|
|||
"""
|
||||
asyncio.create_task(self._handle_live_message(user_id, update))
|
||||
|
||||
live_id = await surreal_db.live_messages(
|
||||
room_id = str(user_id),
|
||||
live_id = await surreal_db.live_messages_for_user(
|
||||
user_id = str(user_id),
|
||||
callback = message_callback
|
||||
)
|
||||
|
||||
|
|
@ -226,10 +239,12 @@ class ConnectionManager:
|
|||
message_id = message_data.id,
|
||||
sender_id = message_data.sender_id,
|
||||
recipient_id = str(user_id),
|
||||
ciphertext = message_data.encrypted_content,
|
||||
nonce = "",
|
||||
header = message_data.encrypted_header,
|
||||
sender_username = "",
|
||||
room_id = message_data.room_id or "",
|
||||
content = "[Encrypted message - requires decryption]",
|
||||
ciphertext = message_data.ciphertext,
|
||||
nonce = message_data.nonce,
|
||||
header = message_data.header,
|
||||
sender_username = message_data.sender_username,
|
||||
timestamp = message_data.created_at
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class CreateRoomRequest(BaseModel):
|
|||
"""
|
||||
Request to create a new room
|
||||
"""
|
||||
creator_id: str
|
||||
participant_id: str
|
||||
room_type: RoomType = RoomType.DIRECT
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,15 @@ class MessageResponse(BaseModel):
|
|||
Message response from SurrealDB
|
||||
"""
|
||||
id: str
|
||||
room_id: str
|
||||
room_id: str | None = None
|
||||
sender_id: str
|
||||
encrypted_content: str
|
||||
encrypted_header: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
recipient_id: str
|
||||
ciphertext: str
|
||||
nonce: str
|
||||
header: str
|
||||
sender_username: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class RoomResponse(BaseModel):
|
||||
|
|
@ -28,7 +31,7 @@ class RoomResponse(BaseModel):
|
|||
Room response from SurrealDB
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
name: str | None = None
|
||||
room_type: RoomType
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ class EncryptedMessageWS(BaseWSMessage):
|
|||
message_id: str = Field(max_length = MESSAGE_ID_MAX_LENGTH)
|
||||
sender_id: str
|
||||
recipient_id: str
|
||||
room_id: str
|
||||
content: str = ""
|
||||
ciphertext: str = Field(max_length = ENCRYPTED_CONTENT_MAX_LENGTH)
|
||||
nonce: str
|
||||
header: str
|
||||
|
|
@ -91,3 +93,29 @@ class WSHeartbeat(BaseModel):
|
|||
"""
|
||||
type: str = "heartbeat"
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class RoomCreatedWS(BaseModel):
|
||||
"""
|
||||
Room created notification sent over WebSocket
|
||||
"""
|
||||
type: str = "room_created"
|
||||
room_id: str
|
||||
room_type: str
|
||||
name: str | None
|
||||
participants: list[dict[str, Any]]
|
||||
is_encrypted: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class MessageSentWS(BaseWSMessage):
|
||||
"""
|
||||
Confirmation sent back to sender after message is stored
|
||||
"""
|
||||
type: str = "message_sent"
|
||||
temp_id: str
|
||||
message_id: str
|
||||
room_id: str
|
||||
status: str = "sent"
|
||||
created_at: datetime
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ from app.core.exceptions import (
|
|||
from app.core.passkey.passkey_manager import passkey_manager
|
||||
from app.core.redis_manager import redis_manager
|
||||
from app.models.Credential import Credential
|
||||
from app.models.IdentityKey import IdentityKey
|
||||
from app.models.User import User
|
||||
from app.services.prekey_service import prekey_service
|
||||
from app.schemas.auth import (
|
||||
AuthenticationBeginRequest,
|
||||
AuthenticationCompleteRequest,
|
||||
|
|
@ -418,6 +420,11 @@ class AuthService:
|
|||
device_name = request.device_name,
|
||||
)
|
||||
|
||||
await prekey_service.initialize_user_keys(
|
||||
session = session,
|
||||
user_id = user.id,
|
||||
)
|
||||
|
||||
logger.info("Registration completed for user: %s", username)
|
||||
|
||||
return UserResponse(
|
||||
|
|
@ -565,6 +572,20 @@ class AuthService:
|
|||
backup_eligible = verified.backup_eligible,
|
||||
)
|
||||
|
||||
ik_statement = select(IdentityKey).where(IdentityKey.user_id == user.id)
|
||||
ik_result = await session.execute(ik_statement)
|
||||
existing_ik = ik_result.scalar_one_or_none()
|
||||
|
||||
if not existing_ik:
|
||||
logger.info(
|
||||
"Initializing encryption keys for existing user: %s",
|
||||
user.username
|
||||
)
|
||||
await prekey_service.initialize_user_keys(
|
||||
session = session,
|
||||
user_id = user.id,
|
||||
)
|
||||
|
||||
logger.info("Authentication successful for user: %s", user.username)
|
||||
|
||||
return UserResponse(
|
||||
|
|
|
|||
|
|
@ -271,7 +271,8 @@ class MessageService:
|
|||
session: AsyncSession,
|
||||
sender_id: UUID,
|
||||
recipient_id: UUID,
|
||||
plaintext: str
|
||||
plaintext: str,
|
||||
room_id: str | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Encrypts message with Double Ratchet and stores in SurrealDB
|
||||
|
|
@ -324,13 +325,19 @@ class MessageService:
|
|||
"previous_chain_length": encrypted_msg.previous_chain_length
|
||||
}
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now = datetime.now(UTC)
|
||||
surreal_message = {
|
||||
"sender_id": str(sender_id),
|
||||
"recipient_id": str(recipient_id),
|
||||
"room_id": room_id,
|
||||
"ciphertext": bytes_to_base64url(encrypted_msg.ciphertext),
|
||||
"nonce": bytes_to_base64url(encrypted_msg.nonce),
|
||||
"header": json.dumps(message_header),
|
||||
"sender_username": sender_user.username
|
||||
"sender_username": sender_user.username,
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ class PresenceService:
|
|||
try:
|
||||
last_seen = datetime.now(UTC).isoformat()
|
||||
await surreal_db.db.merge(
|
||||
f"presence:{user_id}",
|
||||
f"presence:`{user_id}`",
|
||||
{
|
||||
"last_seen": last_seen,
|
||||
"updated_at": "time::now()"
|
||||
|
|
@ -87,7 +87,7 @@ class PresenceService:
|
|||
"""
|
||||
try:
|
||||
await surreal_db.ensure_connected()
|
||||
result = await surreal_db.db.select(f"presence:{user_id}")
|
||||
result = await surreal_db.db.select(f"presence:`{user_id}`")
|
||||
|
||||
if not result:
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from app.core.enums import PresenceStatus
|
|||
from app.core.websocket_manager import connection_manager
|
||||
from app.schemas.websocket import (
|
||||
EncryptedMessageWS,
|
||||
MessageSentWS,
|
||||
ReadReceiptWS,
|
||||
TypingIndicatorWS,
|
||||
)
|
||||
|
|
@ -94,44 +95,70 @@ class WebSocketService:
|
|||
"""
|
||||
try:
|
||||
recipient_id = UUID(message.get("recipient_id"))
|
||||
room_id = message.get("room_id")
|
||||
plaintext = message.get("plaintext")
|
||||
temp_id = message.get("temp_id", "")
|
||||
|
||||
if not plaintext:
|
||||
logger.error("Missing plaintext in message from %s", user_id)
|
||||
return
|
||||
|
||||
if not room_id:
|
||||
logger.error("Missing room_id in message from %s", user_id)
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
result = await message_service.send_encrypted_message(
|
||||
session,
|
||||
user_id,
|
||||
recipient_id,
|
||||
plaintext
|
||||
plaintext,
|
||||
room_id,
|
||||
)
|
||||
|
||||
ws_message = EncryptedMessageWS(
|
||||
message_id = result.id if hasattr(result,
|
||||
'id') else "unknown",
|
||||
message_id = result.id if hasattr(result, 'id') else "unknown",
|
||||
sender_id = str(user_id),
|
||||
recipient_id = str(recipient_id),
|
||||
ciphertext = message.get("ciphertext",
|
||||
""),
|
||||
nonce = message.get("nonce",
|
||||
""),
|
||||
header = message.get("header",
|
||||
""),
|
||||
sender_username = message.get("sender_username",
|
||||
"")
|
||||
room_id = room_id,
|
||||
content = plaintext,
|
||||
ciphertext = result.ciphertext if hasattr(result, 'ciphertext') else "",
|
||||
nonce = result.nonce if hasattr(result, 'nonce') else "",
|
||||
header = result.header if hasattr(result, 'header') else "",
|
||||
sender_username = result.sender_username if hasattr(result, 'sender_username') else ""
|
||||
)
|
||||
|
||||
is_recipient_connected = connection_manager.is_user_connected(recipient_id)
|
||||
logger.warning(
|
||||
"Sending to recipient %s - connected: %s",
|
||||
recipient_id,
|
||||
is_recipient_connected
|
||||
)
|
||||
|
||||
await connection_manager.send_message(
|
||||
recipient_id,
|
||||
ws_message.model_dump(mode = "json")
|
||||
)
|
||||
logger.warning("Message sent to recipient %s", recipient_id)
|
||||
|
||||
confirmation = MessageSentWS(
|
||||
temp_id = temp_id,
|
||||
message_id = result.id if hasattr(result, 'id') else "unknown",
|
||||
room_id = room_id,
|
||||
status = "sent",
|
||||
created_at = result.created_at if hasattr(result, 'created_at') else datetime.now(UTC)
|
||||
)
|
||||
|
||||
await connection_manager.send_message(
|
||||
user_id,
|
||||
confirmation.model_dump(mode = "json")
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Encrypted message forwarded: %s -> %s",
|
||||
"Encrypted message forwarded: %s -> %s in room %s",
|
||||
user_id,
|
||||
recipient_id
|
||||
recipient_id,
|
||||
room_id
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
|
|
|
|||
|
|
@ -75,7 +75,10 @@ services:
|
|||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY required}
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-chat_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-chat_auth}
|
||||
SURREAL_URL: ${SURREAL_URL:-ws://surrealdb:8000}
|
||||
SURREAL_USER: ${SURREAL_USER:-root}
|
||||
SURREAL_PASSWORD: ${SURREAL_PASSWORD}
|
||||
SURREAL_NAMESPACE: ${SURREAL_NAMESPACE:-chat}
|
||||
SURREAL_DATABASE: ${SURREAL_DATABASE:-production}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
RP_ID: ${RP_ID:-localhost}
|
||||
RP_NAME: ${RP_NAME:-Encrypted P2P Chat}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
$isAuthenticated,
|
||||
$sidebarOpen,
|
||||
toggleSidebar,
|
||||
openModal,
|
||||
} from "../../stores"
|
||||
import { IconButton } from "../UI/IconButton"
|
||||
import { Badge } from "../UI/Badge"
|
||||
|
|
@ -70,6 +71,7 @@ export function Header(): JSX.Element {
|
|||
icon={<SearchIcon />}
|
||||
ariaLabel="Search"
|
||||
size="sm"
|
||||
onClick={() => openModal("new-conversation")}
|
||||
/>
|
||||
</Show>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
$activeRoomId,
|
||||
$totalUnreadCount,
|
||||
setActiveRoom,
|
||||
openModal,
|
||||
} from "../../stores"
|
||||
import { Avatar } from "../UI/Avatar"
|
||||
import { Badge } from "../UI/Badge"
|
||||
|
|
@ -27,7 +28,9 @@ export function Sidebar(): JSX.Element {
|
|||
|
||||
const roomList = (): Room[] => {
|
||||
const roomsObj = rooms()
|
||||
console.log("[Sidebar] rooms store:", roomsObj)
|
||||
const roomArray: Room[] = Object.values(roomsObj)
|
||||
console.log("[Sidebar] roomArray length:", roomArray.length)
|
||||
return roomArray.sort((a, b) => {
|
||||
const aTime = a.last_message?.created_at ?? a.updated_at
|
||||
const bTime = b.last_message?.created_at ?? b.updated_at
|
||||
|
|
@ -59,6 +62,7 @@ export function Sidebar(): JSX.Element {
|
|||
variant="subtle"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2 px-3"
|
||||
onClick={() => openModal("new-conversation")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
|||
error: "border-error text-error",
|
||||
}
|
||||
|
||||
const VARIANT_ICONS: Record<ToastVariant, JSX.Element> = {
|
||||
info: <InfoIcon />,
|
||||
success: <CheckIcon />,
|
||||
warning: <WarningIcon />,
|
||||
error: <ErrorIcon />,
|
||||
const VARIANT_ICONS: Record<ToastVariant, () => JSX.Element> = {
|
||||
info: InfoIcon,
|
||||
success: CheckIcon,
|
||||
warning: WarningIcon,
|
||||
error: ErrorIcon,
|
||||
}
|
||||
|
||||
export function Toast(props: ToastProps): JSX.Element {
|
||||
|
|
@ -42,7 +42,7 @@ export function Toast(props: ToastProps): JSX.Element {
|
|||
role="alert"
|
||||
>
|
||||
<span class="flex-shrink-0 mt-0.5">
|
||||
{VARIANT_ICONS[props.variant]}
|
||||
{VARIANT_ICONS[props.variant]()}
|
||||
</span>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
@import "tailwindcss";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Jersey+10&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-black: #000000;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { render } from "solid-js/web";
|
|||
import { Router } from "@solidjs/router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||
import App from "./App";
|
||||
import { ToastContainer } from "./components/UI/Toast";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
|
@ -26,6 +27,7 @@ render(
|
|||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
<ToastContainer />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
root
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// © AngelaMos | 2025
|
||||
// Chat.tsx
|
||||
// ===================
|
||||
import { createSignal, Show, onMount, onCleanup } from "solid-js"
|
||||
import { Show, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Participant } from "../types"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
|
|
@ -16,27 +16,45 @@ import {
|
|||
import {
|
||||
$activeRoom,
|
||||
$activeRoomId,
|
||||
$activeModal,
|
||||
$userId,
|
||||
$currentUser,
|
||||
showToast,
|
||||
addRoom,
|
||||
setActiveRoom,
|
||||
openModal,
|
||||
closeModal,
|
||||
addMessage,
|
||||
} from "../stores"
|
||||
import { api } from "../lib/api-client"
|
||||
import {
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
wsManager,
|
||||
} from "../websocket"
|
||||
import { roomService } from "../services"
|
||||
|
||||
export default function Chat(): JSX.Element {
|
||||
const activeRoom = useStore($activeRoom)
|
||||
const activeRoomId = useStore($activeRoomId)
|
||||
const userId = useStore($userId)
|
||||
const [showNewChat, setShowNewChat] = createSignal(false)
|
||||
const activeModal = useStore($activeModal)
|
||||
|
||||
onMount(() => {
|
||||
if (userId()) {
|
||||
createEffect(() => {
|
||||
const roomId = activeRoomId()
|
||||
if (roomId) {
|
||||
roomService.loadMessages(roomId)
|
||||
}
|
||||
})
|
||||
|
||||
onMount(async () => {
|
||||
const currentUserId = userId()
|
||||
console.log("[Chat] onMount - userId:", currentUserId)
|
||||
if (currentUserId) {
|
||||
console.log("[Chat] Connecting WebSocket and loading rooms...")
|
||||
connectWebSocket()
|
||||
await roomService.loadRooms(currentUserId)
|
||||
console.log("[Chat] Rooms loaded")
|
||||
} else {
|
||||
console.log("[Chat] No userId, skipping room load")
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -47,40 +65,73 @@ export default function Chat(): JSX.Element {
|
|||
const handleSendMessage = (content: string): void => {
|
||||
const roomId = activeRoomId()
|
||||
const room = activeRoom()
|
||||
const currentUserId = userId()
|
||||
const user = $currentUser.get()
|
||||
|
||||
if (roomId === null || room === null) return
|
||||
if (roomId === null || room === null) {
|
||||
showToast("error", "SEND FAILED", "NO ACTIVE ROOM")
|
||||
return
|
||||
}
|
||||
|
||||
const recipientId = room.participants.find((p: Participant) => p.user_id !== userId())?.user_id
|
||||
if (currentUserId === null) {
|
||||
showToast("error", "SEND FAILED", "NOT AUTHENTICATED")
|
||||
return
|
||||
}
|
||||
|
||||
const recipientId = room.participants.find((p: Participant) => p.user_id !== currentUserId)?.user_id
|
||||
|
||||
if (recipientId === undefined) {
|
||||
showToast("error", "SEND FAILED", "NO RECIPIENT FOUND")
|
||||
return
|
||||
}
|
||||
|
||||
wsManager.sendEncryptedMessage(
|
||||
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const now = new Date().toISOString()
|
||||
|
||||
addMessage(roomId, {
|
||||
id: tempId,
|
||||
room_id: roomId,
|
||||
sender_id: currentUserId,
|
||||
sender_username: user?.username ?? "me",
|
||||
content,
|
||||
status: "sending",
|
||||
is_encrypted: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
const sent = wsManager.sendEncryptedMessage(
|
||||
recipientId,
|
||||
roomId,
|
||||
content
|
||||
content,
|
||||
tempId
|
||||
)
|
||||
|
||||
if (!sent) {
|
||||
showToast("error", "SEND FAILED", "NOT CONNECTED")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateRoom = async (targetUserId: string): Promise<void> => {
|
||||
try {
|
||||
const room = await api.rooms.create({
|
||||
participant_id: targetUserId,
|
||||
room_type: "direct",
|
||||
})
|
||||
const currentUserId = userId()
|
||||
|
||||
addRoom(room)
|
||||
if (currentUserId === null) {
|
||||
showToast("error", "FAILED", "NOT AUTHENTICATED")
|
||||
return
|
||||
}
|
||||
|
||||
const room = await roomService.createRoom(currentUserId, targetUserId)
|
||||
|
||||
if (room) {
|
||||
setActiveRoom(room.id)
|
||||
setShowNewChat(false)
|
||||
} catch {
|
||||
closeModal()
|
||||
} else {
|
||||
showToast("error", "FAILED", "COULD NOT CREATE CONVERSATION")
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChat = (): void => {
|
||||
setShowNewChat(true)
|
||||
openModal("new-conversation")
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -111,8 +162,8 @@ export default function Chat(): JSX.Element {
|
|||
</div>
|
||||
|
||||
<NewConversation
|
||||
isOpen={showNewChat()}
|
||||
onClose={() => setShowNewChat(false)}
|
||||
isOpen={activeModal() === "new-conversation"}
|
||||
onClose={closeModal}
|
||||
onCreateRoom={handleCreateRoom}
|
||||
/>
|
||||
</AppShell>
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@
|
|||
// index.ts
|
||||
// ===================
|
||||
export * from "./auth.service"
|
||||
export * from "./room.service"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* ©AngelaMos | 2025
|
||||
* room.service.ts
|
||||
*/
|
||||
|
||||
import { api } from "../lib/api-client"
|
||||
import type { Message, Room } from "../types"
|
||||
import {
|
||||
setRooms,
|
||||
addRoom,
|
||||
setRoomMessages,
|
||||
setHasMore,
|
||||
} from "../stores"
|
||||
|
||||
export async function loadRooms(userId: string): Promise<Room[]> {
|
||||
console.log("[RoomService] loadRooms called with userId:", userId)
|
||||
try {
|
||||
const response = await api.rooms.list(userId)
|
||||
console.log("[RoomService] API response:", response)
|
||||
console.log("[RoomService] rooms count:", response.rooms.length)
|
||||
setRooms(response.rooms)
|
||||
return response.rooms
|
||||
} catch (err) {
|
||||
console.error("[RoomService] Failed to load rooms:", err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRoom(
|
||||
creatorId: string,
|
||||
participantId: string,
|
||||
roomType: "direct" | "group" | "ephemeral" = "direct"
|
||||
): Promise<Room | null> {
|
||||
try {
|
||||
const room = await api.rooms.create({
|
||||
creator_id: creatorId,
|
||||
participant_id: participantId,
|
||||
room_type: roomType,
|
||||
})
|
||||
addRoom(room)
|
||||
return room
|
||||
} catch (err) {
|
||||
console.error("[RoomService] Failed to create room:", err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMessages(
|
||||
roomId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<Message[]> {
|
||||
try {
|
||||
const response = await api.rooms.getMessages(roomId, limit, offset)
|
||||
const messages: Message[] = response.messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
room_id: msg.room_id,
|
||||
sender_id: msg.sender_id,
|
||||
sender_username: msg.sender_username,
|
||||
content: "[Encrypted message]",
|
||||
status: "delivered" as const,
|
||||
is_encrypted: true,
|
||||
encrypted_content: msg.ciphertext,
|
||||
nonce: msg.nonce,
|
||||
header: msg.header,
|
||||
created_at: msg.created_at,
|
||||
updated_at: msg.created_at,
|
||||
}))
|
||||
const reversedMessages = messages.reverse()
|
||||
setRoomMessages(roomId, reversedMessages)
|
||||
setHasMore(roomId, response.has_more)
|
||||
return reversedMessages
|
||||
} catch (err) {
|
||||
console.error("[RoomService] Failed to load messages:", err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const roomService = {
|
||||
loadRooms,
|
||||
createRoom,
|
||||
loadMessages,
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ const $userIdRaw = persistentAtom<string>("chat:user_id", "", {
|
|||
|
||||
export const $userId = computed($userIdRaw, (id) => id !== "" ? id : null)
|
||||
|
||||
export const $isAuthenticated = computed($currentUser, (user) => user !== null)
|
||||
export const $isAuthenticated = computed($userId, (id) => id !== null)
|
||||
|
||||
export const $session = atom<Session | null>(null)
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,15 @@ export function showToast(
|
|||
description?: string,
|
||||
duration?: number
|
||||
): string {
|
||||
const existingToasts = $toasts.get()
|
||||
const duplicate = existingToasts.find(
|
||||
(t) => t.title === title && t.description === description && t.variant === variant
|
||||
)
|
||||
|
||||
if (duplicate !== undefined) {
|
||||
return duplicate.id
|
||||
}
|
||||
|
||||
const id = `toast-${++toastIdCounter}`
|
||||
const toast: ToastNotification = {
|
||||
id,
|
||||
|
|
@ -88,7 +97,7 @@ export function showToast(
|
|||
duration: duration ?? 5000,
|
||||
}
|
||||
|
||||
$toasts.set([...$toasts.get(), toast])
|
||||
$toasts.set([...existingToasts, toast])
|
||||
|
||||
const toastDuration = toast.duration ?? 5000
|
||||
if (toastDuration > 0) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import type {
|
|||
ReadReceiptWS,
|
||||
HeartbeatWS,
|
||||
ErrorMessageWS,
|
||||
RoomCreatedWS,
|
||||
MessageSentWS,
|
||||
} from "./index"
|
||||
|
||||
export function isString(value: unknown): value is string {
|
||||
|
|
@ -141,9 +143,10 @@ export function isEncryptedMessageWS(value: unknown): value is EncryptedMessageW
|
|||
isNonEmptyString(value.sender_id) &&
|
||||
isNonEmptyString(value.recipient_id) &&
|
||||
isNonEmptyString(value.room_id) &&
|
||||
isNonEmptyString(value.ciphertext) &&
|
||||
isNonEmptyString(value.nonce) &&
|
||||
isNonEmptyString(value.header) &&
|
||||
isString(value.content) &&
|
||||
isString(value.ciphertext) &&
|
||||
isString(value.nonce) &&
|
||||
isString(value.header) &&
|
||||
isNonEmptyString(value.sender_username)
|
||||
)
|
||||
}
|
||||
|
|
@ -199,6 +202,33 @@ export function isErrorMessageWS(value: unknown): value is ErrorMessageWS {
|
|||
)
|
||||
}
|
||||
|
||||
export function isRoomCreatedWS(value: unknown): value is RoomCreatedWS {
|
||||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "room_created" &&
|
||||
isNonEmptyString(value.room_id) &&
|
||||
isNonEmptyString(value.room_type) &&
|
||||
isArray(value.participants) &&
|
||||
isBoolean(value.is_encrypted) &&
|
||||
isNonEmptyString(value.created_at) &&
|
||||
isNonEmptyString(value.updated_at)
|
||||
)
|
||||
}
|
||||
|
||||
export function isMessageSentWS(value: unknown): value is MessageSentWS {
|
||||
if (!isObject(value)) return false
|
||||
|
||||
return (
|
||||
value.type === "message_sent" &&
|
||||
isNonEmptyString(value.temp_id) &&
|
||||
isNonEmptyString(value.message_id) &&
|
||||
isNonEmptyString(value.room_id) &&
|
||||
isNonEmptyString(value.status) &&
|
||||
isNonEmptyString(value.created_at)
|
||||
)
|
||||
}
|
||||
|
||||
export function isWSMessage(value: unknown): value is WSMessage {
|
||||
return (
|
||||
isEncryptedMessageWS(value) ||
|
||||
|
|
@ -206,7 +236,9 @@ export function isWSMessage(value: unknown): value is WSMessage {
|
|||
isPresenceUpdateWS(value) ||
|
||||
isReadReceiptWS(value) ||
|
||||
isHeartbeatWS(value) ||
|
||||
isErrorMessageWS(value)
|
||||
isErrorMessageWS(value) ||
|
||||
isRoomCreatedWS(value) ||
|
||||
isMessageSentWS(value)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export type WSMessageType =
|
|||
| "receipt"
|
||||
| "heartbeat"
|
||||
| "error"
|
||||
| "room_created"
|
||||
| "message_sent"
|
||||
|
||||
export interface BaseWSMessage {
|
||||
type: WSMessageType
|
||||
|
|
@ -23,6 +25,7 @@ export interface EncryptedMessageWS extends BaseWSMessage {
|
|||
sender_id: string
|
||||
recipient_id: string
|
||||
room_id: string
|
||||
content: string
|
||||
ciphertext: string
|
||||
nonce: string
|
||||
header: string
|
||||
|
|
@ -63,6 +66,32 @@ export interface ErrorMessageWS extends BaseWSMessage {
|
|||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RoomCreatedWS extends BaseWSMessage {
|
||||
type: "room_created"
|
||||
room_id: string
|
||||
room_type: string
|
||||
name: string | null
|
||||
participants: Array<{
|
||||
user_id: string
|
||||
username: string
|
||||
display_name: string
|
||||
role: string
|
||||
joined_at: string
|
||||
}>
|
||||
is_encrypted: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface MessageSentWS extends BaseWSMessage {
|
||||
type: "message_sent"
|
||||
temp_id: string
|
||||
message_id: string
|
||||
room_id: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type WSMessage =
|
||||
| EncryptedMessageWS
|
||||
| TypingIndicatorWS
|
||||
|
|
@ -70,12 +99,15 @@ export type WSMessage =
|
|||
| ReadReceiptWS
|
||||
| HeartbeatWS
|
||||
| ErrorMessageWS
|
||||
| RoomCreatedWS
|
||||
| MessageSentWS
|
||||
|
||||
export interface WSOutgoingEncryptedMessage {
|
||||
type: "encrypted_message"
|
||||
recipient_id: string
|
||||
room_id: string
|
||||
plaintext: string
|
||||
temp_id: string
|
||||
}
|
||||
|
||||
export interface WSOutgoingTyping {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import type {
|
|||
PresenceUpdateWS,
|
||||
ReadReceiptWS,
|
||||
ErrorMessageWS,
|
||||
RoomCreatedWS,
|
||||
MessageSentWS,
|
||||
Message,
|
||||
Room,
|
||||
} from "../types"
|
||||
import {
|
||||
isEncryptedMessageWS,
|
||||
|
|
@ -17,11 +20,17 @@ import {
|
|||
isPresenceUpdateWS,
|
||||
isReadReceiptWS,
|
||||
isErrorMessageWS,
|
||||
isRoomCreatedWS,
|
||||
isMessageSentWS,
|
||||
} from "../types/guards"
|
||||
import {
|
||||
addMessage,
|
||||
updateMessageStatus,
|
||||
confirmPendingMessage,
|
||||
} from "../stores/messages.store"
|
||||
import {
|
||||
addRoom,
|
||||
} from "../stores/rooms.store"
|
||||
import {
|
||||
setUserPresence,
|
||||
} from "../stores/presence.store"
|
||||
|
|
@ -38,7 +47,7 @@ const encryptedMessageHandler: MessageHandler<EncryptedMessageWS> = (message) =>
|
|||
room_id: message.room_id,
|
||||
sender_id: message.sender_id,
|
||||
sender_username: message.sender_username,
|
||||
content: message.ciphertext,
|
||||
content: message.content,
|
||||
status: "delivered",
|
||||
is_encrypted: true,
|
||||
encrypted_content: message.ciphertext,
|
||||
|
|
@ -76,6 +85,38 @@ const errorMessageHandler: MessageHandler<ErrorMessageWS> = (message) => {
|
|||
showToast("error", "CONNECTION ERROR", message.error_message.toUpperCase())
|
||||
}
|
||||
|
||||
const roomCreatedHandler: MessageHandler<RoomCreatedWS> = (message) => {
|
||||
const room: Room = {
|
||||
id: message.room_id,
|
||||
type: message.room_type as "direct" | "group" | "ephemeral",
|
||||
name: message.name,
|
||||
participants: message.participants,
|
||||
unread_count: 0,
|
||||
is_encrypted: message.is_encrypted,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.updated_at,
|
||||
}
|
||||
|
||||
addRoom(room)
|
||||
showToast("info", "NEW CHAT", `NEW CONVERSATION STARTED`)
|
||||
}
|
||||
|
||||
const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
|
||||
const confirmedMessage: Message = {
|
||||
id: message.message_id,
|
||||
room_id: message.room_id,
|
||||
sender_id: "",
|
||||
sender_username: "",
|
||||
content: "",
|
||||
status: "sent",
|
||||
is_encrypted: true,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.created_at,
|
||||
}
|
||||
|
||||
confirmPendingMessage(message.room_id, message.temp_id, confirmedMessage)
|
||||
}
|
||||
|
||||
export function handleWSMessage(message: WSMessage): void {
|
||||
if (isEncryptedMessageWS(message)) {
|
||||
encryptedMessageHandler(message)
|
||||
|
|
@ -87,6 +128,10 @@ export function handleWSMessage(message: WSMessage): void {
|
|||
readReceiptHandler(message)
|
||||
} else if (isErrorMessageWS(message)) {
|
||||
errorMessageHandler(message)
|
||||
} else if (isRoomCreatedWS(message)) {
|
||||
roomCreatedHandler(message)
|
||||
} else if (isMessageSentWS(message)) {
|
||||
messageSentHandler(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import {
|
|||
isReadReceiptWS,
|
||||
isHeartbeatWS,
|
||||
isErrorMessageWS,
|
||||
isRoomCreatedWS,
|
||||
isMessageSentWS,
|
||||
} from "../types/guards"
|
||||
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "reconnecting"
|
||||
|
|
@ -42,6 +44,7 @@ export const $isConnected = computed(
|
|||
|
||||
const MAX_RECONNECT_ATTEMPTS = 10
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const FATAL_ERROR_CODES = ["max_connections", "unauthorized", "invalid_user", "database_error"]
|
||||
|
||||
class WebSocketManager {
|
||||
private ws: WebSocket | null = null
|
||||
|
|
@ -49,6 +52,7 @@ class WebSocketManager {
|
|||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private messageQueue: WSOutgoingMessage[] = []
|
||||
private intentionalClose = false
|
||||
private fatalError = false
|
||||
|
||||
connect(): void {
|
||||
const userId = $userId.get()
|
||||
|
|
@ -61,6 +65,10 @@ class WebSocketManager {
|
|||
return
|
||||
}
|
||||
|
||||
if (this.fatalError) {
|
||||
return
|
||||
}
|
||||
|
||||
this.intentionalClose = false
|
||||
$connectionStatus.set("connecting")
|
||||
$lastError.set(null)
|
||||
|
|
@ -76,6 +84,7 @@ class WebSocketManager {
|
|||
|
||||
disconnect(): void {
|
||||
this.intentionalClose = true
|
||||
this.fatalError = false
|
||||
this.cleanup()
|
||||
$connectionStatus.set("disconnected")
|
||||
$reconnectAttempts.set(0)
|
||||
|
|
@ -99,13 +108,15 @@ class WebSocketManager {
|
|||
sendEncryptedMessage(
|
||||
recipientId: string,
|
||||
roomId: string,
|
||||
plaintext: string
|
||||
plaintext: string,
|
||||
tempId: string
|
||||
): boolean {
|
||||
const message: WSOutgoingEncryptedMessage = {
|
||||
type: "encrypted_message",
|
||||
recipient_id: recipientId,
|
||||
room_id: roomId,
|
||||
plaintext,
|
||||
temp_id: tempId,
|
||||
}
|
||||
return this.send(message)
|
||||
}
|
||||
|
|
@ -176,6 +187,14 @@ class WebSocketManager {
|
|||
} else if (isHeartbeatWS(message)) {
|
||||
return
|
||||
} else if (isErrorMessageWS(message)) {
|
||||
if (FATAL_ERROR_CODES.includes(message.error_code)) {
|
||||
this.fatalError = true
|
||||
$lastError.set(message.error_message)
|
||||
}
|
||||
handleWSMessage(message)
|
||||
} else if (isRoomCreatedWS(message)) {
|
||||
handleWSMessage(message)
|
||||
} else if (isMessageSentWS(message)) {
|
||||
handleWSMessage(message)
|
||||
}
|
||||
}
|
||||
|
|
@ -188,6 +207,11 @@ class WebSocketManager {
|
|||
return
|
||||
}
|
||||
|
||||
if (this.fatalError) {
|
||||
$connectionStatus.set("disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
const attempts = $reconnectAttempts.get()
|
||||
|
||||
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue