diff --git a/apps/api/src/resolvers/room.ts b/apps/api/src/resolvers/room.ts index 2fbc998..e5f8118 100644 --- a/apps/api/src/resolvers/room.ts +++ b/apps/api/src/resolvers/room.ts @@ -6,6 +6,7 @@ const prisma = new PrismaClient(); const pubsub = new PubSub(); export const ROOM_ADDED = 'ROOM_ADDED'; +export const ROOM_UPDATED = 'ROOM_UPDATED'; export const roomResolvers = { Query: { @@ -81,14 +82,20 @@ export const roomResolvers = { return room; } - return prisma.room.update({ + const updatedRoom = await prisma.room.update({ where: { id: roomId }, data: { members: { connect: { id: context.userId }, }, }, + include: { members: true }, }); + + // Publish room updated event + pubsub.publish(ROOM_UPDATED, { roomUpdated: updatedRoom }); + + return updatedRoom; }, leaveRoom: async (_: any, { roomId }: { roomId: string }, context: any) => { if (!context.userId) { @@ -117,15 +124,19 @@ export const roomResolvers = { throw new ForbiddenError('You cannot leave a room you own'); } - await prisma.room.update({ + const updatedRoom = await prisma.room.update({ where: { id: roomId }, data: { members: { disconnect: { id: context.userId }, }, }, + include: { members: true }, }); + // Publish room updated event + pubsub.publish(ROOM_UPDATED, { roomUpdated: updatedRoom }); + return true; }, }, @@ -133,6 +144,9 @@ export const roomResolvers = { roomAdded: { subscribe: () => pubsub.asyncIterator([ROOM_ADDED]), }, + roomUpdated: { + subscribe: () => pubsub.asyncIterator([ROOM_UPDATED]), + }, }, Room: { messages: async (parent: any) => { diff --git a/apps/api/src/schema/typeDefs.ts b/apps/api/src/schema/typeDefs.ts index 1a58fda..b887c78 100644 --- a/apps/api/src/schema/typeDefs.ts +++ b/apps/api/src/schema/typeDefs.ts @@ -59,5 +59,6 @@ export const typeDefs = gql` type Subscription { messageAdded(roomId: ID!): Message! roomAdded: Room! + roomUpdated: Room! } `; diff --git a/apps/web/src/App.css b/apps/web/src/App.css index d361b00..1c6353a 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -211,23 +211,49 @@ button:disabled { } .room-item { + display: flex; + justify-content: space-between; + align-items: center; padding: 0.75rem; border-radius: 0.25rem; - cursor: pointer; margin-bottom: 0.5rem; - transition: background-color 0.2s; + border: 1px solid var(--border-color); + transition: border-color 0.2s; } -.room-item:hover { +.room-info { + flex: 1; + cursor: pointer; +} + +.room-info.selected { + background-color: rgba(79, 70, 229, 0.2); +} + +.room-info:hover { background-color: rgba(79, 70, 229, 0.1); } -.room-item.selected { - background-color: rgba(79, 70, 229, 0.2); +.join-button { + padding: 0.4rem 0.8rem; + font-size: 0.875rem; + margin-left: 0.5rem; } .room-name { font-weight: 500; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.member-badge { + font-size: 0.7rem; + background-color: var(--primary-color); + color: white; + padding: 0.1rem 0.4rem; + border-radius: 1rem; + font-weight: normal; } .room-description { @@ -259,6 +285,60 @@ button:disabled { .chat-header { padding: 1rem; border-bottom: 1px solid var(--border-color); + display: flex; + flex-direction: column; +} + +.room-actions { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.leave-button { + padding: 0.4rem 0.8rem; + font-size: 0.875rem; + background-color: var(--error-color); +} + +.leave-button:hover { + background-color: #dc2626; +} + +.confirm-leave { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.confirm-leave span { + font-size: 0.875rem; + color: var(--error-color); +} + +.confirm-leave button { + padding: 0.3rem 0.6rem; + font-size: 0.75rem; +} + +.confirm-leave button:first-of-type { + background-color: var(--error-color); +} + +.confirm-leave button:last-of-type { + background-color: var(--secondary-color); +} + +.error-message { + color: var(--error-color); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.chat-main { + flex: 1; + display: flex; + flex-direction: column; } .chat-header h2 { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1f15f32..ce5753f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -45,6 +45,10 @@ function App() { setSelectedRoomId(roomId); }; + const handleLeaveRoom = () => { + setSelectedRoomId(null); + }; + return (
@@ -95,18 +99,20 @@ function App() { /> -
- - Select a room to start chatting -
- } - > - +
+ + -
+ +
+ Select a room to start chatting or create a new one +
+
+ diff --git a/apps/web/src/components/chat-room.tsx b/apps/web/src/components/chat-room.tsx index 190cdfb..fff8cb4 100644 --- a/apps/web/src/components/chat-room.tsx +++ b/apps/web/src/components/chat-room.tsx @@ -1,7 +1,7 @@ import { createSignal, createEffect, For, Show } from 'solid-js'; import { gql } from '@urql/core'; import { createQuery, createMutation, createSubscription } from '@urql/solid'; -import { Message, Room } from '../types'; +import { Message } from '../types'; const ROOM_QUERY = gql` query GetRoom($id: ID!) { @@ -18,19 +18,14 @@ const ROOM_QUERY = gql` id username } - } - } -`; - -const MESSAGES_QUERY = gql` - query GetMessages($roomId: ID!) { - messages(roomId: $roomId) { - id - content - createdAt - user { + messages { id - username + content + createdAt + user { + id + username + } } } } @@ -64,25 +59,35 @@ const MESSAGE_SUBSCRIPTION = gql` } `; +const LEAVE_ROOM_MUTATION = gql` + mutation LeaveRoom($roomId: ID!) { + leaveRoom(roomId: $roomId) + } +`; + interface ChatRoomProps { roomId: string; userId: string; + onLeaveRoom?: () => void; } export function ChatRoom(props: ChatRoomProps) { + const [variables, setVariables] = createSignal({ + id: props.roomId, + roomId: props.roomId, + }); const [message, setMessage] = createSignal(''); const [messages, setMessages] = createSignal([]); + const [confirmLeave, setConfirmLeave] = createSignal(false); + const [leaveError, setLeaveError] = createSignal(null); // Query room details const [roomQuery] = createQuery({ query: ROOM_QUERY, - variables: { id: props.roomId }, - }); - - // Query messages - const [messagesQuery] = createQuery({ - query: MESSAGES_QUERY, - variables: { roomId: props.roomId }, + variables: variables, + context: { + requestPolicy: 'network-only', // Force refetch when variables change + }, }); // Send message mutation @@ -91,14 +96,28 @@ export function ChatRoom(props: ChatRoomProps) { // Subscribe to new messages const [messageSubscription] = createSubscription({ query: MESSAGE_SUBSCRIPTION, - variables: { roomId: props.roomId }, + variables: variables, + pause: false, // Ensure subscription is active + }); + + // Leave room mutation + const [leaveRoomResult, leaveRoom] = createMutation(LEAVE_ROOM_MUTATION); + + // Reset messages when room changes + createEffect(() => { + // Access props.roomId to create a dependency + setVariables({ id: props.roomId, roomId: props.roomId }); + // Reset messages when room changes + // Clear any errors or confirmations + setLeaveError(null); + setConfirmLeave(false); }); // Load initial messages createEffect(() => { - const result = messagesQuery; - if (result.data?.messages) { - setMessages(result.data.messages); + const result = roomQuery; + if (result.data?.room) { + setMessages(result.data.room.messages); } }); @@ -111,6 +130,14 @@ export function ChatRoom(props: ChatRoomProps) { } }); + // Handle leave room error + createEffect(() => { + if (leaveRoomResult.error) { + setLeaveError(leaveRoomResult.error.message); + setConfirmLeave(false); + } + }); + const handleSendMessage = async (e: Event) => { e.preventDefault(); @@ -127,6 +154,30 @@ export function ChatRoom(props: ChatRoomProps) { } }; + const handleLeaveRoom = async () => { + if (!confirmLeave()) { + setConfirmLeave(true); + return; + } + + setLeaveError(null); + try { + const result = await leaveRoom({ roomId: props.roomId }); + if (result.data?.leaveRoom) { + if (props.onLeaveRoom) { + props.onLeaveRoom(); + } + } + } catch (err) { + console.error('Failed to leave room:', err); + } + }; + + const cancelLeave = () => { + setConfirmLeave(false); + setLeaveError(null); + }; + const formatTime = (dateString: string) => { const date = new Date(dateString); return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); @@ -138,6 +189,23 @@ export function ChatRoom(props: ChatRoomProps) {

{roomQuery.data?.room.name}

{roomQuery.data?.room.description}

+
+ +
{leaveError()}
+
+ +
+ Are you sure you want to leave this room? + + +
+
+ + + +
diff --git a/apps/web/src/components/room-list.tsx b/apps/web/src/components/room-list.tsx index 082e6b6..6539b36 100644 --- a/apps/web/src/components/room-list.tsx +++ b/apps/web/src/components/room-list.tsx @@ -1,6 +1,6 @@ import { createSignal, createEffect, For, Show } from 'solid-js'; import { gql } from '@urql/core'; -import { createQuery, createSubscription } from '@urql/solid'; +import { createQuery, createSubscription, createMutation } from '@urql/solid'; import { Room } from '../types'; const ROOMS_QUERY = gql` @@ -11,6 +11,9 @@ const ROOMS_QUERY = gql` description isPrivate createdAt + members { + id + } } } `; @@ -23,6 +26,36 @@ const ROOM_ADDED_SUBSCRIPTION = gql` description isPrivate createdAt + members { + id + } + } + } +`; + +const ROOM_UPDATED_SUBSCRIPTION = gql` + subscription OnRoomUpdated { + roomUpdated { + id + name + description + isPrivate + createdAt + members { + id + } + } + } +`; + +const JOIN_ROOM_MUTATION = gql` + mutation JoinRoom($roomId: ID!) { + joinRoom(roomId: $roomId) { + id + name + members { + id + } } } `; @@ -34,10 +67,15 @@ interface RoomListProps { export function RoomList(props: RoomListProps) { const [rooms, setRooms] = createSignal([]); + const [error, setError] = createSignal(null); + const [isJoining, setIsJoining] = createSignal(false); // Query rooms const [roomsQuery] = createQuery({ query: ROOMS_QUERY, + context: { + requestPolicy: 'network-only', + }, }); // Subscribe to new rooms @@ -45,6 +83,14 @@ export function RoomList(props: RoomListProps) { query: ROOM_ADDED_SUBSCRIPTION, }); + // Subscribe to room updates (when members change) + const [roomUpdatedSubscription] = createSubscription({ + query: ROOM_UPDATED_SUBSCRIPTION, + }); + + // Join room mutation + const [joinRoomResult, joinRoom] = createMutation(JOIN_ROOM_MUTATION); + // Load initial rooms createEffect(() => { const result = roomsQuery; @@ -68,18 +114,93 @@ export function RoomList(props: RoomListProps) { } }); + // Handle room updates from subscription + createEffect(() => { + const result = roomUpdatedSubscription; + if (result.data?.roomUpdated) { + const updatedRoom = result.data.roomUpdated; + setRooms((prev) => { + return prev.map((room) => { + if (room.id === updatedRoom.id) { + return { ...room, ...updatedRoom }; + } + return room; + }); + }); + } + }); + + // Handle join room error + createEffect(() => { + if (joinRoomResult.error) { + setError(joinRoomResult.error.message); + setIsJoining(false); + } + }); + + const handleJoinRoom = async (roomId: string, event: MouseEvent) => { + event.stopPropagation(); + setError(null); + setIsJoining(true); + + try { + const result = await joinRoom({ roomId }); + if (result.data?.joinRoom) { + // Update the local room data with the joined room + setRooms((prev) => + prev.map((room) => + room.id === roomId + ? { ...room, members: result.data.joinRoom.members } + : room + ) + ); + // Select the room after joining + props.onSelectRoom(roomId); + } + } catch (err) { + console.error('Failed to join room:', err); + } finally { + setIsJoining(false); + } + }; + + // Check if the current user is a member of a room + const isMember = (room: Room) => { + const userId = localStorage.getItem('userId'); + return room.members?.some((member) => member.id === userId); + }; + return (

Chat Rooms

+ +
{error()}
+
Loading rooms...
}> {(room) => ( -
props.onSelectRoom(room.id)} - > -
{room.name}
-
{room.description}
+
+
+ isMember(room) ? props.onSelectRoom(room.id) : null + } + > +
+ {room.name} + {isMember(room) && Member} +
+
{room.description}
+
+ + +
)}