diff --git a/TODO-Rooms.txt b/TODO-Rooms.txt new file mode 100644 index 0000000..42d29dc --- /dev/null +++ b/TODO-Rooms.txt @@ -0,0 +1,4 @@ +TODO: + +1. Background color spills over +2. Add react-hook form for more homogeneous form validation diff --git a/backend/api-files/Create Room.bru b/backend/api-files/Create Room.bru new file mode 100644 index 0000000..6290c4c --- /dev/null +++ b/backend/api-files/Create Room.bru @@ -0,0 +1,21 @@ +meta { + name: Create Room + type: http + seq: 2 +} + +post { + url: https://localhost:1926/api/auth/room + body: json + auth: none +} + +headers { + Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Im1pY2hhZWxAZHVuZGVybWlmZmxpbi5jb20iLCJleHAiOjE3ODkzMzQxODl9.56ZzU1MPIp8eIIavgvXxWLkGO1hgfTrbxIY99TpQKEo +} + +body:json { + { + "name": "Watercooler" + } +} diff --git a/backend/api-files/Create User.bru b/backend/api-files/Create User.bru index 3517b90..7808379 100644 --- a/backend/api-files/Create User.bru +++ b/backend/api-files/Create User.bru @@ -14,8 +14,8 @@ body:json { { "first_name": "Costa", "last_name": "Alexoglou", - "email": "kostakos14@gmail.com", + "email": "test-abcde@gmail.com", "password": "hoppless", - "team_name": "The Hustlers" + "team_invite_uuid": "0198d2c9-40ec-7b46-b318-bb3ec64f5e27" } } diff --git a/backend/api-files/Delete Room.bru b/backend/api-files/Delete Room.bru new file mode 100644 index 0000000..62ad3d8 --- /dev/null +++ b/backend/api-files/Delete Room.bru @@ -0,0 +1,19 @@ +meta { + name: Delete Room + type: http + seq: 14 +} + +delete { + url: https://localhost:1926/api/auth/room/:id + body: json + auth: none +} + +params:path { + id: 20 +} + +headers { + Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Im1pY2hhZWxAZHVuZGVybWlmZmxpbi5jb20iLCJleHAiOjE3ODkwNjM5OTd9.4cNbNEHiLjuflD4ZDzLnoLCoSBstC0CbwX6P4i9HOj0 +} diff --git a/backend/api-files/Get Room.bru b/backend/api-files/Get Room.bru new file mode 100644 index 0000000..fd1d80b --- /dev/null +++ b/backend/api-files/Get Room.bru @@ -0,0 +1,19 @@ +meta { + name: Get Room + type: http + seq: 15 +} + +get { + url: https://localhost:1926/api/auth/room/:id + body: json + auth: none +} + +params:path { + id: 20 +} + +headers { + Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImR3aWdodEBkdW5kZXJtaWZmbGluLmNvbSIsImV4cCI6MTc4OTE1NzQzNH0.2f6apCqEGGGzPV3shdXzCBTOofrMCa-584Y2ZtnxIQY +} diff --git a/backend/api-files/Get Rooms.bru b/backend/api-files/Get Rooms.bru new file mode 100644 index 0000000..42ae458 --- /dev/null +++ b/backend/api-files/Get Rooms.bru @@ -0,0 +1,15 @@ +meta { + name: Get Rooms + type: http + seq: 16 +} + +get { + url: https://localhost:1926/api/auth/rooms + body: json + auth: none +} + +headers { + Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImR3aWdodEBkdW5kZXJtaWZmbGluLmNvbSIsImV4cCI6MTc4OTE1NzQzNH0.2f6apCqEGGGzPV3shdXzCBTOofrMCa-584Y2ZtnxIQY +} diff --git a/backend/api-files/Get user info.bru b/backend/api-files/Get user info.bru index f7be34c..ee99928 100644 --- a/backend/api-files/Get user info.bru +++ b/backend/api-files/Get user info.bru @@ -11,5 +11,5 @@ get { } headers { - Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImtvbnNhbGV4ZWVAZ21haWwuY29tIiwiZXhwIjoxNzY3MTg5NzQ0fQ.qIvEcVY00s9xqzlMAD8811lnR6rShveAbYLtEat_uiU + Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Im1pY2hhZWxAZHVuZGVybWlmZmxpbi5jb20iLCJleHAiOjE3OTAwNjczMTh9.Syi8XUiGChyT_z5WWXhqvpA4OU6D-cztGoN7MrrYS8Q } diff --git a/backend/api-files/Update Room.bru b/backend/api-files/Update Room.bru new file mode 100644 index 0000000..80a63b8 --- /dev/null +++ b/backend/api-files/Update Room.bru @@ -0,0 +1,25 @@ +meta { + name: Update Room + type: http + seq: 13 +} + +put { + url: https://localhost:1926/api/auth/room/:id + body: json + auth: none +} + +params:path { + id: 20 +} + +headers { + Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Im1pY2hhZWxAZHVuZGVybWlmZmxpbi5jb20iLCJleHAiOjE3ODkwNjM5OTd9.4cNbNEHiLjuflD4ZDzLnoLCoSBstC0CbwX6P4i9HOj0 +} + +body:json { + { + "name": "Umair" + } +} diff --git a/backend/api-files/openapi.yaml b/backend/api-files/openapi.yaml index f86e66e..68c07ac 100644 --- a/backend/api-files/openapi.yaml +++ b/backend/api-files/openapi.yaml @@ -78,6 +78,21 @@ components: nullable: true description: When the trial period ends (null if not in trial or has active subscription) + type: object + Room: + required: + - id + - name + - user_id + properties: + id: + type: string + format: uuid + name: + type: string + user_id: + type: string + Error: type: object properties: @@ -505,11 +520,72 @@ paths: schema: $ref: "#/components/schemas/Error" - /api/auth/watercooler: + /api/auth/room: + post: + summary: Create a new room + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name of the room + responses: + "200": + description: LiveKit tokens retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Room" + required: + - id + - name + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /api/auth/rooms: get: - summary: Get LiveKit tokens for joining the team's watercooler room + summary: Get all rooms for the user security: - BearerAuth: [] + responses: + "200": + description: Rooms for user retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Room" + + /api/auth/room/{id}: + get: + summary: Get LiveKit tokens for joining the team's selected room + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + description: The ID of the room to retrieve + schema: + type: string + format: uuid responses: "200": description: LiveKit tokens retrieved successfully @@ -532,12 +608,81 @@ paths: - cameraToken - participant - /api/auth/watercooler/anonymous: + put: + summary: Update room details + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + description: The ID of the room to update + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name of the room + responses: + "200": + description: Room updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Room" + required: + - id + - name + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Get LiveKit tokens for joining the team's selected room + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + description: The ID of the room to delete + schema: + type: string + format: uuid + responses: + "204": + description: User successfully deleted (no content) + + /api/auth/room/anonymous: get: summary: Get a link that will have an encoded token that will be used description: Get a link that will have an encoded token that will be used security: - BearerAuth: [] + parameters: + - name: room_id + in: query + required: true + description: The ID of the room to generate an anonymous link for + schema: + type: string + format: uuid responses: "200": description: Link with encoded token retrieved successfully diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go index 45caee9..620c6aa 100644 --- a/backend/internal/handlers/handlers.go +++ b/backend/internal/handlers/handlers.go @@ -645,38 +645,171 @@ func (h *AuthHandler) UpdateOnboardingFormStatus(c echo.Context) error { return c.NoContent(http.StatusOK) } -// Watercooler generates LiveKit tokens for joining the team's watercooler room -// The team's watercooler room will be a room that will have a room name: -// `team--watercooler` -func (h *AuthHandler) Watercooler(c echo.Context) error { +// Get all rooms for the user +func (h *AuthHandler) GetRooms(c echo.Context) error { user, isAuthenticated := h.getAuthenticatedUserFromJWT(c) if !isAuthenticated { return c.String(http.StatusUnauthorized, "Unauthorized request") } - // Generate a room name for the watercooler room - roomName := fmt.Sprintf("team-%d-watercooler", *user.TeamID) + var rooms []models.Room + // First, check if the room exists + result := h.DB.Where("team_id = ?", user.TeamID).Find(&rooms) - // Generate LiveKit tokens - tokens, err := generateLiveKitTokens(&h.ServerState, roomName, user) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return c.String(http.StatusNotFound, "Rooms not found") + } + + return c.JSON(http.StatusOK, rooms) +} + +// CreateRoom creates a new room for the user. +func (h *AuthHandler) CreateRoom(c echo.Context) error { + user, isAuthenticated := h.getAuthenticatedUserFromJWT(c) + if !isAuthenticated { + return c.String(http.StatusUnauthorized, "Unauthorized request") + } + + type Room struct { + Name string `gorm:"not null" json:"name" validate:"required"` + } + + req := &Room{} + + if err := c.Bind(req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + room := models.Room{ + Name: req.Name, + UserID: user.ID, + Team: user.Team, + TeamID: user.TeamID, + } + + if err := h.DB.Create(&room).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create room") + } + + // Send Telegram notification for room creation + _ = notifications.SendTelegramNotification(fmt.Sprintf("Room created: '%s' by user %s", room.Name, user.ID), h.Config) + + return c.JSON(http.StatusOK, room) +} + +// UpdateRoom updates an existing room for the user. +func (h *AuthHandler) UpdateRoom(c echo.Context) error { + user, isAuthenticated := h.getAuthenticatedUserFromJWT(c) + if !isAuthenticated { + return c.String(http.StatusUnauthorized, "Unauthorized request") + } + + roomID := c.Param("id") + + type Room struct { + Name string `gorm:"not null" json:"name" validate:"required"` + } + + req := &Room{} + + if err := c.Bind(req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + var room models.Room + + result := h.DB.Where("id = ?", roomID).First(&room) + + // Check if user can modify the room + if user.Team != room.Team { + return c.String(http.StatusUnauthorized, "Unauthorized request") + } + + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return c.String(http.StatusNotFound, "Room not found") + } + room.Name = req.Name + + if err := h.DB.Save(&room).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create room") + } + + // Send Telegram notification for room modification + _ = notifications.SendTelegramNotification(fmt.Sprintf("Room modified: '%s' by user %s", room.Name, user.ID), h.Config) + + return c.JSON(http.StatusOK, room) +} + +func (h *AuthHandler) DeleteRoom(c echo.Context) error { + user, isAuthenticated := h.getAuthenticatedUserFromJWT(c) + if !isAuthenticated { + return c.String(http.StatusUnauthorized, "Unauthorized request") + } + + roomID := c.Param("id") + + var room models.Room + + // First, check if the room exists + result := h.DB.Where("id = ?", roomID).First(&room) + + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return c.String(http.StatusNotFound, "Room not found") + } + + // Check if user can modify the room + if user.Team != room.Team { + return c.String(http.StatusUnauthorized, "Unauthorized request") + } + + // Delete the room + if err := h.DB.Delete(&room).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete room") + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *AuthHandler) GetRoom(c echo.Context) error { + user, isAuthenticated := h.getAuthenticatedUserFromJWT(c) + if !isAuthenticated { + return c.String(http.StatusUnauthorized, "Unauthorized request") + } + + roomID := c.Param("id") + var room models.Room + + // First, check if the room exists + result := h.DB.Where("id = ?", roomID).First(&room) + + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return c.String(http.StatusNotFound, "Room not found") + } + + // Check if user can access the room + if user.Team != room.Team { + return c.String(http.StatusUnauthorized, "Unauthorized request") + } + + tokens, err := generateLiveKitTokens(&h.ServerState, room.ID, user) if err != nil { - c.Logger().Error("Failed to generate watercooler tokens:", err) + c.Logger().Error("Failed to generate room tokens:", err) return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens") } tokens.Participant = user.ID - _ = notifications.SendTelegramNotification(fmt.Sprintf("User %s joined the watercooler room", user.ID), h.Config) + _ = notifications.SendTelegramNotification(fmt.Sprintf("User %s joined the %s room", user.ID, room.Name), h.Config) return c.JSON(http.StatusOK, tokens) } -// WatercoolerAnonymous generates a link that will have an encoded token that will be used -// in `WatercoolerMeetRedirect` to see if an anonymous user can join the watercooler room. +// RoomAnonymous generates a link that will have an encoded token that will be used +// in `RoomMeetRedirect` to see if an anonymous user can join the room. // The generated token should be in the format: -// /api/watercooler/meet-redirect?token= +// /api/room/meet-redirect?token= // The generated token will be a JWT token valid for 10 minutes with payload -// the team id. -func (h *AuthHandler) WatercoolerAnonymous(c echo.Context) error { +// the team id and room id. +func (h *AuthHandler) RoomAnonymous(c echo.Context) error { user, isAuthenticated := h.getAuthenticatedUserFromJWT(c) if !isAuthenticated { return c.String(http.StatusUnauthorized, "Unauthorized request") @@ -687,12 +820,31 @@ func (h *AuthHandler) WatercoolerAnonymous(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "User is not part of any team") } - // Create custom claims for anonymous watercooler access + // Get room ID from query parameter + roomID := c.QueryParam("room_id") + if roomID == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Missing room_id parameter") + } + + // Verify the room exists and user has access to it + var room models.Room + result := h.DB.Where("id = ?", roomID).First(&room) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "Room not found") + } + + // Check if user can access the room (same team) + if room.TeamID == nil || *room.TeamID != *user.TeamID { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized access to room") + } + + // Create custom claims for anonymous room access claims := jwt.MapClaims{ "team_id": *user.TeamID, + "room_id": roomID, "exp": jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), // 10-minute expiration "iat": jwt.NewNumericDate(time.Now()), // Issued at - "purpose": "anonymous_watercooler", // Purpose of the token + "purpose": "anonymous_room", // Purpose of the token } // Create token with claims @@ -707,24 +859,24 @@ func (h *AuthHandler) WatercoolerAnonymous(c echo.Context) error { // Generate encoded token tokenString, err := token.SignedString([]byte(jwtAuth.Secret)) if err != nil { - c.Logger().Error("Failed to generate anonymous watercooler token:", err) + c.Logger().Error("Failed to generate anonymous room token:", err) return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token") } // Return the redirect URL - redirectURL := fmt.Sprintf("/api/watercooler/meet-redirect?token=%s", tokenString) + redirectURL := fmt.Sprintf("/api/room/meet-redirect?token=%s", tokenString) return c.JSON(http.StatusOK, map[string]string{ "redirect_url": redirectURL, }) } -// WatercoolerMeetRedirect generates LiveKit tokens -// for joining the team's watercooler room via the meet.livekit.io/custom URL. +// RoomMeetRedirect generates LiveKit tokens +// for joining the team's room via the meet.livekit.io/custom URL. // The token will be valid for 3 hours maximum, and the format of the generated URL // that we will redirect user to will be: -// The encoded token will come from the `WatercoolerAnonymous` generated link. -func (h *AuthHandler) WatercoolerMeetRedirect(c echo.Context) error { +// The encoded token will come from the `RoomAnonymous` generated link. +func (h *AuthHandler) RoomMeetRedirect(c echo.Context) error { // Get the token from query parameters tokenString := c.QueryParam("token") if tokenString == "" { @@ -743,7 +895,7 @@ func (h *AuthHandler) WatercoolerMeetRedirect(c echo.Context) error { }) if err != nil { - c.Logger().Error("Failed to parse anonymous watercooler token:", err) + c.Logger().Error("Failed to parse anonymous room token:", err) return echo.NewHTTPError(http.StatusUnauthorized, "Invalid token") } @@ -755,7 +907,7 @@ func (h *AuthHandler) WatercoolerMeetRedirect(c echo.Context) error { // Check token purpose purpose, ok := claims["purpose"].(string) - if !ok || purpose != "anonymous_watercooler" { + if !ok || purpose != "anonymous_room" { return echo.NewHTTPError(http.StatusUnauthorized, "Invalid token purpose") } @@ -766,8 +918,26 @@ func (h *AuthHandler) WatercoolerMeetRedirect(c echo.Context) error { } teamID := uint(teamIDFloat) - // Generate a room name for the watercooler room - roomName := fmt.Sprintf("team-%d-watercooler", teamID) + // Extract room ID + roomID, ok := claims["room_id"].(string) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid room ID in token") + } + + // Verify the room exists and belongs to the team + var room models.Room + result := h.DB.Where("id = ?", roomID).First(&room) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "Room not found") + } + + // Check if room belongs to the team + if room.TeamID == nil || *room.TeamID != teamID { + return echo.NewHTTPError(http.StatusUnauthorized, "Room does not belong to team") + } + + // Use the specific room ID as the room name + roomName := roomID // Generate 4 random characters for anonymous user randomChars := rand.Text()[:4] @@ -779,10 +949,10 @@ func (h *AuthHandler) WatercoolerMeetRedirect(c echo.Context) error { TeamID: &teamID, } - // Generate a token for the anonymous user to join the watercooler room + // Generate a token for the anonymous user to join the room livekitToken, err := generateMeetRedirectToken(&h.ServerState, roomName, anonymousUser) if err != nil { - c.Logger().Error("Failed to generate watercooler tokens:", err) + c.Logger().Error("Failed to generate room tokens:", err) return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate tokens") } diff --git a/backend/internal/models/room.go b/backend/internal/models/room.go new file mode 100644 index 0000000..22f9a94 --- /dev/null +++ b/backend/internal/models/room.go @@ -0,0 +1,30 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Room struct { + ID string `json:"id" gorm:"unique;not null"` // Standard field for the primary key + Name string `gorm:"not null" json:"name" validate:"required"` + UserID string `gorm:"not null" json:"user_id" validate:"required"` + CreatedAt time.Time `json:"created_at"` // Automatically managed by GORM for creation time + UpdatedAt time.Time `json:"updated_at"` // Automatically managed by GORM for update time + TeamID *uint `json:"team_id" gorm:"default:null"` + Team *Team `json:"team,omitempty"` +} + +func (r *Room) BeforeCreate(tx *gorm.DB) (err error) { + // Using uuid v7 to be indexable with B-tree + // Overkill for real + uuidV7, err := uuid.NewV7() + if err != nil { + return err + } + r.ID = uuidV7.String() + + return +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 1c8356f..c1e0266 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -181,6 +181,7 @@ func (s *Server) runMigrations() { err := s.DB.AutoMigrate( &models.User{}, &models.Team{}, + &models.Room{}, &models.TeamInvitation{}, &models.EmailInvitation{}, &models.Subscription{}, @@ -277,7 +278,7 @@ func (s *Server) setupRoutes() { api.GET("/auth/social/:provider/callback", auth.SocialLoginCallback) api.POST("/sign-up", auth.ManualSignUp) api.POST("/sign-in", auth.ManualSignIn) - api.GET("/watercooler/meet-redirect", auth.WatercoolerMeetRedirect) + api.GET("/room/meet-redirect", auth.RoomMeetRedirect) // Protected API routes group protectedAPI := api.Group("/auth", s.JwtIssuer.Middleware()) @@ -293,10 +294,13 @@ func (s *Server) setupRoutes() { protectedAPI.POST("/change-team/:uuid", auth.ChangeTeam) protectedAPI.POST("/send-team-invites", auth.SendTeamInvites) protectedAPI.POST("/metadata/onboarding-form", auth.UpdateOnboardingFormStatus) - // Temporary room functionality for alpha - // on-boarding of >2 people calls - protectedAPI.GET("/watercooler", auth.Watercooler) - protectedAPI.GET("/watercooler/anonymous", auth.WatercoolerAnonymous) + + protectedAPI.POST("/room", auth.CreateRoom) + protectedAPI.PUT("/room/:id", auth.UpdateRoom) + protectedAPI.DELETE("/room/:id", auth.DeleteRoom) + protectedAPI.GET("/room/:id", auth.GetRoom) + protectedAPI.GET("/rooms", auth.GetRooms) + protectedAPI.GET("/room/anonymous", auth.RoomAnonymous) // LiveKit server endpoint protectedAPI.GET("/livekit/server-url", auth.GetLivekitServerURL) diff --git a/docs/src/assets/room.gif b/docs/src/assets/room.gif index 928d974..65118b9 100644 Binary files a/docs/src/assets/room.gif and b/docs/src/assets/room.gif differ diff --git a/docs/src/content/docs/features/rooms.mdx b/docs/src/content/docs/features/rooms.mdx index deee9d3..8feb0e3 100644 --- a/docs/src/content/docs/features/rooms.mdx +++ b/docs/src/content/docs/features/rooms.mdx @@ -3,19 +3,34 @@ title: Rooms description: Learn about Hopp's room system for organizing pair programming sessions. --- -import { Aside } from '@astrojs/starlight/components'; -import { Image } from 'astro:assets'; +import { Aside } from "@astrojs/starlight/components"; +import { Image } from "astro:assets"; import room from "../../../assets/room.gif"; +Rooms are permanent, named meeting spots. Some rooms for example can be `Stand-ups`, `Mob Programming`, `Planning`, `Retrospectives`, etc. - +You can just hop into a room and start collaborating with your teammates. Additionally, rooms allow more than 2 users to collaborate in a single call, and do not request a call request to be made. -Rooms are spaces where you can just hop-in and start collaborating with your teammates. Rooms allow more than 2 users to collaborate in a single call, and do not request a call request to be made. +## Creating a room -## How to join a room? +Click on the top right corner of the app, and click on the `+` button. -To join a room, for now only the `Watercooler` room is available, navigate inside the app to the Room tab, and click on the `Watercooler` room. +Then give a name to your room, and click on the `Create room` button. + +Do not worry, you can always rename your room later. Join room + +## Joining a room + +To join a room, all you have to do is click on the room tile you want to join. + +## More features to components + +We want to improve room experience, and some features we will add in the future are: + +1. Favourite a room +2. See who is in the room without joining +3. Subscribe to a room so you can get notified when someone joins + +If you want to tackle any of these features, please open an issue on our GitHub repository and you can work on it. diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 2575cc3..3bd1f48 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -10,3 +10,7 @@ --font-sans: "Geist", sans-serif; --font-mono: "Geist Mono", monospace; } + +code { + border-radius: 4px; +} diff --git a/tauri/package.json b/tauri/package.json index 197cab6..e936c05 100644 --- a/tauri/package.json +++ b/tauri/package.json @@ -13,9 +13,11 @@ "dependencies": { "@livekit/components-react": "^2.4.3", "@livekit/krisp-noise-filter": "patch:@livekit/krisp-noise-filter@npm%3A0.3.0#../.yarn/patches/@livekit-krisp-noise-filter-npm-0.3.0-ad3abbd55c.patch", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", diff --git a/tauri/src/App.css b/tauri/src/App.css index 09d3cf8..0ab5321 100644 --- a/tauri/src/App.css +++ b/tauri/src/App.css @@ -403,6 +403,7 @@ body { display: flex; margin: 0; background-color: transparent; + position: relative; } .screenshare-body { diff --git a/tauri/src/assets/door.png b/tauri/src/assets/door.png new file mode 100644 index 0000000..1f7156d Binary files /dev/null and b/tauri/src/assets/door.png differ diff --git a/tauri/src/components/ui/alert-dialog.tsx b/tauri/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..b2fd860 --- /dev/null +++ b/tauri/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/tauri/src/components/ui/call-center.tsx b/tauri/src/components/ui/call-center.tsx index d97cc6e..80ff2e9 100644 --- a/tauri/src/components/ui/call-center.tsx +++ b/tauri/src/components/ui/call-center.tsx @@ -16,7 +16,15 @@ import { useRoomContext, useTracks, } from "@livekit/components-react"; -import { Track, RemoteParticipant, ConnectionState, RoomEvent, VideoPresets, LocalTrack } from "livekit-client"; +import { + Track, + RemoteParticipant, + ConnectionState, + RoomEvent, + VideoPresets, + LocalTrack, + RemoteTrackPublication, +} from "livekit-client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./select"; import { SelectPortal } from "@radix-ui/react-select"; @@ -570,7 +578,25 @@ function CameraIcon() { }; useEffect(() => { - if (tracks.length > 0) { + // Filter out anonymous tracks that do not share their camera + const filteredTracks = tracks.filter((track) => { + if (!track.participant.identity.includes("anonymous")) { + return true; + } + + // If participant is anonymous and the video track is muted or not shared, return false + for (const trackPublication of track.participant.trackPublications) { + console.log("--- Track publication: ", trackPublication); + const pub: RemoteTrackPublication = trackPublication[1]; + if (pub.source === Track.Source.Camera && pub.isMuted) { + return false; + } + } + + return true; + }); + + if (filteredTracks.length > 0) { tauriUtils.ensureCameraWindowIsVisible(callTokens?.cameraToken || ""); } else { // If there are 0 then close the window @@ -646,7 +672,6 @@ function MediaDevicesSettings() { const { localParticipant } = useLocalParticipant(); const { isNoiseFilterPending, setNoiseFilterEnabled } = useKrispNoiseFilter(); const [micEnabled, setMicEnabled] = useState(false); - const [cameraEnabled, setCameraEnabled] = useState(false); const room = useRoomContext(); const [roomConnected, setRoomConnected] = useState(false); useEffect(() => { diff --git a/tauri/src/components/ui/dialog.tsx b/tauri/src/components/ui/dialog.tsx new file mode 100644 index 0000000..53c5611 --- /dev/null +++ b/tauri/src/components/ui/dialog.tsx @@ -0,0 +1,119 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + container, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; + container?: Element | null; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/tauri/src/components/ui/room-button.tsx b/tauri/src/components/ui/room-button.tsx new file mode 100644 index 0000000..63ec6c9 --- /dev/null +++ b/tauri/src/components/ui/room-button.tsx @@ -0,0 +1,38 @@ +import clsx from "clsx"; +import React from "react"; +type RoomButtonState = "deactivated" | "active" | "neutral"; + +export const RoomButton: React.FC< + React.PropsWithChildren< + { + cornerIcon?: React.ReactNode; + size?: "default" | "unsized"; + title: string; + } & React.ComponentPropsWithoutRef<"button"> + > +> = ({ cornerIcon, title, className = "", size = "default", ...props }) => { + return ( + + ); +}; diff --git a/tauri/src/openapi.d.ts b/tauri/src/openapi.d.ts index b28af60..c975caf 100644 --- a/tauri/src/openapi.d.ts +++ b/tauri/src/openapi.d.ts @@ -751,14 +751,75 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/watercooler": { + "/api/auth/room": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get LiveKit tokens for joining the team's watercooler room */ + get?: never; + put?: never; + /** Create a new room */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Name of the room */ + name?: string; + }; + }; + }; + responses: { + /** @description LiveKit tokens retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/rooms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all rooms for the user */ get: { parameters: { query?: never; @@ -767,6 +828,45 @@ export interface paths { cookie?: never; }; requestBody?: never; + responses: { + /** @description Rooms for user retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"][]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/room/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get LiveKit tokens for joining the team's selected room */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the room to retrieve */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; responses: { /** @description LiveKit tokens retrieved successfully */ 200: { @@ -784,15 +884,84 @@ export interface paths { }; }; }; - put?: never; + /** Update room details */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the room to update */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Name of the room */ + name?: string; + }; + }; + }; + responses: { + /** @description Room updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; post?: never; - delete?: never; + /** Get LiveKit tokens for joining the team's selected room */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the room to delete */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User successfully deleted (no content) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/auth/watercooler/anonymous": { + "/api/auth/room/anonymous": { parameters: { query?: never; header?: never; @@ -805,7 +974,10 @@ export interface paths { */ get: { parameters: { - query?: never; + query: { + /** @description The ID of the room to generate an anonymous link for */ + room_id: string; + }; header?: never; path?: never; cookie?: never; @@ -1327,6 +1499,12 @@ export interface components { */ trial_ends_at?: string | null; }; + Room: { + /** Format: uuid */ + id: string; + name: string; + user_id: string; + }; Error: { message?: string; }; diff --git a/tauri/src/store/store.ts b/tauri/src/store/store.ts index fb5dc57..bafa0fc 100644 --- a/tauri/src/store/store.ts +++ b/tauri/src/store/store.ts @@ -27,6 +27,7 @@ export type CallState = { isRemoteControlEnabled: boolean; isRoomCall?: boolean; cameraTrackId?: string | null; + room?: components["schemas"]["Room"]; } & TCallTokensMessage["payload"]; type State = { diff --git a/tauri/src/windows/main-window/app.tsx b/tauri/src/windows/main-window/app.tsx index cb7b728..fc31ca4 100644 --- a/tauri/src/windows/main-window/app.tsx +++ b/tauri/src/windows/main-window/app.tsx @@ -354,7 +354,7 @@ function App() { } return ( -
+
{/* Action Sidebar */} { + const fuse = new Fuse(rooms, { + keys: ["name"], + threshold: 0.3, + shouldSort: true, + }); + return fuse.search(searchQuery).map((result) => result.item); +}; export const Rooms = () => { + const { authToken, callTokens, setCallTokens } = useStore(); + const [searchQuery, setSearchQuery] = useState(""); + const [filteredRooms, setFilteredRooms] = useState([]); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [selectedRoom, setSelectedRoom] = useState(null); + + const { useQuery } = useAPI(); + + // Get current user's rooms + const { + error: roomsError, + data: rooms, + refetch, + } = useQuery("get", "/api/auth/rooms", undefined, { + enabled: !!authToken, + refetchInterval: 30_000, + retry: true, + queryHash: `rooms-${authToken}`, + }); + const { useMutation } = useAPI(); - const { callTokens, setCallTokens } = useStore(); + const { mutateAsync: getRoomTokens, error } = useMutation("get", "/api/auth/room/{id}", undefined); + + const { mutateAsync: createRoom } = useMutation("post", "/api/auth/room", undefined); + + const handleCreateRoom = async (roomName: string) => { + try { + const response = await createRoom({ + body: { name: roomName }, + }); + refetch(); + } catch (error) { + console.error("Failed to create room:", error); + toast.error("Failed to create room"); + } + }; - const { mutateAsync: getWatercoolerTokens, error } = useMutation("get", "/api/auth/watercooler", undefined); + const { mutateAsync: deleteRoom } = useMutation("delete", "/api/auth/room/{id}", undefined); - const handleJoinWatercooler = useCallback(async () => { + const handleDeleteRoom = async (room: Room) => { try { - const tokens = await getWatercoolerTokens({}); - if (!tokens) { - toast.error("Error joining watercooler room"); + // Send JSON body as specified in OpenAPI + const response = await deleteRoom({ + params: { + path: { + id: room.id, + }, + }, + }); + + refetch(); + } catch (error) { + // Handle 401, 500, or other errors + console.error("Failed to delete room:", error); + } + }; + + const { mutateAsync: updateRoom } = useMutation("put", "/api/auth/room/{id}", undefined); + + const handleUpdateRoom = async (room: Room, e: FormEvent) => { + e.preventDefault(); + try { + const formData = new FormData(e.currentTarget); + const roomName = formData.get("name") as string; + + if (!roomName) { + toast.error("Provide room name"); return; } - sounds.callAccepted.play(); - setCallTokens({ - ...tokens, - isRoomCall: true, - timeStarted: new Date(), - hasAudioEnabled: true, - hasCameraEnabled: false, - role: ParticipantRole.NONE, - isRemoteControlEnabled: true, - cameraTrackId: null, + + await updateRoom({ + body: { name: roomName }, + params: { + path: { + id: room.id, + }, + }, }); + + setIsUpdateDialogOpen(false); + setSelectedRoom(null); + refetch(); + toast.success("Room renamed successfully"); } catch (error) { - toast.error("Error joining watercooler room"); + // Handle 401, 500, or other errors + console.error("Failed to update room:", error); + toast.error("Failed to rename room"); + } + }; + + const handleJoinRoom = useCallback( + async (room: Room) => { + try { + const tokens = await getRoomTokens({ + params: { + path: { + id: room.id, + }, + }, + }); + if (!tokens) { + toast.error("Error joining room"); + return; + } + sounds.callAccepted.play(); + setCallTokens({ + ...tokens, + isRoomCall: true, + timeStarted: new Date(), + hasAudioEnabled: true, + hasCameraEnabled: false, + role: ParticipantRole.NONE, + isRemoteControlEnabled: true, + cameraTrackId: null, + room: room, + }); + } catch (error) { + toast.error("Error joining room"); + } + }, + [getRoomTokens], + ); + + useEffect(() => { + if (searchQuery == "") { + // Set rooms from the fetch response + if (rooms) { + setFilteredRooms(rooms); + } + } else { + // Filter rooms based on search query + if (rooms) { + const filteredRooms = fuseSearch(rooms, searchQuery); + setFilteredRooms(filteredRooms); + } } - }, [getWatercoolerTokens]); + }, [rooms, searchQuery]); + + callTokens?.audioToken; + const isRoomCall = !(callTokens == null || (callTokens !== null && !callTokens.room)); return ( -
- {callTokens?.isRoomCall && } - {!callTokens?.isRoomCall && ( - <> -
-

Rooms

- Beta -
-
-
- Watercooler 🚰 -
- Join room → -
+
+ {isRoomCall && callTokens.room && } + {!isRoomCall && ( +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Search rooms" + className="pl-8 w-full focus-visible:ring-opacity-20 focus-visible:ring-2 focus-visible:ring-blue-300" + />
+ + + + + + + Create new room + Create a new room for your team to collaborate on. + +
+ + +
+ Anyone in your team can modify or remove this room. + + + + + +
+
- + {filteredRooms && filteredRooms.length > 0 ? +
+ {filteredRooms?.map((room) => ( + handleJoinRoom(room)} + size="unsized" + title={room.name} + className="flex-1 min-w-0 text-slate-600" + cornerIcon={ + + + + + + { + setSelectedRoom(room); + setIsUpdateDialogOpen(true); + }} + > + + Rename room + + { + setSelectedRoom(room); + setIsDeleteDialogOpen(true); + }} + > + + Delete room + + + + } + /> + ))} +
+ : setIsCreateDialogOpen(true)} />} + + + + Delete Room + + Are you sure you want to delete this room? This action cannot be undone. + + + + + + + + + +
selectedRoom && handleUpdateRoom(selectedRoom, e)}> + + Rename room + +
+ +
+ + Anyone in your team can modify or remove this room. + + + + + + +
+
+
+
)}
); }; -const WatercoolerRoom = () => { +const EmptyRoomsState = ({ onCreateRoomClick }: { onCreateRoomClick: () => void }) => { + return ( +
+ No rooms +

+ Think of Rooms as permanent, named meeting spots. They're great for your team's regular get-togethers like daily + stand-ups or mob programming sessions. +

+
+ + + Read docs + +
+
+ ); +}; + +const SelectedRoom = ({ room }: { room: Room }) => { const { useMutation } = useAPI(); const participants = useParticipants(); const { teammates, user } = useStore(); + const roomContext = useRoomContext(); - const { mutateAsync: getWatercoolerAnonymous, error: errorAnonymous } = useMutation( - "get", - "/api/auth/watercooler/anonymous", - undefined, - ); + const { mutateAsync: getRoomAnonymous } = useMutation("get", "/api/auth/room/anonymous", undefined); const handleInviteAnonymousUser = useCallback(async () => { - const redirectURL = await getWatercoolerAnonymous({}); + const redirectURL = await getRoomAnonymous({ + params: { + query: { + room_id: room.id, + }, + }, + }); if (!redirectURL || !redirectURL.redirect_url) { toast.error("Error generating link"); return; @@ -87,7 +383,25 @@ const WatercoolerRoom = () => { const link = `${BACKEND_URLS.BASE}${redirectURL.redirect_url}`; await writeText(link); toast.success("Link copied to clipboard"); - }, [getWatercoolerAnonymous]); + }, [getRoomAnonymous, room.id]); + + // Listen for participant connection events and play sound when someone joins + useEffect(() => { + const handleParticipantConnected = (participant: any) => { + // Filter out video/camera tracks to only play sound for actual users + if (!participant.identity.includes("video") && !participant.identity.includes("camera")) { + sounds.callAccepted.play(); + } + }; + + // Add event listener for participant connections + roomContext.on(RoomEvent.ParticipantConnected, handleParticipantConnected); + + // Cleanup event listener on component unmount + return () => { + roomContext.off(RoomEvent.ParticipantConnected, handleParticipantConnected); + }; + }, [roomContext]); // Parse participant identities and match with teammates const participantList = useMemo(() => { @@ -137,7 +451,7 @@ const WatercoolerRoom = () => {
-

Watercooler 🚰

+

{room.name}

Participants ({participantList.length})
diff --git a/web-app/src/openapi.d.ts b/web-app/src/openapi.d.ts index b28af60..c975caf 100644 --- a/web-app/src/openapi.d.ts +++ b/web-app/src/openapi.d.ts @@ -751,14 +751,75 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/watercooler": { + "/api/auth/room": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get LiveKit tokens for joining the team's watercooler room */ + get?: never; + put?: never; + /** Create a new room */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Name of the room */ + name?: string; + }; + }; + }; + responses: { + /** @description LiveKit tokens retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/rooms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all rooms for the user */ get: { parameters: { query?: never; @@ -767,6 +828,45 @@ export interface paths { cookie?: never; }; requestBody?: never; + responses: { + /** @description Rooms for user retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"][]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/room/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get LiveKit tokens for joining the team's selected room */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the room to retrieve */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; responses: { /** @description LiveKit tokens retrieved successfully */ 200: { @@ -784,15 +884,84 @@ export interface paths { }; }; }; - put?: never; + /** Update room details */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the room to update */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Name of the room */ + name?: string; + }; + }; + }; + responses: { + /** @description Room updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; post?: never; - delete?: never; + /** Get LiveKit tokens for joining the team's selected room */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the room to delete */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User successfully deleted (no content) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/auth/watercooler/anonymous": { + "/api/auth/room/anonymous": { parameters: { query?: never; header?: never; @@ -805,7 +974,10 @@ export interface paths { */ get: { parameters: { - query?: never; + query: { + /** @description The ID of the room to generate an anonymous link for */ + room_id: string; + }; header?: never; path?: never; cookie?: never; @@ -1327,6 +1499,12 @@ export interface components { */ trial_ends_at?: string | null; }; + Room: { + /** Format: uuid */ + id: string; + name: string; + user_id: string; + }; Error: { message?: string; }; diff --git a/yarn.lock b/yarn.lock index da8f855..02b3849 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1967,6 +1967,30 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-alert-dialog@npm:^1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-alert-dialog@npm:1.1.15" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-dialog": "npm:1.1.15" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-slot": "npm:1.2.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/038de84ad1b36c162e5f5a3b4034de95558698eb6e3f483d2b1a15f4a502c921c4e6a5a723fe6f29e928ed7001ffe38ac6fd16bb720b1e629892ce7beb1da174 + languageName: node + linkType: hard + "@radix-ui/react-arrow@npm:1.1.7": version: 1.1.7 resolution: "@radix-ui/react-arrow@npm:1.1.7" @@ -2100,7 +2124,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-dialog@npm:^1.1.15": +"@radix-ui/react-dialog@npm:1.1.15, @radix-ui/react-dialog@npm:^1.1.15": version: 1.1.15 resolution: "@radix-ui/react-dialog@npm:1.1.15" dependencies: @@ -6674,9 +6698,11 @@ __metadata: dependencies: "@livekit/components-react": "npm:^2.4.3" "@livekit/krisp-noise-filter": "patch:@livekit/krisp-noise-filter@npm%3A0.3.0#../.yarn/patches/@livekit-krisp-noise-filter-npm-0.3.0-ad3abbd55c.patch" + "@radix-ui/react-alert-dialog": "npm:^1.1.15" "@radix-ui/react-aspect-ratio": "npm:^1.1.7" "@radix-ui/react-avatar": "npm:^1.1.10" "@radix-ui/react-context-menu": "npm:^2.2.16" + "@radix-ui/react-dialog": "npm:^1.1.15" "@radix-ui/react-dropdown-menu": "npm:^2.1.16" "@radix-ui/react-icons": "npm:^1.3.2" "@radix-ui/react-label": "npm:^2.1.7"