Merge pull request #9 from CarterPerez-dev/project/secure-p2p-messaging

in progress debugging checkpoint
This commit is contained in:
Carter Perez 2025-12-08 17:46:18 -05:00 committed by GitHub
commit 3889bdd1f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 824 additions and 92 deletions

View File

@ -26,6 +26,7 @@ wheels/
.installed.cfg
*.egg
venv/
.venv/
ENV/
env/

View File

@ -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",
)

View File

@ -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,

View File

@ -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
)

View File

@ -12,6 +12,7 @@ class CreateRoomRequest(BaseModel):
"""
Request to create a new room
"""
creator_id: str
participant_id: str
room_type: RoomType = RoomType.DIRECT

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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:

View File

@ -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 {

View File

@ -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:

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -1,5 +1,5 @@
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Jersey+10&display=swap');
@import "tailwindcss";
@theme {
--color-black: #000000;

View File

@ -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

View File

@ -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>

View File

@ -3,3 +3,4 @@
// index.ts
// ===================
export * from "./auth.service"
export * from "./room.service"

View File

@ -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,
}

View File

@ -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)

View File

@ -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) {

View File

@ -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)
)
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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) {