diff --git a/backend/app/routers/invites.py b/backend/app/routers/invites.py index 22c2606..03fd4f3 100644 --- a/backend/app/routers/invites.py +++ b/backend/app/routers/invites.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import get_current_user from app.models import User -from app.schemas.invite import InviteRead +from app.schemas.invite import InviteRead, MyInviteRead from app.schemas.room import RoomMemberRead from app.services.invite_service import ( InviteExpiredError, @@ -21,12 +21,26 @@ from app.services.invite_service import ( router = APIRouter(prefix="/api/invites", tags=["invites"]) -@router.get("/mine", response_model=list[InviteRead]) +@router.get("/mine", response_model=list[MyInviteRead]) async def list_my_invites_endpoint( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - return await list_my_invites(db, current_user.id) + invites = await list_my_invites(db, current_user.id) + return [ + MyInviteRead( + id=i.id, + room_id=i.room_id, + invited_by=i.invited_by, + target_user_id=i.target_user_id, + status=i.status, + expires_at=i.expires_at, + created_at=i.created_at, + room_name=i.room.name, + invited_by_username=i.inviter.username, + ) + for i in invites + ] @router.post("/{invite_id}/accept", response_model=RoomMemberRead) diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index 4a0ccb4..eaba35b 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -257,7 +257,31 @@ async def get_room_messages_endpoint( db: AsyncSession = Depends(get_db), ): await require_room_member(room_id, current_user, db) - return await list_recent_messages(db, room_id, limit) + messages = await list_recent_messages(db, room_id, limit) + return [ + MessageRead( + id=m.id, + room_id=m.room_id, + user_id=m.user_id, + username=m.user.username, + content=m.content, + created_at=m.created_at, + ) + for m in messages + ] + + +def _to_invite_read(invite) -> InviteRead: + return InviteRead( + id=invite.id, + room_id=invite.room_id, + invited_by=invite.invited_by, + target_user_id=invite.target_user_id, + target_username=invite.target_user.username if invite.target_user else None, + status=invite.status, + expires_at=invite.expires_at, + created_at=invite.created_at, + ) @router.post("/{room_id}/invites", response_model=InviteRead, status_code=201) @@ -269,13 +293,14 @@ async def create_invite_endpoint( ): await require_room_role(room_id, current_user, db, RoomRole.admin) try: - return await create_invite(db, room_id, current_user.id, data.target_username) + invite = await create_invite(db, room_id, current_user.id, data.target_username) except TargetUserNotFoundError: raise HTTPException(status_code=404, detail="No user with that username") except AlreadyMemberError: raise HTTPException(status_code=409, detail="That user is already a member") except DuplicateInviteError: raise HTTPException(status_code=409, detail="That user already has a pending invite") + return _to_invite_read(invite) @router.get("/{room_id}/invites", response_model=list[InviteRead]) @@ -285,7 +310,8 @@ async def list_room_invites_endpoint( db: AsyncSession = Depends(get_db), ): await require_room_role(room_id, current_user, db, RoomRole.admin) - return await list_room_invites(db, room_id) + invites = await list_room_invites(db, room_id) + return [_to_invite_read(i) for i in invites] @router.delete("/{room_id}/invites/{invite_id}", status_code=204) diff --git a/backend/app/schemas/invite.py b/backend/app/schemas/invite.py index 3127bef..fdc56f4 100644 --- a/backend/app/schemas/invite.py +++ b/backend/app/schemas/invite.py @@ -17,6 +17,15 @@ class InviteRead(BaseModel): room_id: uuid.UUID invited_by: uuid.UUID target_user_id: uuid.UUID | None + target_username: str | None = None status: InviteStatus expires_at: datetime created_at: datetime + + +class MyInviteRead(InviteRead): + """InviteRead plus context the recipient can't otherwise resolve client-side -- + GET /api/invites/mine is for rooms the user isn't a member of yet.""" + + room_name: str + invited_by_username: str diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index 8767c2c..82a9d51 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -10,5 +10,6 @@ class MessageRead(BaseModel): id: uuid.UUID room_id: uuid.UUID user_id: uuid.UUID + username: str content: str created_at: datetime diff --git a/backend/app/services/invite_service.py b/backend/app/services/invite_service.py index 7a687ad..908fdbd 100644 --- a/backend/app/services/invite_service.py +++ b/backend/app/services/invite_service.py @@ -72,14 +72,15 @@ async def create_invite( db.add(invite) await db.commit() await db.refresh(invite) + invite.target_user = target return invite async def list_room_invites(db: AsyncSession, room_id: uuid.UUID) -> list[RoomInvite]: result = await db.execute( - select(RoomInvite).where( - RoomInvite.room_id == room_id, RoomInvite.status == InviteStatus.pending - ) + select(RoomInvite) + .where(RoomInvite.room_id == room_id, RoomInvite.status == InviteStatus.pending) + .options(selectinload(RoomInvite.target_user)) ) return list(result.scalars().all()) @@ -92,7 +93,7 @@ async def list_my_invites(db: AsyncSession, user_id: uuid.UUID) -> list[RoomInvi RoomInvite.status == InviteStatus.pending, RoomInvite.expires_at > datetime.now(timezone.utc), ) - .options(selectinload(RoomInvite.room)) + .options(selectinload(RoomInvite.room), selectinload(RoomInvite.inviter)) ) return list(result.scalars().all()) diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py index 06f26e9..97b4424 100644 --- a/backend/app/services/message_service.py +++ b/backend/app/services/message_service.py @@ -2,6 +2,7 @@ import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.models import Message @@ -22,6 +23,7 @@ async def list_recent_messages( result = await db.execute( select(Message) .where(Message.room_id == room_id) + .options(selectinload(Message.user)) .order_by(Message.created_at.desc()) .limit(limit) ) diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py index 0e67635..45a0b12 100644 --- a/backend/tests/test_invites.py +++ b/backend/tests/test_invites.py @@ -49,6 +49,11 @@ async def test_invite_accept_flow(client, db_session): assert resp.status_code == 201 invite = resp.json() assert invite["status"] == "pending" + assert invite["target_username"] == "bob" + + resp = await client.get(f"/api/rooms/{room['id']}/invites") + assert resp.status_code == 200 + assert resp.json()[0]["target_username"] == "bob" await client.post("/api/auth/logout") await login_as(client, "bob") @@ -58,6 +63,8 @@ async def test_invite_accept_flow(client, db_session): mine = resp.json() assert len(mine) == 1 assert mine[0]["id"] == invite["id"] + assert mine[0]["room_name"] == room["name"] + assert mine[0]["invited_by_username"] == "alice" resp = await client.post(f"/api/invites/{invite['id']}/accept") assert resp.status_code == 200 diff --git a/backend/tests/test_ws_chat.py b/backend/tests/test_ws_chat.py index aa0a213..197e2c0 100644 --- a/backend/tests/test_ws_chat.py +++ b/backend/tests/test_ws_chat.py @@ -60,8 +60,8 @@ def test_ws_join_and_message_roundtrip(ws_client): resp = ws_client.get(f"/api/rooms/{room['id']}/messages") assert resp.status_code == 200 - contents = [m["content"] for m in resp.json()] - assert "hello" in contents + history = resp.json() + assert any(m["content"] == "hello" and m["username"] == username for m in history) def test_ws_message_without_join_errors(ws_client): diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png index 5e85c98..6e06424 100644 Binary files a/frontend/public/icons/icon-192.png and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512-maskable.png b/frontend/public/icons/icon-512-maskable.png index 5626dde..d91e8ca 100644 Binary files a/frontend/public/icons/icon-512-maskable.png and b/frontend/public/icons/icon-512-maskable.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png index 7a05ba7..d5c0624 100644 Binary files a/frontend/public/icons/icon-512.png and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b51b68..7fc4b57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,8 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { AuthProvider } from './context/AuthContext' import { ProtectedRoute } from './components/ProtectedRoute' import { LoginPage } from './pages/LoginPage' -import { RoomListPage } from './pages/RoomListPage' -import { ChatRoomPage } from './pages/ChatRoomPage' +import { ChatShellPage } from './pages/ChatShellPage' function App() { return ( @@ -14,7 +13,7 @@ function App() { path="/rooms" element={ - + } /> @@ -22,7 +21,7 @@ function App() { path="/rooms/:roomId" element={ - + } /> diff --git a/frontend/src/api/invites.ts b/frontend/src/api/invites.ts new file mode 100644 index 0000000..fde8513 --- /dev/null +++ b/frontend/src/api/invites.ts @@ -0,0 +1,29 @@ +import { apiFetch } from './client' +import type { Invite, MyInvite, RoomMember } from '../types' + +export function createInvite(roomId: string, targetUsername: string): Promise { + return apiFetch(`/api/rooms/${roomId}/invites`, { + method: 'POST', + body: JSON.stringify({ target_username: targetUsername }), + }) +} + +export function listRoomInvites(roomId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/invites`) +} + +export function revokeInvite(roomId: string, inviteId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/invites/${inviteId}`, { method: 'DELETE' }) +} + +export function listMyInvites(): Promise { + return apiFetch('/api/invites/mine') +} + +export function acceptInvite(inviteId: string): Promise { + return apiFetch(`/api/invites/${inviteId}/accept`, { method: 'POST' }) +} + +export function declineInvite(inviteId: string): Promise { + return apiFetch(`/api/invites/${inviteId}/decline`, { method: 'POST' }) +} diff --git a/frontend/src/api/rooms.ts b/frontend/src/api/rooms.ts index bd03cd0..d5ca5f3 100644 --- a/frontend/src/api/rooms.ts +++ b/frontend/src/api/rooms.ts @@ -1,21 +1,73 @@ import { apiFetch } from './client' -import type { Message, Room, RoomListItem } from '../types' +import type { Message, MyRoomItem, Room, RoomListItem, RoomMember, RoomRole } from '../types' export function listRooms(): Promise { return apiFetch('/api/rooms') } -export function createRoom(name: string, description?: string): Promise { +export function listMyRooms(): Promise { + return apiFetch('/api/rooms/mine') +} + +export function createRoom( + name: string, + description?: string, + isPrivate = false, +): Promise { return apiFetch('/api/rooms', { method: 'POST', - body: JSON.stringify({ name, description: description || null }), + body: JSON.stringify({ name, description: description || null, is_private: isPrivate }), }) } +export function updateRoom( + roomId: string, + data: { name?: string; description?: string }, +): Promise { + return apiFetch(`/api/rooms/${roomId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) +} + +export function deleteRoom(roomId: string): Promise { + return apiFetch(`/api/rooms/${roomId}`, { method: 'DELETE' }) +} + export function joinRoom(roomId: string): Promise { return apiFetch(`/api/rooms/${roomId}/join`, { method: 'POST' }) } +export function leaveRoom(roomId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/leave`, { method: 'POST' }) +} + +export function listRoomMembers(roomId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/members`) +} + +export function removeMember(roomId: string, userId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/members/${userId}`, { method: 'DELETE' }) +} + +export function changeMemberRole( + roomId: string, + userId: string, + role: RoomRole, +): Promise { + return apiFetch(`/api/rooms/${roomId}/members/${userId}`, { + method: 'PATCH', + body: JSON.stringify({ role }), + }) +} + +export function transferOwnership(roomId: string, newOwnerUserId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/transfer-ownership`, { + method: 'POST', + body: JSON.stringify({ new_owner_user_id: newOwnerUserId }), + }) +} + export function getRoomMessages(roomId: string): Promise { return apiFetch(`/api/rooms/${roomId}/messages`) } diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 0000000..23756b1 Binary files /dev/null and b/frontend/src/assets/logo.png differ diff --git a/frontend/src/components/BrowseRoomsModal.tsx b/frontend/src/components/BrowseRoomsModal.tsx new file mode 100644 index 0000000..7d0c140 --- /dev/null +++ b/frontend/src/components/BrowseRoomsModal.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react' +import { ApiError } from '../api/client' +import { joinRoom, listRooms } from '../api/rooms' +import type { RoomListItem } from '../types' +import { RoomAvatar } from './RoomAvatar' +import './Modal.css' + +interface BrowseRoomsModalProps { + onClose: () => void + onJoined: (roomId: string) => void +} + +export function BrowseRoomsModal({ onClose, onJoined }: BrowseRoomsModalProps) { + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [joiningId, setJoiningId] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + listRooms() + .then(setRooms) + .catch((err) => setError(err instanceof ApiError ? err.message : String(err))) + .finally(() => setLoading(false)) + }, []) + + async function handleJoin(roomId: string) { + setJoiningId(roomId) + setError(null) + try { + await joinRoom(roomId) + onJoined(roomId) + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } finally { + setJoiningId(null) + } + } + + const joinable = rooms.filter((r) => !r.is_member) + + return ( +
+
e.stopPropagation()}> +
+

Browse open rooms

+ +
+ + {error &&

{error}

} + + {loading ? ( +

Loading...

+ ) : joinable.length === 0 ? ( +

No open rooms to join right now.

+ ) : ( + joinable.map((room, i) => ( +
+ +
+
{room.name}
+ {room.description &&
{room.description}
} +
+ +
+ )) + )} + +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/ChatPane.css b/frontend/src/components/ChatPane.css new file mode 100644 index 0000000..49546fe --- /dev/null +++ b/frontend/src/components/ChatPane.css @@ -0,0 +1,72 @@ +.chat-pane { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--ds-void); +} + +.chat-pane-header { + height: 56px; + min-height: 56px; + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 0 var(--sp-4); + border-bottom: 1px solid var(--ds-border); + background: var(--ds-surface); +} + +.chat-pane-back { + background: transparent; + border: none; + color: var(--ds-muted); + cursor: pointer; + display: flex; + padding: 4px; + flex: none; +} + +.chat-pane-title-block { + min-width: 0; + flex: 1; +} + +.chat-pane-title { + font-weight: 700; + font-size: 0.96rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-pane-subtitle { + font-size: 0.76rem; + color: var(--ds-muted); +} + +.chat-pane-info-btn { + width: 32px; + height: 32px; + border-radius: var(--radius); + background: transparent; + border: 1px solid var(--ds-border); + color: var(--ds-accent); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex: none; +} + +.chat-pane-info-btn-active { + background: var(--ds-surface-2); +} + +.chat-pane-error { + color: var(--ds-danger); + font-size: 0.86rem; + padding: var(--sp-2) var(--sp-4) 0; + margin: 0; +} diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx new file mode 100644 index 0000000..6de84a3 --- /dev/null +++ b/frontend/src/components/ChatPane.tsx @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { getRoomMessages } from '../api/rooms' +import { useChatSocket } from '../ws/useChatSocket' +import type { ChatMessageEnvelope, Message, MyRoomItem, RoomMember, ServerEnvelope } from '../types' +import { Composer } from './Composer' +import { MessageList } from './MessageList' +import './ChatPane.css' + +interface ChatPaneProps { + room: MyRoomItem + members: RoomMember[] + isMobile: boolean + onBack: () => void + onToggleInfo: () => void + infoOpen: boolean +} + +export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOpen }: ChatPaneProps) { + const navigate = useNavigate() + const [history, setHistory] = useState([]) + const [live, setLive] = useState([]) + const [wsError, setWsError] = useState(null) + + useEffect(() => { + setHistory([]) + setLive([]) + setWsError(null) + getRoomMessages(room.id).then(setHistory).catch((err) => setWsError(String(err))) + }, [room.id]) + + const onMessage = useCallback((envelope: ServerEnvelope) => { + if (envelope.type === 'message') { + setLive((prev) => [...prev, envelope]) + } else if (envelope.type === 'error') { + setWsError(envelope.detail) + } + }, []) + + const onUnauthenticated = useCallback(() => navigate('/login'), [navigate]) + + const { connected, send } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated }) + + return ( +
+
+ {isMobile && ( + + )} +
+
#{room.name}
+
{members.length} member{members.length === 1 ? '' : 's'}
+
+ +
+ + {wsError &&

{wsError}

} + + + +
+ ) +} diff --git a/frontend/src/components/Composer.css b/frontend/src/components/Composer.css new file mode 100644 index 0000000..8bfee81 --- /dev/null +++ b/frontend/src/components/Composer.css @@ -0,0 +1,44 @@ +.composer { + padding: var(--sp-3) var(--sp-4); + border-top: 1px solid var(--ds-border); + background: var(--ds-surface); + display: flex; + gap: var(--sp-2); + align-items: flex-end; +} + +.composer textarea { + flex: 1; + resize: none; + background: var(--ds-surface-2); + border: 1px solid var(--ds-border); + border-radius: var(--radius); + padding: 9px 12px; + color: var(--ds-text); + font-size: 0.88rem; + outline: none; + max-height: 120px; +} + +.composer textarea:focus { + border-color: var(--ds-accent); +} + +.composer-send { + width: 36px; + height: 36px; + flex: none; + border-radius: var(--radius); + background: var(--ds-accent); + color: var(--ds-void); + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.composer-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/frontend/src/components/Composer.tsx b/frontend/src/components/Composer.tsx new file mode 100644 index 0000000..6eb948d --- /dev/null +++ b/frontend/src/components/Composer.tsx @@ -0,0 +1,63 @@ +import { useRef, useState, type KeyboardEvent } from 'react' +import './Composer.css' + +interface ComposerProps { + roomName: string + disabled?: boolean + onSend: (content: string) => void +} + +export function Composer({ roomName, disabled, onSend }: ComposerProps) { + const [value, setValue] = useState('') + const textareaRef = useRef(null) + + function autoGrow() { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 120)}px` + } + + function handleSend() { + const trimmed = value.trim() + if (!trimmed) return + onSend(trimmed) + setValue('') + requestAnimationFrame(autoGrow) + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + return ( +
+