diff --git a/backend/backend-error.txt b/backend/backend-error.txt new file mode 100644 index 0000000..e4ac156 Binary files /dev/null and b/backend/backend-error.txt differ diff --git a/backend/src/main/java/com/project/model/Message.java b/backend/src/main/java/com/project/model/Message.java index 55b7d7f..3a63d8c 100644 --- a/backend/src/main/java/com/project/model/Message.java +++ b/backend/src/main/java/com/project/model/Message.java @@ -9,7 +9,7 @@ import java.time.Instant; @Entity -@Table(name = "messages") +@Table(name = "messages", indexes = @Index(name = "idx_messages_room_client_id", columnList = "room_id,client_message_id")) @EntityListeners(AuditingEntityListener.class) public class Message { @@ -33,6 +33,12 @@ public class Message { @Column(name = "user_image", columnDefinition = "TEXT") private String userImage; + @Column(name = "payload", columnDefinition = "TEXT") + private String payload; + + @Column(name = "client_message_id") + private String clientMessageId; + @CreatedDate @Column(name = "created_at", updatable = false, columnDefinition = "TIMESTAMP") private Instant createdAt; @@ -100,6 +106,22 @@ public void setUserImage(String userImage) { this.userImage = userImage; } + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + + public String getClientMessageId() { + return clientMessageId; + } + + public void setClientMessageId(String clientMessageId) { + this.clientMessageId = clientMessageId; + } + public Instant getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/project/model/PinnedNote.java b/backend/src/main/java/com/project/model/PinnedNote.java new file mode 100644 index 0000000..6712631 --- /dev/null +++ b/backend/src/main/java/com/project/model/PinnedNote.java @@ -0,0 +1,70 @@ +package com.project.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.Instant; + +@Entity +@Table( + name = "pinned_notes", + indexes = @Index(name = "idx_pinned_notes_room_pinned", columnList = "room_id,pinned_at"), + uniqueConstraints = @UniqueConstraint( + name = "uk_pinned_notes_room_note", + columnNames = {"room_id", "note_id"} + ) +) +public class PinnedNote { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(name = "room_id", nullable = false) + private String roomId; + + @Column(name = "note_id", nullable = false) + private String noteId; + + @Column(name = "payload", nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(name = "pinned_at", nullable = false) + private Instant pinnedAt; + + public PinnedNote() { + } + + public PinnedNote(String roomId, String noteId, String payload, Instant pinnedAt) { + this.roomId = roomId; + this.noteId = noteId; + this.payload = payload; + this.pinnedAt = pinnedAt; + } + + public String getId() { + return id; + } + + public String getRoomId() { + return roomId; + } + + public String getNoteId() { + return noteId; + } + + public String getPayload() { + return payload; + } + + public Instant getPinnedAt() { + return pinnedAt; + } +} diff --git a/backend/src/main/java/com/project/model/Room.java b/backend/src/main/java/com/project/model/Room.java index 6cd433e..d4f6599 100644 --- a/backend/src/main/java/com/project/model/Room.java +++ b/backend/src/main/java/com/project/model/Room.java @@ -27,6 +27,9 @@ public class Room { @Column(name = "is_private") private boolean isPrivate = false; + @Column(name = "is_closed", nullable = false, columnDefinition = "boolean not null default false") + private boolean isClosed = false; + @Column(name = "password") private String password; @@ -80,6 +83,14 @@ public void setPrivate(boolean aPrivate) { isPrivate = aPrivate; } + public boolean isClosed() { + return isClosed; + } + + public void setClosed(boolean closed) { + isClosed = closed; + } + public String getPassword() { return password; } diff --git a/backend/src/main/java/com/project/model/RoomMediaState.java b/backend/src/main/java/com/project/model/RoomMediaState.java new file mode 100644 index 0000000..347cf97 --- /dev/null +++ b/backend/src/main/java/com/project/model/RoomMediaState.java @@ -0,0 +1,34 @@ +package com.project.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "room_media_state") +public class RoomMediaState { + + @Id + @Column(name = "room_id", nullable = false) + private String roomId; + + @Column(name = "payload", nullable = false, columnDefinition = "TEXT") + private String payload; + + public RoomMediaState() { + } + + public RoomMediaState(String roomId, String payload) { + this.roomId = roomId; + this.payload = payload; + } + + public String getRoomId() { + return roomId; + } + + public String getPayload() { + return payload; + } +} diff --git a/backend/src/main/java/com/project/repository/MessageRepository.java b/backend/src/main/java/com/project/repository/MessageRepository.java index 744b5fe..48a1a41 100644 --- a/backend/src/main/java/com/project/repository/MessageRepository.java +++ b/backend/src/main/java/com/project/repository/MessageRepository.java @@ -5,8 +5,13 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface MessageRepository extends JpaRepository { List findByRoomIdOrderByCreatedAtAsc(String roomId); + List findTop500ByRoomIdOrderByCreatedAtDesc(String roomId); + Optional findFirstByRoomIdAndClientMessageId(String roomId, String clientMessageId); + List findTop500ByRoomIdAndUserIdOrderByCreatedAtDesc(String roomId, String userId); + void deleteByRoomId(String roomId); } diff --git a/backend/src/main/java/com/project/repository/PinnedNoteRepository.java b/backend/src/main/java/com/project/repository/PinnedNoteRepository.java new file mode 100644 index 0000000..aca29c1 --- /dev/null +++ b/backend/src/main/java/com/project/repository/PinnedNoteRepository.java @@ -0,0 +1,14 @@ +package com.project.repository; + +import com.project.model.PinnedNote; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PinnedNoteRepository extends JpaRepository { + List findByRoomIdOrderByPinnedAtDesc(String roomId); + void deleteByRoomIdAndNoteId(String roomId, String noteId); + void deleteByRoomId(String roomId); +} diff --git a/backend/src/main/java/com/project/repository/RoomMediaStateRepository.java b/backend/src/main/java/com/project/repository/RoomMediaStateRepository.java new file mode 100644 index 0000000..8042e45 --- /dev/null +++ b/backend/src/main/java/com/project/repository/RoomMediaStateRepository.java @@ -0,0 +1,9 @@ +package com.project.repository; + +import com.project.model.RoomMediaState; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RoomMediaStateRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/project/service/RoomService.java b/backend/src/main/java/com/project/service/RoomService.java index 56720b3..51e351a 100644 --- a/backend/src/main/java/com/project/service/RoomService.java +++ b/backend/src/main/java/com/project/service/RoomService.java @@ -6,4 +6,7 @@ public interface RoomService { RoomResponse getRoom(String roomId); RoomResponse createRoom(RoomCreateRequest request); + boolean prepareRoomForJoin(String roomId, String ownerId); + boolean isRoomCreator(String roomId, String userId); + boolean closeRoom(String roomId, String ownerId); } diff --git a/backend/src/main/java/com/project/service/RoomServiceImpl.java b/backend/src/main/java/com/project/service/RoomServiceImpl.java index 3539df1..ef80447 100644 --- a/backend/src/main/java/com/project/service/RoomServiceImpl.java +++ b/backend/src/main/java/com/project/service/RoomServiceImpl.java @@ -5,21 +5,34 @@ import com.project.exception.BadRequestException; import com.project.exception.ResourceNotFoundException; import com.project.model.Room; +import com.project.repository.MessageRepository; +import com.project.repository.PinnedNoteRepository; import com.project.repository.RoomRepository; +import com.project.repository.RoomMediaStateRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.util.Optional; @Service public class RoomServiceImpl implements RoomService { private final RoomRepository roomRepository; private final PasswordEncoder passwordEncoder; + private final MessageRepository messageRepository; + private final PinnedNoteRepository pinnedNoteRepository; + private final RoomMediaStateRepository roomMediaStateRepository; - public RoomServiceImpl(RoomRepository roomRepository, PasswordEncoder passwordEncoder) { + public RoomServiceImpl(RoomRepository roomRepository, PasswordEncoder passwordEncoder, + MessageRepository messageRepository, PinnedNoteRepository pinnedNoteRepository, + RoomMediaStateRepository roomMediaStateRepository) { this.roomRepository = roomRepository; this.passwordEncoder = passwordEncoder; + this.messageRepository = messageRepository; + this.pinnedNoteRepository = pinnedNoteRepository; + this.roomMediaStateRepository = roomMediaStateRepository; } @Override @@ -31,9 +44,70 @@ public RoomResponse getRoom(String roomId) { Room room = roomRepository.findByRoomId(roomId.trim()) .orElseThrow(() -> new ResourceNotFoundException("Room not found")); + if (room.isClosed()) { + throw new ResourceNotFoundException("Room not found"); + } + return toResponse(room); } + @Override + @Transactional + public synchronized boolean prepareRoomForJoin(String roomId, String ownerId) { + if (roomId == null || roomId.isBlank() || ownerId == null || ownerId.isBlank()) { + return false; + } + + String normalizedRoomId = roomId.trim(); + return roomRepository.findByRoomId(normalizedRoomId) + .map(room -> !room.isClosed()) + .orElseGet(() -> { + Room room = new Room(normalizedRoomId, ownerId.trim(), false, null); + Instant now = Instant.now(); + room.setCreatedAt(now); + room.setUpdatedAt(now); + roomRepository.save(room); + return true; + }); + } + + @Override + public boolean isRoomCreator(String roomId, String userId) { + if (roomId == null || roomId.isBlank() || userId == null || userId.isBlank()) { + return false; + } + + return roomRepository.findByRoomId(roomId.trim()) + .filter(room -> !room.isClosed()) + .map(room -> userId.trim().equals(room.getOwnerId())) + .orElse(false); + } + + @Override + @Transactional + public synchronized boolean closeRoom(String roomId, String ownerId) { + if (roomId == null || roomId.isBlank() || ownerId == null || ownerId.isBlank()) { + return false; + } + + Optional roomOptional = roomRepository.findByRoomId(roomId.trim()); + if (roomOptional.isEmpty()) { + return false; + } + + Room room = roomOptional.get(); + if (room.isClosed() || !ownerId.trim().equals(room.getOwnerId())) { + return false; + } + + room.setClosed(true); + roomRepository.save(room); + messageRepository.deleteByRoomId(room.getRoomId()); + pinnedNoteRepository.deleteByRoomId(room.getRoomId()); + roomMediaStateRepository.deleteById(room.getRoomId()); + return true; + } + @Override public RoomResponse createRoom(RoomCreateRequest request) { if (request.getRoomId() == null || request.getRoomId().isBlank()) { diff --git a/backend/src/main/java/com/project/service/RoomStateManager.java b/backend/src/main/java/com/project/service/RoomStateManager.java index 3270020..97d3766 100644 --- a/backend/src/main/java/com/project/service/RoomStateManager.java +++ b/backend/src/main/java/com/project/service/RoomStateManager.java @@ -20,6 +20,9 @@ public class RoomStateManager { // roomId -> List private final Map> roomQueues = new ConcurrentHashMap<>(); + // roomId -> previously played videos, newest first + private final Map>> roomVideoHistory = new ConcurrentHashMap<>(); + // roomId -> List> private final Map>> roomWhiteboardStrokes = new ConcurrentHashMap<>(); @@ -29,6 +32,10 @@ public class RoomStateManager { // roomId -> List> private final Map>> roomQA = new ConcurrentHashMap<>(); + private final Map>> roomMessages = new ConcurrentHashMap<>(); + + private final Map>> roomPinnedNotes = new ConcurrentHashMap<>(); + // roomId -> List> (shared files) private final Map>> roomFiles = new ConcurrentHashMap<>(); @@ -100,6 +107,31 @@ public VideoState getVideoState(String roomId) { return roomVideoStates.get(roomId); } + public boolean hasMediaState(String roomId) { + return roomVideoStates.containsKey(roomId) + || roomQueues.containsKey(roomId) + || roomVideoHistory.containsKey(roomId); + } + + public void restoreMediaState(String roomId, String url, boolean isPlaying, double currentTime, + List queue, List> history) { + if (url != null && !url.isBlank()) { + roomVideoStates.put(roomId, new VideoState(url, isPlaying, currentTime)); + } + List roomQueue = getQueue(roomId); + synchronized (roomQueue) { + roomQueue.clear(); + if (queue != null) roomQueue.addAll(queue); + } + List> roomHistory = getVideoHistory(roomId); + synchronized (roomHistory) { + roomHistory.clear(); + if (history != null) { + history.stream().limit(100).forEach(item -> roomHistory.add(new HashMap<>(item))); + } + } + } + public void updateVideoUrl(String roomId, String url) { if (url != null && !url.isBlank()) { VideoState state = roomVideoStates.computeIfAbsent(roomId, k -> new VideoState(url, true, 0)); @@ -245,6 +277,22 @@ public Set getSocketIdsInRoom(String roomId) { return new HashSet<>(roomUsers.keySet()); } + public synchronized Set closeRoom(String roomId) { + Set socketIds = getSocketIdsInRoom(roomId); + rooms.remove(roomId); + roomNames.remove(roomId); + roomVideoStates.remove(roomId); + roomQueues.remove(roomId); + roomVideoHistory.remove(roomId); + roomWhiteboardStrokes.remove(roomId); + roomPolls.remove(roomId); + roomQA.remove(roomId); + roomMessages.remove(roomId); + roomPinnedNotes.remove(roomId); + roomFiles.remove(roomId); + return socketIds; + } + public synchronized DisconnectResult removeUserFromAllRooms(String socketId) { DisconnectResult result = null; for (Iterator>>> it = rooms.entrySet().iterator(); it.hasNext(); ) { @@ -260,11 +308,10 @@ public synchronized DisconnectResult removeUserFromAllRooms(String socketId) { if (roomUsers.isEmpty()) { it.remove(); roomNames.remove(roomId); - roomVideoStates.remove(roomId); - roomQueues.remove(roomId); roomWhiteboardStrokes.remove(roomId); roomPolls.remove(roomId); roomQA.remove(roomId); + roomPinnedNotes.remove(roomId); } else if (wasPrimary) { newPrimarySocketId = roomUsers.keySet().iterator().next(); Map newPrimary = roomUsers.get(newPrimarySocketId); @@ -290,6 +337,13 @@ public void addToQueue(String roomId, Object item) { queue.add(item); } + public List getQueueSnapshot(String roomId) { + List queue = getQueue(roomId); + synchronized (queue) { + return new ArrayList<>(queue); + } + } + public void removeFromQueue(String roomId, int index) { List queue = getQueue(roomId); synchronized (queue) { @@ -309,6 +363,51 @@ public Object playNext(String roomId) { return null; } + public List> getVideoHistory(String roomId) { + return roomVideoHistory.computeIfAbsent( + roomId, + k -> Collections.synchronizedList(new ArrayList<>()) + ); + } + + public List> getVideoHistorySnapshot(String roomId) { + List> history = getVideoHistory(roomId); + synchronized (history) { + List> snapshot = new ArrayList<>(history.size()); + for (Map item : history) { + snapshot.add(new HashMap<>(item)); + } + return snapshot; + } + } + + public void addVideoHistory(String roomId, Map video) { + Object url = video.get("url"); + if (url == null || String.valueOf(url).isBlank()) return; + List> history = getVideoHistory(roomId); + synchronized (history) { + history.removeIf(item -> Objects.equals(String.valueOf(item.get("url")), String.valueOf(url))); + history.add(0, new HashMap<>(video)); + if (history.size() > 100) history.subList(100, history.size()).clear(); + } + } + + public void removeVideoHistory(String roomId, String url) { + List> history = roomVideoHistory.get(roomId); + if (history == null) return; + synchronized (history) { + history.removeIf(item -> Objects.equals(String.valueOf(item.get("url")), url)); + } + } + + public void clearVideoHistory(String roomId) { + List> history = roomVideoHistory.get(roomId); + if (history == null) return; + synchronized (history) { + history.clear(); + } + } + // ── Polls ───────────────────────────────────────────────────────────────── public List> getPolls(String roomId) { @@ -441,6 +540,97 @@ public void deleteQuestion(String roomId, String questionId) { } } + // ── Pinned Notes ───────────────────────────────────────────────────────────── + + public List> getRoomMessages(String roomId) { + List> messages = roomMessages.computeIfAbsent( + roomId, + k -> Collections.synchronizedList(new ArrayList<>()) + ); + synchronized (messages) { + return new ArrayList<>(messages); + } + } + + public void addRoomMessage(String roomId, Map message) { + List> messages = roomMessages.computeIfAbsent( + roomId, + k -> Collections.synchronizedList(new ArrayList<>()) + ); + synchronized (messages) { + messages.add(new HashMap<>(message)); + if (messages.size() > 500) { + messages.subList(0, messages.size() - 500).clear(); + } + } + } + + public void updateRoomMessage(String roomId, Map updatedMessage) { + List> messages = roomMessages.computeIfAbsent( + roomId, + k -> Collections.synchronizedList(new ArrayList<>()) + ); + synchronized (messages) { + for (int i = 0; i < messages.size(); i++) { + if (Objects.equals( + String.valueOf(messages.get(i).get("id")), + String.valueOf(updatedMessage.get("id")) + )) { + messages.set(i, new HashMap<>(updatedMessage)); + return; + } + } + } + } + + public void removeRoomMessage(String roomId, String messageId) { + List> messages = roomMessages.get(roomId); + if (messages == null) return; + synchronized (messages) { + messages.removeIf(message -> Objects.equals( + String.valueOf(message.get("id")), + messageId + )); + } + } + +public List> getPinnedNotes(String roomId) { + return roomPinnedNotes.computeIfAbsent( + roomId, + k -> Collections.synchronizedList(new ArrayList<>()) + ); +} + +public void addPinnedNote(String roomId, Map note) { + List> notes = getPinnedNotes(roomId); + + synchronized (notes) { + notes.removeIf(existing -> Objects.equals( + String.valueOf(existing.get("id")), + String.valueOf(note.get("id")) + )); + notes.add(0, new HashMap<>(note)); + + // Keep maximum 100 pinned notes per room + if (notes.size() > 100) { + notes.subList(100, notes.size()).clear(); + } + } +} + +public void removePinnedNote(String roomId, String noteId) { + List> notes = getPinnedNotes(roomId); + + synchronized (notes) { + notes.removeIf(note -> + Objects.equals( + String.valueOf(note.get("id")), + String.valueOf(noteId) + ) + ); + } +} + // ── Shared Files ──────────────────────────────────────────────────────────── public List> getRoomFiles(String roomId) { diff --git a/backend/src/main/java/com/project/websocket/PeerJsWebSocketHandler.java b/backend/src/main/java/com/project/websocket/PeerJsWebSocketHandler.java index 27c57df..a32f057 100644 --- a/backend/src/main/java/com/project/websocket/PeerJsWebSocketHandler.java +++ b/backend/src/main/java/com/project/websocket/PeerJsWebSocketHandler.java @@ -73,15 +73,13 @@ protected void handleTextMessage(WebSocketSession session, TextMessage message) // Handle PeerJS protocol message types switch (type.toUpperCase()) { case "HEARTBEAT", "PING" -> { - // Heartbeat / Ping response to prevent client timeouts - session.sendMessage(new TextMessage("{\"type\":\"PONG\"}")); return; } case "OFFER", "ANSWER", "CANDIDATE", "LEAVE" -> { // These are signaling messages that should be forwarded to the target peer String dst = root.path("dst").asText(""); if (!dst.isBlank()) { - forwardToPeer(dst, payload); + forwardToPeer(dst, sessionToPeerId.get(session.getId()), root); } else { log.warn("PeerJS {} message missing 'dst' field: {}", type, payload); } @@ -91,7 +89,7 @@ protected void handleTextMessage(WebSocketSession session, TextMessage message) // Unknown message type, try to forward if it has dst String dst = root.path("dst").asText(""); if (!dst.isBlank()) { - forwardToPeer(dst, payload); + forwardToPeer(dst, sessionToPeerId.get(session.getId()), root); } else { log.debug("PeerJS unknown message type '{}' without dst: {}", type, payload); } @@ -102,12 +100,18 @@ protected void handleTextMessage(WebSocketSession session, TextMessage message) } } - private void forwardToPeer(String dst, String payload) { + private void forwardToPeer(String dst, String sourcePeerId, JsonNode payload) { + if (sourcePeerId == null || !payload.isObject()) { + log.warn("Cannot forward PeerJS message without a registered source peer: dst={}", dst); + return; + } WebSocketSession targetSession = peers.get(dst); if (targetSession != null && targetSession.isOpen()) { synchronized (targetSession) { try { - targetSession.sendMessage(new TextMessage(payload)); + ObjectNode forwardedMessage = ((ObjectNode) payload).deepCopy(); + forwardedMessage.put("src", sourcePeerId); + targetSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(forwardedMessage))); log.debug("PeerJS forwarded message to dst={}", dst); } catch (Exception e) { log.error("Failed to forward message to dst={}", dst, e); @@ -124,7 +128,7 @@ private void forwardToPeer(String dst, String payload) { public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String peerId = sessionToPeerId.remove(session.getId()); if (peerId != null) { - peers.remove(peerId); + peers.remove(peerId, session); log.info("PeerJS client disconnected: peerId={}, status={}", peerId, status); // Notify other peers that this peer has left (optional) diff --git a/backend/src/main/java/com/project/websocket/SocketIoWebSocketHandler.java b/backend/src/main/java/com/project/websocket/SocketIoWebSocketHandler.java index 2defa55..2fbaa6d 100644 --- a/backend/src/main/java/com/project/websocket/SocketIoWebSocketHandler.java +++ b/backend/src/main/java/com/project/websocket/SocketIoWebSocketHandler.java @@ -2,8 +2,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; import com.project.service.AiAssistantService; import com.project.service.RoomStateManager; +import com.project.model.Message; +import com.project.model.PinnedNote; +import com.project.model.RoomMediaState; +import com.project.repository.MessageRepository; +import com.project.repository.PinnedNoteRepository; +import com.project.repository.RoomMediaStateRepository; +import com.project.service.RoomService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -13,6 +21,7 @@ import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; +import java.time.Instant; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; @@ -33,14 +42,27 @@ public class SocketIoWebSocketHandler extends TextWebSocketHandler { private final ObjectMapper objectMapper; private final RoomStateManager roomStateManager; private final AiAssistantService aiAssistantService; + private final MessageRepository messageRepository; + private final PinnedNoteRepository pinnedNoteRepository; + private final RoomMediaStateRepository roomMediaStateRepository; + private final RoomService roomService; + private final Object roomLifecycleLock = new Object(); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); public SocketIoWebSocketHandler(ObjectMapper objectMapper, RoomStateManager roomStateManager, - AiAssistantService aiAssistantService) { + AiAssistantService aiAssistantService, + MessageRepository messageRepository, + PinnedNoteRepository pinnedNoteRepository, + RoomMediaStateRepository roomMediaStateRepository, + RoomService roomService) { this.objectMapper = objectMapper; this.roomStateManager = roomStateManager; this.aiAssistantService = aiAssistantService; + this.messageRepository = messageRepository; + this.pinnedNoteRepository = pinnedNoteRepository; + this.roomMediaStateRepository = roomMediaStateRepository; + this.roomService = roomService; } @Override @@ -119,12 +141,15 @@ private void handleSocketEvent(WebSocketSession session, String event, JsonNode switch (event) { case "join-room" -> handleJoinRoom(session, dataMap); + case "delete-room" -> handleDeleteRoom(socketId, roomId); case "video-play" -> handleVideoPlay(socketId, roomId, dataMap); case "video-pause" -> handleVideoPause(socketId, roomId, dataMap); case "video-seek" -> handleVideoSeek(socketId, roomId, dataMap); case "video-url-change" -> handleVideoUrlChange(socketId, roomId, dataMap); case "add-to-queue" -> handleAddToQueue(roomId, dataMap); case "remove-from-queue" -> handleRemoveFromQueue(socketId, roomId, dataMap); + case "remove-video-history" -> handleRemoveVideoHistory(socketId, roomId, dataMap); + case "clear-video-history" -> handleClearVideoHistory(socketId, roomId); case "play-next" -> handlePlayNext(socketId, roomId); case "draw-line" -> handleDrawLine(socketId, roomId, dataMap); case "clear-whiteboard" -> handleClearWhiteboard(socketId, roomId); @@ -135,6 +160,8 @@ private void handleSocketEvent(WebSocketSession session, String event, JsonNode case "request-sync-state" -> broadcastToOthersInRoom(roomId, socketId, "sync-state-request", Map.of("requesterId", socketId)); case "sync-state-response" -> handleSyncStateResponse(dataMap); case "chat-message" -> handleChatMessage(socketId, roomId, dataMap); + case "edit-chat-message" -> handleEditChatMessage(socketId, roomId, dataMap); + case "delete-chat-message" -> handleDeleteChatMessage(socketId, roomId, dataMap); case "typing" -> broadcastToOthersInRoom(roomId, socketId, "typing", dataMap); case "toggle-mic" -> handleToggleMic(roomId, dataMap); case "toggle-camera" -> handleToggleCamera(roomId, dataMap); @@ -158,6 +185,9 @@ private void handleSocketEvent(WebSocketSession session, String event, JsonNode case "upvote-question" -> handleUpvoteQuestion(roomId, dataMap); case "answer-question" -> handleAnswerQuestion(roomId, dataMap); case "delete-question" -> handleDeleteQuestion(roomId, dataMap); + case "pin-message" -> handlePinMessage(roomId, dataMap); + case "unpin-message" -> handleUnpinMessage(roomId, dataMap); + case "request-pinned-notes" -> handleRequestPinnedNotes(socketId, roomId); case "file-share" -> handleFileShare(socketId, roomId, dataMap); case "request-files" -> handleRequestFiles(socketId, roomId); default -> log.debug("Unhandled event: {}", event); @@ -169,11 +199,27 @@ private void handleSocketEvent(WebSocketSession session, String event, JsonNode @SuppressWarnings("unchecked") private void handleJoinRoom(WebSocketSession session, Map dataMap) { String socketId = session.getId(); - String roomId = String.valueOf(dataMap.get("roomId")); + String roomId = Objects.toString(dataMap.get("roomId"), "").trim(); Map rawUser = (Map) dataMap.get("user"); + String ownerId = rawUser == null ? "" : Objects.toString(rawUser.get("userId"), ""); + + Map enhancedUser; + synchronized (roomLifecycleLock) { + if (!roomService.prepareRoomForJoin(roomId, ownerId)) { + emitToSocket(socketId, "room-closed", Map.of("roomId", roomId)); + try { + session.close(CloseStatus.NORMAL); + } catch (IOException e) { + log.warn("Could not close rejected room connection for socketId={}", socketId, e); + } + return; + } - socketToRoom.put(socketId, roomId); - Map enhancedUser = roomStateManager.joinRoom(roomId, socketId, rawUser); + socketToRoom.put(socketId, roomId); + enhancedUser = roomStateManager.joinRoom(roomId, socketId, rawUser); + enhancedUser.put("isCreator", roomService.isRoomCreator(roomId, ownerId)); + } + restoreMediaState(roomId); // Broadcast user-joined to other users in room broadcastToOthersInRoom(roomId, socketId, "user-joined", Map.of("socketId", socketId, "user", enhancedUser)); @@ -204,6 +250,8 @@ private void handleJoinRoom(WebSocketSession session, Map dataMa // Send current queue emitToSocket(socketId, "queue-updated", Map.of("queue", roomStateManager.getQueue(roomId))); + emitToSocket(socketId, "video-history-updated", + Map.of("history", roomStateManager.getVideoHistorySnapshot(roomId))); // Send whiteboard stroke history List> strokes = roomStateManager.getWhiteboardStrokes(roomId); @@ -218,6 +266,36 @@ private void handleJoinRoom(WebSocketSession session, Map dataMa // Send current polls and Q&A emitToSocket(socketId, "polls-updated", Map.of("polls", roomStateManager.getPolls(roomId))); emitToSocket(socketId, "qa-updated", Map.of("questions", roomStateManager.getQuestions(roomId))); + emitToSocket(socketId, "chat-history", Map.of("messages", loadChatHistory(roomId))); + emitToSocket(socketId, "pinned-notes-updated", Map.of("notes", loadPinnedNotes(roomId))); + } + + private void handleDeleteRoom(String socketId, String roomId) { + Map user = roomStateManager.getUser(roomId, socketId); + String ownerId = user == null ? "" : Objects.toString(user.get("userId"), ""); + Set roomSocketIds; + + synchronized (roomLifecycleLock) { + if (!roomService.closeRoom(roomId, ownerId)) { + emitToSocket(socketId, "room-delete-error", + Map.of("message", "Only the person who created this room can delete it.")); + return; + } + + emitToRoom(roomId, "room-closed", Map.of("roomId", roomId)); + roomSocketIds = roomStateManager.closeRoom(roomId); + } + + for (String roomSocketId : roomSocketIds) { + WebSocketSession roomSession = sessions.get(roomSocketId); + if (roomSession != null && roomSession.isOpen()) { + try { + roomSession.close(CloseStatus.NORMAL); + } catch (IOException e) { + log.warn("Could not close deleted room connection for socketId={}", roomSocketId, e); + } + } + } } private void handleVideoPlay(String socketId, String roomId, Map dataMap) { @@ -227,6 +305,7 @@ private void handleVideoPlay(String socketId, String roomId, Map time = num.doubleValue(); } roomStateManager.updateVideoPlay(roomId, time); + persistMediaState(roomId); broadcastToOthersInRoom(roomId, socketId, "video-play", dataMap); } } @@ -238,6 +317,7 @@ private void handleVideoPause(String socketId, String roomId, Map time = num.doubleValue(); } roomStateManager.updateVideoSeek(roomId, time); + persistMediaState(roomId); broadcastToOthersInRoom(roomId, socketId, "video-seek", dataMap); } } @@ -256,8 +337,11 @@ private void handleVideoSeek(String socketId, String roomId, Map private void handleVideoUrlChange(String socketId, String roomId, Map dataMap) { if (roomStateManager.isUserOwner(roomId, socketId)) { String newUrl = String.valueOf(dataMap.get("newUrl")); + recordCurrentVideoInHistory(roomId, newUrl); roomStateManager.updateVideoUrl(roomId, newUrl); + persistMediaState(roomId); emitToRoom(roomId, "video-url-change", dataMap); + emitVideoHistory(roomId); } } @@ -265,6 +349,7 @@ private void handleAddToQueue(String roomId, Map dataMap) { Object item = dataMap.get("videoInfo") != null ? dataMap.get("videoInfo") : dataMap.get("videoUrl"); if (item != null) { roomStateManager.addToQueue(roomId, item); + persistMediaState(roomId); emitToRoom(roomId, "queue-updated", Map.of("queue", roomStateManager.getQueue(roomId))); } } @@ -273,10 +358,28 @@ private void handleRemoveFromQueue(String socketId, String roomId, Map dataMap) { + if (!roomStateManager.isUserOwner(roomId, socketId)) return; + String url = String.valueOf(dataMap.getOrDefault("url", "")); + if (url.isBlank()) return; + + roomStateManager.removeVideoHistory(roomId, url); + persistMediaState(roomId); + emitVideoHistory(roomId); + } + + private void handleClearVideoHistory(String socketId, String roomId) { + if (!roomStateManager.isUserOwner(roomId, socketId)) return; + roomStateManager.clearVideoHistory(roomId); + persistMediaState(roomId); + emitVideoHistory(roomId); + } + @SuppressWarnings("unchecked") private void handlePlayNext(String socketId, String roomId) { if (roomStateManager.isUserOwner(roomId, socketId)) { @@ -286,10 +389,82 @@ private void handlePlayNext(String socketId, String roomId) { ? String.valueOf(((Map) nextItem).get("url")) : String.valueOf(nextItem); + recordCurrentVideoInHistory(roomId, newUrl); roomStateManager.updateVideoUrl(roomId, newUrl); + persistMediaState(roomId); emitToRoom(roomId, "video-url-change", Map.of("newUrl", newUrl)); emitToRoom(roomId, "queue-updated", Map.of("queue", roomStateManager.getQueue(roomId))); + emitVideoHistory(roomId); + } + } + } + + private void recordCurrentVideoInHistory(String roomId, String nextUrl) { + RoomStateManager.VideoState current = roomStateManager.getVideoState(roomId); + if (current == null || current.getUrl() == null || current.getUrl().isBlank() + || Objects.equals(current.getUrl(), nextUrl)) return; + + Map previous = new HashMap<>(); + previous.put("url", current.getUrl()); + previous.put("title", "YouTube Video"); + previous.put("thumbnail", youtubeThumbnail(current.getUrl())); + roomStateManager.addVideoHistory(roomId, previous); + } + + private String youtubeThumbnail(String url) { + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("(?:youtube\\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\\.be/)([^\"&?/\\s]{11})", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(url); + return matcher.find() ? "https://img.youtube.com/vi/" + matcher.group(1) + "/mqdefault.jpg" : ""; + } + + private void emitVideoHistory(String roomId) { + emitToRoom(roomId, "video-history-updated", + Map.of("history", roomStateManager.getVideoHistorySnapshot(roomId))); + } + + private void restoreMediaState(String roomId) { + if (roomStateManager.hasMediaState(roomId)) return; + roomMediaStateRepository.findById(roomId).ifPresent(saved -> { + try { + Map payload = objectMapper.readValue( + saved.getPayload(), + new TypeReference>() {} + ); + String url = payload.get("url") == null ? null : String.valueOf(payload.get("url")); + boolean isPlaying = Boolean.TRUE.equals(payload.get("isPlaying")); + double currentTime = payload.get("currentTime") instanceof Number number ? number.doubleValue() : 0; + List queue = payload.get("queue") instanceof List list + ? new ArrayList<>(list) + : Collections.emptyList(); + List> history = new ArrayList<>(); + if (payload.get("history") instanceof List list) { + for (Object item : list) { + Map video = asStringKeyedMap(item); + if (video != null) history.add(video); + } + } + roomStateManager.restoreMediaState(roomId, url, isPlaying, currentTime, queue, history); + } catch (IOException ex) { + log.error("Failed to restore saved media state for room {}", roomId, ex); + } + }); + } + + private void persistMediaState(String roomId) { + try { + Map payload = new HashMap<>(); + RoomStateManager.VideoState videoState = roomStateManager.getVideoState(roomId); + if (videoState != null) { + payload.put("url", videoState.getUrl()); + payload.put("isPlaying", videoState.isPlaying()); + payload.put("currentTime", videoState.getCalculatedCurrentTime()); } + payload.put("queue", roomStateManager.getQueueSnapshot(roomId)); + payload.put("history", roomStateManager.getVideoHistorySnapshot(roomId)); + roomMediaStateRepository.save(new RoomMediaState(roomId, objectMapper.writeValueAsString(payload))); + } catch (IOException ex) { + log.error("Failed to serialize saved media state for room {}", roomId, ex); } } @@ -347,27 +522,256 @@ private void handleSyncStateResponse(Map dataMap) { } } - @SuppressWarnings("unchecked") private void handleChatMessage(String socketId, String roomId, Map dataMap) { Object msgObj = dataMap.get("message"); if (msgObj == null) return; - emitToRoom(roomId, "chat-message", msgObj); + Map message = asStringKeyedMap(msgObj); + if (message == null) return; + + Map roomUser = roomStateManager.getUser(roomId, socketId); + if (roomUser == null) return; + message.put("userId", String.valueOf(roomUser.getOrDefault("userId", "anonymous"))); + message.put("userName", String.valueOf(roomUser.getOrDefault("name", "Guest"))); + message.put("userImage", String.valueOf(roomUser.getOrDefault("image", ""))); + + persistChatMessage(roomId, message); + emitToRoom(roomId, "chat-message", message); + + String text = String.valueOf(message.getOrDefault("text", "")); + if (aiAssistantService.isAiTrigger(text)) { + emitToRoom(roomId, "typing", Map.of("userName", "SyncRoom AI 🤖")); + + scheduler.schedule(() -> { + Map aiResponse = aiAssistantService.generateAiResponse(text); + persistChatMessage(roomId, aiResponse); + emitToRoom(roomId, "chat-message", aiResponse); + }, 400, TimeUnit.MILLISECONDS); + } + } + + private void handleEditChatMessage(String socketId, String roomId, Map dataMap) { + String messageId = String.valueOf(dataMap.getOrDefault("messageId", "")); + String text = String.valueOf(dataMap.getOrDefault("text", "")).trim(); + Message savedMessage = findOwnedTextMessage(socketId, roomId, messageId); + if (savedMessage == null) return; + if (text.isBlank()) { + emitChatOperationError(socketId, "A message cannot be empty."); + return; + } + + try { + Map message = objectMapper.readValue( + savedMessage.getPayload(), + new TypeReference>() {} + ); + message.put("text", text); + message.put("edited", true); + savedMessage.setText(text); + savedMessage.setPayload(writeChatPayload(message)); + messageRepository.save(savedMessage); + roomStateManager.updateRoomMessage(roomId, message); + emitToRoom(roomId, "chat-message-updated", message); + } catch (IOException ex) { + log.error("Failed to update chat message {} in room {}", messageId, roomId, ex); + emitChatOperationError(socketId, "The message could not be updated. Please try again."); + } + } + + private void handleDeleteChatMessage(String socketId, String roomId, Map dataMap) { + String messageId = String.valueOf(dataMap.getOrDefault("messageId", "")); + Message savedMessage = findOwnedTextMessage(socketId, roomId, messageId); + if (savedMessage == null) return; - if (msgObj instanceof Map) { - Map msg = (Map) msgObj; - String text = String.valueOf(msg.getOrDefault("text", "")); - if (aiAssistantService.isAiTrigger(text)) { - emitToRoom(roomId, "typing", Map.of("userName", "SyncRoom AI 🤖")); + messageRepository.delete(savedMessage); + roomStateManager.removeRoomMessage(roomId, messageId); + emitToRoom(roomId, "chat-message-deleted", Map.of("messageId", messageId)); + } + + private Message findOwnedTextMessage(String socketId, String roomId, String messageId) { + Map roomUser = roomStateManager.getUser(roomId, socketId); + if (roomUser == null || messageId.isBlank()) return null; + + String userId = String.valueOf(roomUser.getOrDefault("userId", "anonymous")); + Optional saved = messageRepository.findFirstByRoomIdAndClientMessageId(roomId, messageId); + if (saved.isEmpty()) { + for (Message candidate : messageRepository.findTop500ByRoomIdAndUserIdOrderByCreatedAtDesc(roomId, userId)) { + if (candidate.getPayload() == null) continue; + try { + Map payload = objectMapper.readValue( + candidate.getPayload(), + new TypeReference>() {} + ); + if (Objects.equals(String.valueOf(payload.get("id")), messageId)) { + candidate.setClientMessageId(messageId); + saved = Optional.of(messageRepository.save(candidate)); + break; + } + } catch (IOException ex) { + log.error("Failed to inspect chat message {} in room {}", candidate.getId(), roomId, ex); + } + } + } + if (saved.isEmpty() || !Objects.equals(saved.get().getUserId(), userId)) { + emitChatOperationError(socketId, "You can only change your own text messages."); + return null; + } + if (saved.get().getPayload() == null) { + emitChatOperationError(socketId, "This message cannot be changed."); + return null; + } + + try { + Map payload = objectMapper.readValue( + saved.get().getPayload(), + new TypeReference>() {} + ); + if ("file".equals(payload.get("type")) || Boolean.TRUE.equals(payload.get("isAi"))) return null; + } catch (IOException | IllegalArgumentException ex) { + log.error("Failed to verify chat message {} in room {}", messageId, roomId, ex); + emitChatOperationError(socketId, "The message could not be verified."); + return null; + } + return saved.get(); + } + + private void emitChatOperationError(String socketId, String message) { + emitToSocket(socketId, "chat-operation-error", Map.of("message", message)); + } + + private List> loadChatHistory(String roomId) { + List savedMessages = messageRepository.findTop500ByRoomIdOrderByCreatedAtDesc(roomId); + if (savedMessages.isEmpty()) { + return roomStateManager.getRoomMessages(roomId); + } + List> history = new ArrayList<>(savedMessages.size()); + for (Message savedMessage : savedMessages) { + try { + Map message = savedMessage.getPayload() == null + ? new HashMap<>() + : objectMapper.readValue( + savedMessage.getPayload(), + new TypeReference>() {} + ); + message.putIfAbsent("id", savedMessage.getId()); + message.putIfAbsent("userId", savedMessage.getUserId()); + message.putIfAbsent("userName", savedMessage.getUserName()); + message.putIfAbsent("userImage", savedMessage.getUserImage()); + message.putIfAbsent("text", savedMessage.getText()); + message.putIfAbsent("timestamp", savedMessage.getCreatedAt() == null + ? Instant.now().toString() + : savedMessage.getCreatedAt().toString()); + history.add(message); + } catch (IOException ex) { + log.error("Failed to restore chat message {} for room {}", savedMessage.getId(), roomId, ex); + } + } + Collections.reverse(history); + return history; + } + + private void persistChatMessage(String roomId, Map messageData) { + String text = String.valueOf(messageData.getOrDefault("text", "")); + if (text.isBlank()) { + log.warn("Not saving an empty chat message for room {}", roomId); + return; + } + + Message message = new Message( + roomId, + String.valueOf(messageData.getOrDefault("userId", "unknown")), + text, + String.valueOf(messageData.getOrDefault("userName", "Guest")), + String.valueOf(messageData.getOrDefault("userImage", "")) + ); + message.setPayload(writeChatPayload(messageData)); + message.setClientMessageId(String.valueOf(messageData.getOrDefault("id", ""))); + message.setCreatedAt(readMessageTimestamp(messageData.get("timestamp"))); + messageRepository.save(message); + roomStateManager.addRoomMessage(roomId, messageData); + } + + private String writeChatPayload(Map messageData) { + try { + return objectMapper.writeValueAsString(messageData); + } catch (IOException ex) { + throw new IllegalStateException("Failed to save chat message payload", ex); + } + } + + private Instant readMessageTimestamp(Object timestamp) { + if (timestamp instanceof String value) { + try { + return Instant.parse(value); + } catch (RuntimeException ex) { + log.warn("Invalid chat timestamp '{}'; using server time", value); + } + } + return Instant.now(); + } + + private Map asStringKeyedMap(Object value) { + if (!(value instanceof Map source)) return null; + Map result = new HashMap<>(); + source.forEach((key, item) -> { + if (key instanceof String stringKey) result.put(stringKey, item); + }); + return result; + } + + private void handlePinMessage(String roomId, Map dataMap) { + Map message = asStringKeyedMap(dataMap.get("message")); + if (message == null || message.get("id") == null) return; + String noteId = String.valueOf(message.get("id")); + pinnedNoteRepository.deleteByRoomIdAndNoteId(roomId, noteId); + pinnedNoteRepository.save(new PinnedNote( + roomId, + noteId, + writeChatPayload(message), + Instant.now() + )); + roomStateManager.addPinnedNote(roomId, message); + + emitToRoom(roomId, "pinned-notes-updated", + Map.of("notes", loadPinnedNotes(roomId))); + } + + private void handleUnpinMessage(String roomId, Map dataMap) { + String noteId = String.valueOf(dataMap.get("noteId")); - scheduler.schedule(() -> { - Map aiResponse = aiAssistantService.generateAiResponse(text); - emitToRoom(roomId, "chat-message", aiResponse); - }, 400, TimeUnit.MILLISECONDS); + if (noteId == null || "null".equals(noteId)) { + return; + } + + pinnedNoteRepository.deleteByRoomIdAndNoteId(roomId, noteId); + roomStateManager.removePinnedNote(roomId, noteId); + + emitToRoom(roomId, "pinned-notes-updated", + Map.of("notes", loadPinnedNotes(roomId))); + } + + private void handleRequestPinnedNotes(String socketId, String roomId) { + emitToSocket(socketId, "pinned-notes-updated", + Map.of("notes", loadPinnedNotes(roomId))); + } + + private List> loadPinnedNotes(String roomId) { + List> notes = new ArrayList<>(); + for (PinnedNote pinnedNote : pinnedNoteRepository.findByRoomIdOrderByPinnedAtDesc(roomId)) { + try { + Map note = objectMapper.readValue( + pinnedNote.getPayload(), + new TypeReference>() {} + ); + notes.add(note); + } catch (IOException ex) { + log.error("Failed to restore pinned note {} for room {}", pinnedNote.getNoteId(), roomId, ex); } } + return notes; } + private void handleToggleMic(String roomId, Map dataMap) { String userId = String.valueOf(dataMap.get("userId")); boolean isMuted = Boolean.TRUE.equals(dataMap.get("isMuted")); @@ -541,6 +945,21 @@ private void handleFileShare(String socketId, String roomId, Map fileInfo.put("senderId", socketId); fileInfo.put("timestamp", System.currentTimeMillis()); roomStateManager.addRoomFile(roomId, fileInfo); + + Map chatFile = new HashMap<>(); + chatFile.put("id", fileInfo.getOrDefault("id", UUID.randomUUID().toString())); + chatFile.put("userId", socketId); + chatFile.put("userName", fileInfo.getOrDefault("uploaderName", "Guest")); + chatFile.put("userImage", fileInfo.getOrDefault("uploaderImage", "")); + chatFile.put("text", fileInfo.getOrDefault("originalFilename", "Shared file")); + chatFile.put("timestamp", Instant.now().toString()); + chatFile.put("type", "file"); + chatFile.put("fileName", fileInfo.getOrDefault("originalFilename", "Shared file")); + chatFile.put("fileSize", fileInfo.get("fileSize")); + chatFile.put("fileUrl", fileInfo.get("downloadUrl")); + chatFile.put("fileType", fileInfo.get("contentType")); + persistChatMessage(roomId, chatFile); + emitToRoom(roomId, "file-shared", fileInfo); } @@ -558,6 +977,7 @@ public void afterConnectionClosed(WebSocketSession session, CloseStatus status) RoomStateManager.DisconnectResult result = roomStateManager.removeUserFromAllRooms(socketId); if (result != null) { String roomId = result.getRoomId(); + persistMediaState(roomId); broadcastToOthersInRoom(roomId, socketId, "user-left", Map.of("socketId", socketId, "user", result.getUser())); if (result.getNewPrimarySocketId() != null) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index be38034..a0c7830 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -15,9 +15,9 @@ server.tomcat.remoteip.protocol-header=x-forwarded-proto # ========================================================= # Credentials are provided through Render Environment Variables -spring.datasource.url=${SPRING_DATASOURCE_URL} -spring.datasource.username=${SPRING_DATASOURCE_USERNAME} -spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/syncroom} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:postgres} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:postgres} spring.datasource.driver-class-name=org.postgresql.Driver diff --git a/backend/src/test/java/com/project/service/RoomServiceTest.java b/backend/src/test/java/com/project/service/RoomServiceTest.java index 9cf232c..ece8515 100644 --- a/backend/src/test/java/com/project/service/RoomServiceTest.java +++ b/backend/src/test/java/com/project/service/RoomServiceTest.java @@ -6,6 +6,9 @@ import com.project.exception.ResourceNotFoundException; import com.project.model.Room; import com.project.repository.RoomRepository; +import com.project.repository.MessageRepository; +import com.project.repository.PinnedNoteRepository; +import com.project.repository.RoomMediaStateRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -21,13 +24,20 @@ public class RoomServiceTest { private RoomRepository roomRepository; private PasswordEncoder passwordEncoder; + private MessageRepository messageRepository; + private PinnedNoteRepository pinnedNoteRepository; + private RoomMediaStateRepository roomMediaStateRepository; private RoomServiceImpl roomService; @BeforeEach public void setup() { roomRepository = Mockito.mock(RoomRepository.class); passwordEncoder = Mockito.mock(PasswordEncoder.class); - roomService = new RoomServiceImpl(roomRepository, passwordEncoder); + messageRepository = Mockito.mock(MessageRepository.class); + pinnedNoteRepository = Mockito.mock(PinnedNoteRepository.class); + roomMediaStateRepository = Mockito.mock(RoomMediaStateRepository.class); + roomService = new RoomServiceImpl(roomRepository, passwordEncoder, messageRepository, + pinnedNoteRepository, roomMediaStateRepository); } @Test @@ -71,4 +81,44 @@ public void testCreateRoom_DuplicateRoomId() { RoomCreateRequest request = new RoomCreateRequest("EXISTING", "user1", false, null); assertThrows(BadRequestException.class, () -> roomService.createRoom(request)); } + + @Test + public void testPrepareRoomForJoin_CreatesRoomAndRemembersCreator() { + when(roomRepository.findByRoomId("NEWROOM")).thenReturn(Optional.empty()); + + assertTrue(roomService.prepareRoomForJoin("NEWROOM", "creator1")); + Mockito.verify(roomRepository).save(Mockito.argThat(room -> + "NEWROOM".equals(room.getRoomId()) && "creator1".equals(room.getOwnerId()))); + } + + @Test + public void testPrepareRoomForJoin_RejectsClosedRoom() { + Room room = new Room("CLOSED", "creator1", false, null); + room.setClosed(true); + when(roomRepository.findByRoomId("CLOSED")).thenReturn(Optional.of(room)); + + assertFalse(roomService.prepareRoomForJoin("CLOSED", "someone-else")); + } + + @Test + public void testIsRoomCreator_UsesPersistedRoomOwner() { + Room room = new Room("ROOM1", "creator1", false, null); + when(roomRepository.findByRoomId("ROOM1")).thenReturn(Optional.of(room)); + + assertTrue(roomService.isRoomCreator("ROOM1", "creator1")); + assertFalse(roomService.isRoomCreator("ROOM1", "someone-else")); + } + + @Test + public void testCloseRoom_OnlyCreatorCanCloseAndRoomDataIsRemoved() { + Room room = new Room("ROOM1", "creator1", false, null); + when(roomRepository.findByRoomId("ROOM1")).thenReturn(Optional.of(room)); + + assertFalse(roomService.closeRoom("ROOM1", "someone-else")); + assertTrue(roomService.closeRoom("ROOM1", "creator1")); + assertTrue(room.isClosed()); + Mockito.verify(messageRepository).deleteByRoomId("ROOM1"); + Mockito.verify(pinnedNoteRepository).deleteByRoomId("ROOM1"); + Mockito.verify(roomMediaStateRepository).deleteById("ROOM1"); + } } diff --git a/backend/src/test/java/com/project/service/RoomStateManagerTest.java b/backend/src/test/java/com/project/service/RoomStateManagerTest.java index ee73f73..dc3bfed 100644 --- a/backend/src/test/java/com/project/service/RoomStateManagerTest.java +++ b/backend/src/test/java/com/project/service/RoomStateManagerTest.java @@ -57,6 +57,79 @@ void testVideoStateTracking() { assertEquals(20.0, state.getCurrentTime()); } + @Test + void playingWithoutSelectedVideoUsesDefaultVideo() { + roomStateManager.updateVideoPlay("empty-video-room", 0.0); + + assertNotNull(roomStateManager.getVideoState("empty-video-room")); + assertEquals("aqz-KE-bpKQ", roomStateManager.getVideoState("empty-video-room").getUrl()); + } + + @Test + void mediaQueueAndHistoryRemainAfterLastParticipantLeaves() { + String roomId = "persistent-media-room"; + roomStateManager.joinRoom(roomId, "socket-1", Map.of("name", "Alice")); + roomStateManager.updateVideoUrl(roomId, "https://www.youtube.com/watch?v=video12345"); + roomStateManager.addToQueue(roomId, Map.of( + "url", "https://www.youtube.com/watch?v=video67890", + "title", "Next video" + )); + roomStateManager.addVideoHistory(roomId, Map.of( + "url", "https://www.youtube.com/watch?v=video00000", + "title", "Previous video" + )); + + roomStateManager.removeUserFromAllRooms("socket-1"); + + assertEquals("https://www.youtube.com/watch?v=video12345", + roomStateManager.getVideoState(roomId).getUrl()); + assertEquals(1, roomStateManager.getQueueSnapshot(roomId).size()); + assertEquals("https://www.youtube.com/watch?v=video00000", + roomStateManager.getVideoHistorySnapshot(roomId).get(0).get("url")); + } + + @Test + void chatHistoryRemainsAvailableAfterLastParticipantLeaves() { + String roomId = "persistent-chat-room"; + roomStateManager.joinRoom(roomId, "socket-1", Map.of("name", "Alice")); + roomStateManager.addRoomMessage(roomId, Map.of( + "id", "message-1", + "userName", "Alice", + "text", "Hello" + )); + + roomStateManager.removeUserFromAllRooms("socket-1"); + + List> history = roomStateManager.getRoomMessages(roomId); + assertEquals(1, history.size()); + assertEquals("Hello", history.get(0).get("text")); + } + + @Test + void removeVideoHistoryDeletesOnlyMatchingVideo() { + String roomId = "video-history-room"; + roomStateManager.addVideoHistory(roomId, Map.of("url", "video-a", "title", "A")); + roomStateManager.addVideoHistory(roomId, Map.of("url", "video-b", "title", "B")); + + roomStateManager.removeVideoHistory(roomId, "video-a"); + + List> history = roomStateManager.getVideoHistorySnapshot(roomId); + assertEquals(1, history.size()); + assertEquals("video-b", history.get(0).get("url")); + } + + @Test + void clearVideoHistoryRemovesAllHistoryWithoutRemovingQueue() { + String roomId = "clear-video-history-room"; + roomStateManager.addVideoHistory(roomId, Map.of("url", "video-a", "title", "A")); + roomStateManager.getQueue(roomId).add("video-next"); + + roomStateManager.clearVideoHistory(roomId); + + assertTrue(roomStateManager.getVideoHistorySnapshot(roomId).isEmpty()); + assertEquals(List.of("video-next"), roomStateManager.getQueueSnapshot(roomId)); + } + @Test void testWhiteboardStrokes() { String roomId = "wb-room"; @@ -139,4 +212,22 @@ void testRoomNameAndDisconnectHandover() { assertEquals("socket-guest", result.getNewPrimarySocketId()); assertTrue(roomStateManager.isUserPrimaryOwner(roomId, "socket-guest")); } + + @Test + void closingRoomRemovesAllLiveRoomStateAndReturnsConnectedSockets() { + String roomId = "closed-room"; + roomStateManager.joinRoom(roomId, "socket-host", Map.of("name", "Host")); + roomStateManager.addToQueue(roomId, "next-video"); + roomStateManager.addVideoHistory(roomId, Map.of("url", "old-video")); + roomStateManager.addRoomMessage(roomId, Map.of("id", "message-1")); + + assertEquals(java.util.Set.of("socket-host"), roomStateManager.closeRoom(roomId)); + + assertTrue(roomStateManager.getUsersInRoom(roomId).isEmpty()); + assertNull(roomStateManager.getRoomName(roomId)); + assertNull(roomStateManager.getVideoState(roomId)); + assertTrue(roomStateManager.getQueueSnapshot(roomId).isEmpty()); + assertTrue(roomStateManager.getVideoHistorySnapshot(roomId).isEmpty()); + assertTrue(roomStateManager.getRoomMessages(roomId).isEmpty()); + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ac9589..e753d3a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2230,6 +2230,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2241,6 +2242,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2336,6 +2338,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2868,6 +2871,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4120,6 +4124,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4289,6 +4294,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5875,6 +5881,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6942,6 +6949,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7084,6 +7092,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz", "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7215,6 +7224,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7227,6 +7237,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7240,6 +7251,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8253,6 +8265,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8360,6 +8373,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8549,6 +8563,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2f28b2b..5c7fad9 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -11,10 +11,12 @@ import { } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { motion } from "framer-motion"; +import { useToast } from "@/hooks/use-toast"; export default function Dashboard() { const { data: session, status } = useSession(); const router = useRouter(); + const { toast } = useToast(); // Core state management const [recentRooms, setRecentRooms] = useState<{roomId: string, joinedAt: string}[]>([]); @@ -59,12 +61,45 @@ export default function Dashboard() { }, [status, session]); // Command Handlers - const handleCreateRoom = useCallback(() => { + const handleCreateRoom = useCallback(async () => { if (isNavigating) return; setIsNavigating(true); const newRoomId = Math.random().toString(36).substring(2, 9).toUpperCase(); - router.push(`/room/${newRoomId}`); - }, [isNavigating, router]); + const ownerId = (session?.user as { id?: string } | undefined)?.id; + + if (!ownerId) { + setIsNavigating(false); + toast({ + title: "Couldn't create room", + description: "Your account ID is missing. Please sign in again.", + variant: "destructive", + }); + return; + } + + try { + const apiUrl = process.env.NEXT_PUBLIC_SOCKET_URL?.replace(/\/$/, "") || "http://localhost:4000"; + const response = await fetch(`${apiUrl}/api/rooms/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomId: newRoomId, ownerId, isPrivate: false }), + }); + + if (!response.ok) { + throw new Error("The server couldn't save the new room."); + } + + router.push(`/room/${newRoomId}`); + } catch (error) { + console.error("Failed to create room:", error); + setIsNavigating(false); + toast({ + title: "Couldn't create room", + description: error instanceof Error ? error.message : "Please try again.", + variant: "destructive", + }); + } + }, [isNavigating, router, session, toast]); const handleJoinRoomId = useCallback((id: string) => { if (isNavigating) return; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2ab6c0d..65cf08a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,10 +4,10 @@ import { signIn, useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { +import { Play, Zap, Video, Sparkles, Laptop, Edit3, ChevronDown, - ArrowRight, Info, Layers, HelpCircle + ArrowRight, Info, Layers, HelpCircle, X } from "lucide-react"; export default function Home() { @@ -19,6 +19,11 @@ export default function Home() { // Active Feature showcase tab const [activeFeature, setActiveFeature] = useState(0); + const [showGuestSignIn, setShowGuestSignIn] = useState(false); + const [guestName, setGuestName] = useState(""); + const [guestEmail, setGuestEmail] = useState(""); + const [signInError, setSignInError] = useState(""); + const [isSigningIn, setIsSigningIn] = useState(false); @@ -30,21 +35,29 @@ export default function Home() { - const handleSignIn = async () => { + const handleSignIn = async (event: React.FormEvent) => { + event.preventDefault(); + setSignInError(""); + setIsSigningIn(true); + try { const res = await signIn("credentials", { redirect: false, callbackUrl: "/dashboard", - name: "SyncRoom User", - email: "user@syncroom.dev" + name: guestName.trim(), + email: guestEmail.trim() }); - if (res?.ok || !res?.error) { + if (res?.ok) { router.push("/dashboard"); router.refresh(); + } else { + setSignInError("We couldn't sign you in. Please check your details and try again."); } } catch (err) { console.error("Sign in error:", err); - router.push("/dashboard"); + setSignInError("Sign-in failed. Please try again."); + } finally { + setIsSigningIn(false); } }; @@ -151,7 +164,7 @@ export default function Home() { Status: Fully Optimized + + + + setGuestName(event.target.value)} + placeholder="For example, Alex" + className="mb-4 h-11 w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 text-sm text-white outline-none placeholder:text-zinc-500 focus:border-indigo-500" + /> + + + setGuestEmail(event.target.value)} + placeholder="you@example.com" + className="h-11 w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 text-sm text-white outline-none placeholder:text-zinc-500 focus:border-indigo-500" + /> +

+ This is a guest identity for testing; no account is created. +

+ + {signInError && ( +

+ {signInError} +

+ )} + + + + + )} ); } diff --git a/frontend/src/app/room/[roomId]/page.tsx b/frontend/src/app/room/[roomId]/page.tsx index b3b139d..3abfe37 100644 --- a/frontend/src/app/room/[roomId]/page.tsx +++ b/frontend/src/app/room/[roomId]/page.tsx @@ -6,8 +6,9 @@ import { useSession, signOut } from "next-auth/react"; import { io, Socket } from "socket.io-client"; import { useRoomStore } from "@/store/useRoomStore"; import { Button } from "@/components/ui/button"; -import { LogOut, Mic, MicOff, Video, VideoOff, MonitorUp, Users as UsersIcon, MessageSquare, Copy, Check, Play, Share2, Link2, WifiOff, Settings, Hand, Vote, ListVideo } from "lucide-react"; +import { LogOut, Mic, MicOff, Video, VideoOff, MonitorUp, Users as UsersIcon, MessageSquare, Copy, Check, Play, Share2, Link2, WifiOff, Settings, Hand, Vote, ListVideo, BookOpen, Trash2 } from "lucide-react"; import EmojiReactions from "@/components/EmojiReactions"; +import StudyRoomPanel from "@/components/StudyRoomPanel"; import { Dialog, DialogContent, @@ -54,13 +55,20 @@ export default function RoomPage() { const [isDisconnected, setIsDisconnected] = useState(false); // UI Toggles - const [showSidebar, setShowSidebar] = useState<"chat" | "participants" | "polls" | "queue" | null>("chat"); + const [showSidebar, setShowSidebar] = useState< + "chat" | "participants" | "polls" | "queue" | "study" | null +>("chat"); const [queueTab, setQueueTab] = useState<"link" | "search">("link"); - const { users, setUsers, addUser, removeUser, addMessage, updateUser, setVideoQueue, setPolls, setQuestions } = useRoomStore(); + const { users, setUsers, addUser, removeUser, addMessage, setMessages, updateUser, setVideoQueue, setVideoHistory, setPolls, setQuestions, resetRoom } = useRoomStore(); const { shortcuts } = useSettingsStore(); + useEffect(() => { + resetRoom(); + }, [roomId, resetRoom]); + const isOwner = users.find(u => u.id === socket?.id)?.isOwner ?? false; + const isRoomCreator = users.find(u => u.id === socket?.id)?.isCreator ?? false; // Refs to hold the latest toggle state so the WebRTC hook's recovery // handler can read them without creating new listener registrations. @@ -71,7 +79,7 @@ export default function RoomPage() { // Start WebRTC connection — pass state refs so recovery restores correct state // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { localStream, toggleScreenShare, ensureVideoTrack } = useWebRTC( + const { localStream, toggleScreenShare, ensureAudioTrack, ensureVideoTrack, disableVideoTrack } = useWebRTC( roomId as string, socketId, isMutedRef, isVideoOnRef ); @@ -90,7 +98,7 @@ export default function RoomPage() { // ── Set initial store state when we have a socket ID ── useEffect(() => { if (socketId) { - updateUser(socketId, { isMuted: true, isVideoOn: false }); + updateUser(socketId, { isMuted: isMutedRef.current, isVideoOn: isVideoOnRef.current }); } }, [socketId, updateUser]); @@ -109,6 +117,7 @@ export default function RoomPage() { const finalImage = session.user?.image || ""; const newSocket = io(SOCKET_URL, { + transports: ["websocket"], withCredentials: true, reconnection: true, reconnectionAttempts: Infinity, @@ -124,10 +133,11 @@ export default function RoomPage() { newSocket.emit("join-room", { roomId, user: { + userId: (session.user as { id?: string }).id || "anonymous", name: finalName, image: finalImage, - isMuted: true, - isVideoOn: false, + isMuted: isMutedRef.current, + isVideoOn: isVideoOnRef.current, } }); @@ -165,20 +175,41 @@ export default function RoomPage() { } }); + newSocket.on("room-closed", () => { + toast({ + title: "Room deleted", + description: "The creator closed this room, so it can no longer be joined.", + }); + newSocket.disconnect(); + router.push("/dashboard"); + }); + + newSocket.on("room-delete-error", ({ message }: { message: string }) => { + toast({ title: "Could not delete room", description: message, variant: "destructive" }); + }); + newSocket.on("chat-message", (message) => { addMessage(message); }); + newSocket.on("chat-history", ({ messages }: { messages: Parameters[0] }) => { + setMessages(messages); + }); + // Room name changes newSocket.on("room-name-changed", ({ name }: { name: string }) => { setRoomName(name); }); // Video Queue sync - newSocket.on("queue-updated", ({ queue }: { queue: string[] }) => { + newSocket.on("queue-updated", ({ queue }: { queue: (string | import("@/store/useRoomStore").VideoInfo)[] }) => { setVideoQueue(queue); }); + newSocket.on("video-history-updated", ({ history }: { history: import("@/store/useRoomStore").VideoInfo[] }) => { + setVideoHistory(history); + }); + // Collaborative Polls & Q&A newSocket.on("polls-updated", ({ polls }) => { setPolls(polls); @@ -202,11 +233,13 @@ export default function RoomPage() { // Host Controls newSocket.on("force-mute", () => { + isMutedRef.current = true; setIsMuted(true); if (localStreamRef.current) { localStreamRef.current.getAudioTracks().forEach(track => track.enabled = false); } newSocket.emit("toggle-mic", { roomId, userId: newSocket.id, isMuted: true }); + if (newSocket.id) updateUser(newSocket.id, { isMuted: true }); }); newSocket.on("hand-toggled", ({ userId, isHandRaised }: { userId: string, isHandRaised: boolean }) => { @@ -225,21 +258,6 @@ export default function RoomPage() { // (see the useEffect below), NOT by socket events. }); - newSocket.on("reconnect", () => { - // Re-join room after reconnect - if (newSocket.id) { - newSocket.emit("join-room", { - roomId, - user: { - name: finalName, - image: finalImage, - isMuted: isMutedRef.current, - isVideoOn: isVideoOnRef.current, - } - }); - } - }); - // Sync remote users' camera and mic state changes newSocket.on("user-toggled-camera", ({ userId, isVideoOn }: { userId: string; isVideoOn: boolean }) => { updateUser(userId, { isVideoOn }); @@ -259,19 +277,6 @@ export default function RoomPage() { router.push("/dashboard"); }); - newSocket.on("force-mute", () => { - setIsMuted(true); - if (localStream) { - localStream.getAudioTracks().forEach(track => track.enabled = false); - } - if (newSocket.id) updateUser(newSocket.id, { isMuted: true }); - toast({ - title: "Force Muted", - description: "The room owner has muted your microphone.", - variant: "destructive", - }); - }); - newSocket.on("owner-status-update", ({ socketId, isOwner, isPrimaryOwner }: { socketId: string, isOwner: boolean, isPrimaryOwner: boolean }) => { updateUser(socketId, { isOwner, isPrimaryOwner }); if (socketId === newSocket.id) { @@ -324,18 +329,33 @@ export default function RoomPage() { }, []); // ── Stabilized toggle handlers (no re-creation on each render) ────── - const toggleMic = useCallback(() => { - if (localStream) { - const newMuted = !isMutedRef.current; - localStream.getAudioTracks().forEach(track => track.enabled = !newMuted); - setIsMuted(newMuted); - if (socketId) updateUser(socketId, { isMuted: newMuted }); - if (socket) socket.emit("toggle-mic", { roomId, userId: socketId, isMuted: newMuted }); + const toggleMic = useCallback(async () => { + const newMuted = !isMutedRef.current; + + if (newMuted) { + localStream?.getAudioTracks().forEach(track => { track.enabled = false; }); + } else { + let track = localStreamRef.current?.getAudioTracks().find(audioTrack => audioTrack.readyState === "live"); + if (!track) { + track = await ensureAudioTrack() ?? undefined; + } + if (!track) { + toast({ + title: "Microphone unavailable", + description: "Allow microphone access in your browser, then try again.", + variant: "destructive", + }); + return; + } + track.enabled = true; } - }, [localStream, socketId, socket, roomId, updateUser]); + isMutedRef.current = newMuted; + setIsMuted(newMuted); + if (socketId) updateUser(socketId, { isMuted: newMuted }); + if (socket) socket.emit("toggle-mic", { roomId, userId: socketId, isMuted: newMuted }); + }, [localStream, socketId, socket, roomId, updateUser, ensureAudioTrack, toast]); const toggleCamera = useCallback(async () => { - if (!localStream) return; if (isTogglingCamera.current) return; isTogglingCamera.current = true; @@ -343,31 +363,45 @@ export default function RoomPage() { const newVideoOn = !isVideoOnRef.current; if (newVideoOn) { - // Try to use existing track first (fast path) - let track: MediaStreamTrack | null | undefined = localStream.getVideoTracks()[0]; + let track: MediaStreamTrack | null | undefined = localStreamRef.current?.getVideoTracks().find(videoTrack => videoTrack.readyState === "live"); if (track && track.readyState === "live") { track.enabled = true; } else { - // Track is dead/missing — re-acquire (slow path) track = await ensureVideoTrack(); if (track) { track.enabled = true; } else { - console.warn("Could not enable camera — no video track available"); + toast({ + title: "Camera unavailable", + description: "Allow camera access in your browser, then try again.", + variant: "destructive", + }); return; } } } else { - localStream.getVideoTracks().forEach(track => track.enabled = false); + isVideoOnRef.current = false; + try { + await disableVideoTrack(); + } catch (error) { + console.error("Could not turn off camera:", error); + toast({ + title: "Could not turn off camera", + description: "Check your camera settings and try again.", + variant: "destructive", + }); + return; + } } + isVideoOnRef.current = newVideoOn; setIsVideoOn(newVideoOn); if (socketId) updateUser(socketId, { isVideoOn: newVideoOn }); if (socket) socket.emit("toggle-camera", { roomId, userId: socketId, isVideoOn: newVideoOn }); } finally { isTogglingCamera.current = false; } - }, [localStream, socketId, socket, roomId, updateUser, ensureVideoTrack]); + }, [socketId, socket, roomId, updateUser, ensureVideoTrack, disableVideoTrack, toast]); const toggleHand = useCallback(() => { setIsHandRaised(prev => { @@ -507,7 +541,40 @@ export default function RoomPage() { Up Next + + + {isOwner && } + {isRoomCreator && ( + + )} + )} + + ); } export default function CameraGrid({ currentUserId }: { currentUserId: string }) { diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx index bc503a7..2d978a1 100644 --- a/frontend/src/components/ChatPanel.tsx +++ b/frontend/src/components/ChatPanel.tsx @@ -2,11 +2,11 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { Socket } from "socket.io-client"; -import { useRoomStore } from "@/store/useRoomStore"; +import { Message, useRoomStore } from "@/store/useRoomStore"; import { useSession } from "next-auth/react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Send, Smile, X, Paperclip, Download, FileText, Image, Video, Music, File as FileIcon } from "lucide-react"; +import { Send, Smile, X, Paperclip, Download, FileText, Image, Video, Music, File as FileIcon, Pencil, Trash2, Check } from "lucide-react"; // ── Comprehensive emoji list organised by category ────────────────── const EMOJI_CATEGORIES: { name: string; icon: string; emojis: string[] }[] = [ @@ -174,9 +174,12 @@ interface ChatPanelProps { } export default function ChatPanel({ socket, roomId }: ChatPanelProps) { - const { messages } = useRoomStore(); + const { messages, updateMessage, removeMessage } = useRoomStore(); const { data: session } = useSession(); const [text, setText] = useState(""); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editText, setEditText] = useState(""); + const [pinnedNotes, setPinnedNotes] = useState([]); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [activeEmojiCategory, setActiveEmojiCategory] = useState(0); const [typingUsers, setTypingUsers] = useState>(new Set()); @@ -191,6 +194,24 @@ export default function ChatPanel({ socket, roomId }: ChatPanelProps) { const lastTypingEmitRef = useRef(0); const [chatSoundEnabled, setChatSoundEnabled] = useState(true); const prevMessageCountRef = useRef(messages.length); + const currentUserId = (session?.user as { id?: string } | undefined)?.id || "anonymous"; + + useEffect(() => { + const handleMessageUpdated = (message: Message) => updateMessage(message); + const handleMessageDeleted = ({ messageId }: { messageId: string }) => removeMessage(messageId); + const handleOperationError = ({ message }: { message: string }) => { + window.alert(message || "The chat action could not be completed."); + }; + + socket.on("chat-message-updated", handleMessageUpdated); + socket.on("chat-message-deleted", handleMessageDeleted); + socket.on("chat-operation-error", handleOperationError); + return () => { + socket.off("chat-message-updated", handleMessageUpdated); + socket.off("chat-message-deleted", handleMessageDeleted); + socket.off("chat-operation-error", handleOperationError); + }; + }, [socket, updateMessage, removeMessage]); // Request file list on mount useEffect(() => { @@ -201,6 +222,21 @@ export default function ChatPanel({ socket, roomId }: ChatPanelProps) { }; }, [socket, roomId]); + // Request pinned notes when entering the room + + useEffect(() => { + const handlePinnedNotesUpdated = ({ notes }: { notes: Message[] }) => { + setPinnedNotes(notes || []); + }; + + socket.on("pinned-notes-updated", handlePinnedNotesUpdated); + socket.emit("request-pinned-notes", { roomId }); + + return () => { + socket.off("pinned-notes-updated", handlePinnedNotesUpdated); + }; + }, [socket, roomId]); + interface SharedFileInfo { id?: string; senderId: string; @@ -331,8 +367,8 @@ export default function ChatPanel({ socket, roomId }: ChatPanelProps) { if (!text.trim() || !session?.user) return; const newMessage = { - id: Math.random().toString(36).substring(2, 9), - userId: (session.user as { id?: string }).id || "anonymous", + id: `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`, + userId: currentUserId, userName: session.user.name || "Guest", userImage: session.user.image || "", text: text.trim(), @@ -343,6 +379,22 @@ export default function ChatPanel({ socket, roomId }: ChatPanelProps) { setText(""); }; + const handleEditSave = (messageId: string) => { + const updatedText = editText.trim(); + if (!updatedText) { + window.alert("A message cannot be empty."); + return; + } + socket.emit("edit-chat-message", { roomId, messageId, text: updatedText }); + setEditingMessageId(null); + setEditText(""); + }; + + const handleDeleteMessage = (messageId: string) => { + if (!window.confirm("Delete this message for everyone in the room?")) return; + socket.emit("delete-chat-message", { roomId, messageId }); + }; + const handleTextChange = (e: React.ChangeEvent) => { setText(e.target.value); @@ -501,38 +553,183 @@ export default function ChatPanel({ socket, roomId }: ChatPanelProps) { ) : ( messages.map((m) => { - const isMe = session?.user?.name === m.userName; + const isMe = currentUserId === m.userId; + const isEditableTextMessage = (m.type === undefined || m.type === "text") && !m.isAi; return ( -
-
- {!isMe && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
- {!isMe &&
{m.userName}
} - {renderMessageContent(m)} -
-
-
- {new Date(m.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
+
+
+ {!isMe && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + +
+ {!isMe && ( +
+ {m.userName} +
+ )} + + {editingMessageId === m.id ? ( +
+ setEditText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleEditSave(m.id); + } + if (event.key === "Escape") { + setEditingMessageId(null); + setEditText(""); + } + }} + aria-label="Edit message" + /> +
+ + +
+
+ ) : ( + <> + {renderMessageContent(m)} + {m.edited && (edited)} + + )} + + {editingMessageId !== m.id && ( +
+ + {isMe && isEditableTextMessage && editingMessageId !== m.id && ( + <> + + + + )} +
+ )} +
+
+ +
+ {new Date(m.timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + })} +
+
); }) )} +{/* Pinned Notes */} +{pinnedNotes.length > 0 && ( +
+
+ 📌 Pinned Notes +
+ +
+ {pinnedNotes.map((note) => ( +
+
+
+ {note.userName} +
+ +
+ {note.text} +
+
+ + +
+ ))} +
+
+)} + {/* Input Area */}
{/* ── Emoji Picker ─────────────────────────────────────── */} diff --git a/frontend/src/components/QueuePanel.tsx b/frontend/src/components/QueuePanel.tsx index 6bd1203..f2acdef 100644 --- a/frontend/src/components/QueuePanel.tsx +++ b/frontend/src/components/QueuePanel.tsx @@ -36,7 +36,7 @@ interface QueuePanelProps { } export default function QueuePanel({ socket, roomId, onClose, onActiveTabChange }: QueuePanelProps) { - const { videoQueue, users } = useRoomStore(); + const { videoQueue, videoHistory, users } = useRoomStore(); const { youtubeApiKey, setYoutubeApiKey } = useSettingsStore(); const [inputUrl, setInputUrl] = useState(""); @@ -124,6 +124,21 @@ export default function QueuePanel({ socket, roomId, onClose, onActiveTabChange } }; + const handlePlayHistoryItem = (url: string) => { + if (!isOwner) return; + socket.emit("video-url-change", { roomId, newUrl: url }); + }; + + const handleRemoveHistoryItem = (url: string) => { + if (!isOwner) return; + socket.emit("remove-video-history", { roomId, url }); + }; + + const handleClearVideoHistory = () => { + if (!isOwner || !window.confirm("Clear all recently played videos for this room?")) return; + socket.emit("clear-video-history", { roomId }); + }; + const handleRemoveFromQueue = (index: number) => { if (!isOwner) return; socket.emit("remove-from-queue", { roomId, index }); @@ -480,15 +495,21 @@ export default function QueuePanel({ socket, roomId, onClose, onActiveTabChange ) ) : ( /* Link / Queue Tab list */ - videoQueue.length === 0 ? ( -
- -

The queue is currently empty.

-

Paste a YouTube link above to share a video with everyone!

-
- ) : ( -
- {videoQueue.map((item, idx) => { +
+
+
+

Up Next

+ {videoQueue.length} videos +
+ {videoQueue.length === 0 ? ( +
+ +

The queue is currently empty.

+

Paste a YouTube link above to add a video.

+
+ ) : ( +
+ {videoQueue.map((item, idx) => { const isObj = typeof item !== "string"; const title = isObj ? item.title : "YouTube Video"; const url = isObj ? item.url : item; @@ -562,9 +583,83 @@ export default function QueuePanel({ socket, roomId, onClose, onActiveTabChange
); - })} -
- ) + })} +
+ )} + + +
+
+

Recently played

+
+ {videoHistory.length} videos + {isOwner && videoHistory.length > 0 && ( + + )} +
+
+ {videoHistory.length === 0 ? ( +

Played videos will appear here so you can watch them again.

+ ) : ( +
+ {videoHistory.map((item, index) => { + const videoId = extractYouTubeId(item.url); + const thumbnail = item.thumbnail || (videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : ""); + return ( +
+ {isOwner && ( + + )} + {!isOwner && ( + <> +
+ {thumbnail && } +
+ + {item.title || "YouTube Video"} + + + )} + {isOwner && ( + + )} +
+ ); + })} +
+ )} +
+ )} diff --git a/frontend/src/components/StudyRoomPanel.tsx b/frontend/src/components/StudyRoomPanel.tsx new file mode 100644 index 0000000..0f08936 --- /dev/null +++ b/frontend/src/components/StudyRoomPanel.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Socket } from "socket.io-client"; +import { FileText, Printer } from "lucide-react"; + +type PinnedNote = { + id: string | number; + userName?: string; + text?: string; + message?: string; + timestamp?: string; +}; + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (character) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[character]; + }); +} + +export default function StudyRoomPanel({ + socket, + roomId, +}: { + socket: Socket; + roomId: string; +}) { + const [notes, setNotes] = useState([]); + + useEffect(() => { + const handleNotes = ({ notes }: { notes: PinnedNote[] }) => { + setNotes(notes || []); + }; + + socket.on("pinned-notes-updated", handleNotes); + socket.emit("request-pinned-notes", { roomId }); + + return () => { + socket.off("pinned-notes-updated", handleNotes); + }; + }, [socket, roomId]); + + const getExportText = () => + notes + .map((note, index) => { + const date = note.timestamp + ? new Date(note.timestamp).toLocaleString() + : ""; + return [ + `${index + 1}. ${note.userName || "Room member"}`, + date, + note.text || note.message || "No text available", + ] + .filter(Boolean) + .join("\n"); + }) + .join("\n\n"); + + const getFileName = (extension: string) => { + const safeRoomId = roomId.replace(/[^a-z0-9-_]/gi, "-"); + return `syncroom-${safeRoomId}-study-notes.${extension}`; + }; + + const downloadText = () => { + const file = new Blob([getExportText()], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(file); + const link = document.createElement("a"); + link.href = url; + link.download = getFileName("txt"); + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + + const printNotes = () => { + const printWindow = window.open("", "_blank"); + if (!printWindow) { + window.alert("Allow pop-ups for SyncRoom to print or save notes as a PDF."); + return; + } + printWindow.onload = () => { + printWindow.focus(); + printWindow.print(); + }; + + const noteCards = notes + .map((note, index) => { + const author = escapeHtml(note.userName || "Room member"); + const content = escapeHtml(note.text || note.message || "No text available"); + const date = note.timestamp + ? `

${escapeHtml(new Date(note.timestamp).toLocaleString())}

` + : ""; + return `

${index + 1}. ${author}

${date}

${content}

`; + }) + .join(""); + + printWindow.document.write(` + + + ${escapeHtml(`SyncRoom ${roomId} study notes`)} + + + +

SyncRoom study notes

+

Room: ${escapeHtml(roomId)}

+ ${noteCards} + + `); + printWindow.document.close(); + }; + + return ( +
+
+

📚 Study Room

+

+ Your saved study notes +

+
+ + +
+
+ +
+ {notes.length === 0 ? ( +

+ No pinned notes yet. Open Chat and click Pin on a message. +

+ ) : ( + notes.map((note) => ( +
+

+ {note.userName || "Room member"} +

+ +

+ {note.text || note.message || "No text available"} +

+ + +
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index 997be5e..52d2a5b 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -36,12 +36,16 @@ export default function VideoPlayer({ socket, roomId, onToggleQueue, isQueueOpen } }, [isOwner, stableIsOwner]); - const [url, setUrl] = useState("aqz-KE-bpKQ"); // YouTube Video ID + const [url, setUrl] = useState("aqz-KE-bpKQ"); // Default bunny sample video const [isVideoType, setIsVideoType] = useState<"youtube" | "local">("youtube"); + const [autoplayEnabled, setAutoplayEnabled] = useState(false); + const [autoplayRequest, setAutoplayRequest] = useState(0); const [localVideoUrl, setLocalVideoUrl] = useState(""); const [isVertical, setIsVertical] = useState(false); const [inputUrl, setInputUrl] = useState(""); const ytPlayerRef = useRef(null); + const autoplayRequestRef = useRef(autoplayRequest); + autoplayRequestRef.current = autoplayRequest; const nativeVideoRef = useRef(null); const fileInputRef = useRef(null); const [isWhiteboardActive, setIsWhiteboardActive] = useState(false); @@ -77,13 +81,13 @@ export default function VideoPlayer({ socket, roomId, onToggleQueue, isQueueOpen // Global hotkey 'c' for chat toggle in fullscreen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!isFullscreen) return; const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return; const key = e.code === "Space" ? "space" : e.key.toLowerCase(); if (key === shortcuts.chat) { + if (!isFullscreen) return; e.preventDefault(); setIsChatVisible(prev => !prev); } else if (key === shortcuts.whiteboard) { @@ -166,6 +170,8 @@ export default function VideoPlayer({ socket, roomId, onToggleQueue, isQueueOpen if (videoId) { setIsVideoType("youtube"); setUrl(videoId); + setAutoplayEnabled(true); + setAutoplayRequest((request) => request + 1); } setInputUrl(""); }); @@ -279,7 +285,8 @@ export default function VideoPlayer({ socket, roomId, onToggleQueue, isQueueOpen - const handleEnd = () => { + const handleEnd = (playerGeneration?: number) => { + if (playerGeneration !== undefined && playerGeneration !== autoplayRequestRef.current) return; if (!isOwner) return; if (videoQueue.length > 0) { socket.emit("play-next", { roomId }); @@ -358,13 +365,13 @@ export default function VideoPlayer({ socket, roomId, onToggleQueue, isQueueOpen height: '100%', width: '100%', playerVars: { - autoplay: 0, + autoplay: autoplayEnabled ? 1 : 0, controls: stableIsOwner ? 1 : 0, // Hide YouTube controls for non-owners disablekb: stableIsOwner ? 0 : 1, // Disable keyboard for non-owners fs: 0, // Disable native fullscreen so we use our custom wrapper fullscreen rel: 0, }, - }), [stableIsOwner]); + }), [autoplayEnabled, stableIsOwner]); return (
@@ -424,7 +431,7 @@ export default function VideoPlayer({ socket, roomId, onToggleQueue, isQueueOpen onClick={() => setIsWhiteboardActive(!isWhiteboardActive)} className={`shrink-0 h-9 transition-colors ${isWhiteboardActive ? "text-indigo-400 bg-indigo-500/10" : "text-zinc-400 hover:text-white"}`} > - Annotate + Whiteboard )} - {isVideoType === "youtube" ? ( + {isVideoType === "youtube" && url ? ( ytPlayerRef.current = e.target} onPlay={handlePlay} onPause={handlePause} - onEnd={handleEnd} + onEnd={() => handleEnd(autoplayRequest)} className="absolute inset-0 w-full h-full pointer-events-auto" iframeClassName="w-full h-full" /> - ) : ( + ) : isVideoType === "local" && localVideoUrl ? (
handleSeek((e.target as HTMLVideoElement).currentTime)} - onEnded={handleEnd} + onEnded={() => handleEnd()} onLoadedMetadata={handleLoadedMetadata} />
- )} + ) : null}
); } - - diff --git a/frontend/src/hooks/useWebRTC.ts b/frontend/src/hooks/useWebRTC.ts index 3ea7cc9..6e5bf96 100644 --- a/frontend/src/hooks/useWebRTC.ts +++ b/frontend/src/hooks/useWebRTC.ts @@ -25,6 +25,7 @@ export function useWebRTC( const { users, updateUser } = useRoomStore(); const callsRef = useRef<{ [id: string]: MediaConnection }>({}); const localStreamRef = useRef(null); + const mediaRequestRef = useRef | null>(null); const isRecoveringRef = useRef(false); const userIdRef = useRef(userId); @@ -51,71 +52,103 @@ export function useWebRTC( const kind = freshTrack.kind; // "audio" | "video" Object.values(callsRef.current).forEach((call) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sender = (call as any).peerConnection + const connection = (call as any).peerConnection as RTCPeerConnection | undefined; + const sender = connection ?.getSenders() // eslint-disable-next-line @typescript-eslint/no-explicit-any ?.find((s: any) => s.track?.kind === kind); - if (sender) sender.replaceTrack(freshTrack); + if (sender) { + sender.replaceTrack(freshTrack).catch((error: unknown) => { + console.error(`Failed to replace ${kind} track:`, error); + }); + } else if (connection && localStreamRef.current) { + connection.addTrack(freshTrack, localStreamRef.current); + } }); }, []); - // ── ensureVideoTrack ──────────────────────────────────────────────── - const ensureVideoTrack = useCallback(async (): Promise => { - const stream = localStreamRef.current; - if (!stream) return null; - - const existing = stream.getVideoTracks()[0]; + const ensureTrack = useCallback(async (kind: "audio" | "video"): Promise => { + let stream = localStreamRef.current; + let existing = stream?.getTracks().find(track => track.kind === kind); if (existing && existing.readyState === "live") return existing; try { - const freshStream = await navigator.mediaDevices.getUserMedia({ video: true }); - const freshTrack = freshStream.getVideoTracks()[0]; + let capturedStream: MediaStream | null = null; + if (mediaRequestRef.current) { + try { + capturedStream = await mediaRequestRef.current; + } catch { + // A failed combined request can still succeed for one device on its own. + } + } + stream = localStreamRef.current; + existing = stream?.getTracks().find(track => track.kind === kind); + if (existing && existing.readyState === "live") return existing; + + let freshTrack = capturedStream?.getTracks().find(track => track.kind === kind); + if (!freshTrack) { + const request = navigator.mediaDevices.getUserMedia( + kind === "audio" ? { audio: true } : { video: true } + ); + mediaRequestRef.current = request; + try { + capturedStream = await request; + } finally { + if (mediaRequestRef.current === request) mediaRequestRef.current = null; + } + freshTrack = capturedStream.getTracks().find(track => track.kind === kind); + } + if (!freshTrack) return null; + + stream = localStreamRef.current; + existing = stream?.getTracks().find(track => track.kind === kind); if (existing) { - stream.removeTrack(existing); - try { existing.stop(); } catch { /* already stopped */ } + stream?.removeTrack(existing); + existing.stop(); + } + if (!stream) { + stream = new MediaStream(); + localStreamRef.current = stream; + setLocalStream(stream); + } + if (!stream.getTracks().includes(freshTrack)) { + stream.addTrack(freshTrack); + hotSwapTrack(freshTrack); } - stream.addTrack(freshTrack); - hotSwapTrack(freshTrack); return freshTrack; } catch (err) { - console.error("Failed to re-acquire video track:", err); + console.error(`Failed to acquire ${kind} track:`, err); return null; } }, [hotSwapTrack]); - // ── ensureAudioTrack ──────────────────────────────────────────────── - const ensureAudioTrack = useCallback(async (): Promise => { - const stream = localStreamRef.current; - if (!stream) return null; - - const existing = stream.getAudioTracks()[0]; - if (existing && existing.readyState === "live") return existing; - - try { - const freshStream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const freshTrack = freshStream.getAudioTracks()[0]; + const ensureVideoTrack = useCallback(() => ensureTrack("video"), [ensureTrack]); + const ensureAudioTrack = useCallback(() => ensureTrack("audio"), [ensureTrack]); - if (existing) { - stream.removeTrack(existing); - try { existing.stop(); } catch { /* */ } - } - stream.addTrack(freshTrack); - hotSwapTrack(freshTrack); - return freshTrack; - } catch (err) { - console.error("Failed to re-acquire audio track:", err); - return null; + const disableVideoTrack = useCallback(async () => { + const stream = localStreamRef.current; + const track = stream?.getVideoTracks()[0]; + if (track) { + stream?.removeTrack(track); + track.stop(); } - }, [hotSwapTrack]); + + const replacements = Object.values(callsRef.current).map((call) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const connection = (call as any).peerConnection as RTCPeerConnection | undefined; + const sender = connection?.getSenders().find((item) => item.track?.kind === "video"); + return sender ? sender.replaceTrack(null) : Promise.resolve(); + }); + await Promise.all(replacements); + }, []); // ── Unified media recovery ────────────────────────────────────── // Single recovery function used by both visibility-change and // track-ended handlers. Serialized via isRecoveringRef. // - // KEY BEHAVIOUR: We ALWAYS keep the hardware track alive (readyState=live). - // User preference (camera on/off) is reflected via track.enabled only. - // This prevents the browser from truly killing the camera between tabs. + // Restore media only when the user has enabled it. A disabled video track + // still keeps the camera hardware active in many browsers. const recoverMedia = useCallback(async () => { if (isRecoveringRef.current) return; isRecoveringRef.current = true; @@ -128,14 +161,14 @@ export function useWebRTC( const wantAudio = !(isMutedRef?.current ?? true); // ── Video ── - // Always ensure a live video track exists in the stream regardless of - // whether the user wants it on or off. Only .enabled reflects preference. const vTrack = stream.getVideoTracks()[0]; - if (!vTrack || vTrack.readyState === "ended") { - const fresh = await ensureVideoTrack(); - if (fresh) fresh.enabled = wantVideo; - } else { - vTrack.enabled = wantVideo; + if (wantVideo) { + if (!vTrack || vTrack.readyState === "ended") { + const fresh = await ensureVideoTrack(); + if (fresh) fresh.enabled = true; + } else { + vTrack.enabled = true; + } } // ── Audio ── @@ -202,6 +235,14 @@ export function useWebRTC( useEffect(() => { if (!userId) return; + let disposed = false; + let pendingStream: MediaStream | null = null; + const pendingCalls: MediaConnection[] = []; + callsRef.current = {}; + localStreamRef.current = null; + setLocalStream(null); + setRemoteStreams({}); + // STUN/TURN configuration for NAT traversal const iceServers = [ { urls: "stun:stun.l.google.com:19302" }, @@ -215,70 +256,26 @@ export function useWebRTC( host: process.env.NEXT_PUBLIC_PEER_HOST || "localhost", port: parseInt(process.env.NEXT_PUBLIC_PEER_PORT || "4000"), path: "/peerjs/myapp", + secure: window.location.protocol === "https:", config: { iceServers }, debug: process.env.NODE_ENV === "development" ? 2 : 0, }); setPeer(newPeer); - // Set up call handler IMMEDIATELY to avoid race condition - // where incoming calls arrive before getUserMedia completes - const setupCallHandler = (stream: MediaStream) => { - newPeer.on("call", (call: MediaConnection) => { - call.answer(stream); - call.on("stream", (userVideoStream: MediaStream) => { - setRemoteStreams((prev) => ({ - ...prev, - [call.peer]: userVideoStream, - })); - updateUser(call.peer, { stream: userVideoStream }); - }); - call.on("error", (err) => { - console.error(`[useWebRTC] Call error with ${call.peer}:`, err); - }); - call.on("close", () => { - console.log(`[useWebRTC] Call closed with ${call.peer}`); - delete callsRef.current[call.peer]; - setRemoteStreams((prev) => { - const next = { ...prev }; - delete next[call.peer]; - return next; - }); - updateUser(call.peer, { stream: undefined }); - }); - callsRef.current[call.peer] = call; - }); - }; - - // Set up call handler immediately with a placeholder stream - // Will be updated with real stream once getUserMedia resolves - let pendingStream: MediaStream | null = null; - const callHandlerWrapper = (call: MediaConnection) => { - if (pendingStream) { - call.answer(pendingStream); - } else { - // Queue the call to answer once stream is ready - const answerWhenReady = () => { - if (pendingStream) { - call.answer(pendingStream); - call.removeListener("error", answerWhenReady); - } - }; - call.on("error", answerWhenReady); - } - call.on("stream", (userVideoStream: MediaStream) => { - setRemoteStreams((prev) => ({ - ...prev, - [call.peer]: userVideoStream, - })); - updateUser(call.peer, { stream: userVideoStream }); + const registerCall = (call: MediaConnection) => { + callsRef.current[call.peer] = call; + call.on("stream", (remoteStream: MediaStream) => { + setRemoteStreams((prev) => ({ ...prev, [call.peer]: remoteStream })); + updateUser(call.peer, { stream: remoteStream }); }); call.on("error", (err) => { console.error(`[useWebRTC] Call error with ${call.peer}:`, err); }); call.on("close", () => { - console.log(`[useWebRTC] Call closed with ${call.peer}`); - delete callsRef.current[call.peer]; + if (callsRef.current[call.peer] === call) { + delete callsRef.current[call.peer]; + } setRemoteStreams((prev) => { const next = { ...prev }; delete next[call.peer]; @@ -286,9 +283,21 @@ export function useWebRTC( }); updateUser(call.peer, { stream: undefined }); }); - callsRef.current[call.peer] = call; }; - newPeer.on("call", callHandlerWrapper); + + const handleIncomingCall = (call: MediaConnection) => { + if (callsRef.current[call.peer]) { + call.close(); + return; + } + registerCall(call); + if (pendingStream) { + call.answer(pendingStream); + } else { + pendingCalls.push(call); + } + }; + newPeer.on("call", handleIncomingCall); // PeerJS connection events newPeer.on("open", (id) => { @@ -305,43 +314,56 @@ export function useWebRTC( console.log("[useWebRTC] PeerJS connection closed"); }); - // Request both audio + video upfront so PeerJS has tracks to share. - // BOTH video and audio tracks are disabled by default — - // video: user toggles it on explicitly - // audio: mic starts muted for better UX - navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + // Request audio on room entry, but only request camera after the user + // explicitly turns video on. Disabled video tracks can still light the + // camera indicator, so do not acquire one preemptively. + const setUpMedia = (stream: MediaStream) => { + if (disposed) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + stream.getTracks().forEach((track) => { track.enabled = false; }); + pendingStream = stream; + localStreamRef.current = stream; + setLocalStream(stream); + pendingCalls.splice(0).forEach((call) => { + if (callsRef.current[call.peer] === call) call.answer(stream); + }); + }; + + if (!navigator.mediaDevices?.getUserMedia) { + console.error("Camera and microphone access require HTTPS or localhost."); + return () => { + disposed = true; + newPeer.destroy(); + }; + } + + const initialMediaRequest = navigator.mediaDevices.getUserMedia({ audio: true }); + mediaRequestRef.current = initialMediaRequest; + initialMediaRequest .then((stream) => { - stream.getVideoTracks().forEach(t => t.enabled = false); - stream.getAudioTracks().forEach(t => t.enabled = false); - pendingStream = stream; - setLocalStream(stream); - localStreamRef.current = stream; - // Replace the wrapper with the real handler now that we have a stream - newPeer.off("call", callHandlerWrapper); - setupCallHandler(stream); + setUpMedia(stream); }) .catch((err) => { - console.warn("getUserMedia (video+audio) failed, trying audio-only:", err); - navigator.mediaDevices.getUserMedia({ audio: true }) - .then((audioStream) => { - audioStream.getAudioTracks().forEach(t => t.enabled = false); - pendingStream = audioStream; - setLocalStream(audioStream); - localStreamRef.current = audioStream; - newPeer.off("call", callHandlerWrapper); - setupCallHandler(audioStream); - }) - .catch((audioErr) => { - console.error("getUserMedia (audio-only) also failed:", audioErr); - pendingStream = null; - }); + console.error("Could not access the microphone:", err); + }) + .finally(() => { + if (mediaRequestRef.current === initialMediaRequest) mediaRequestRef.current = null; }); return () => { - newPeer.off("call", callHandlerWrapper); + disposed = true; + newPeer.off("call", handleIncomingCall); newPeer.destroy(); - // We intentionally DO NOT stop the tracks here to prevent - // component rerenders or StrictMode from killing the persistent camera feed. + pendingStream?.getTracks().forEach((track) => track.stop()); + if (localStreamRef.current === pendingStream) { + localStreamRef.current = null; + } + callsRef.current = {}; + mediaRequestRef.current = null; + setPeer((currentPeer) => currentPeer === newPeer ? null : currentPeer); + setLocalStream((currentStream) => currentStream === pendingStream ? null : currentStream); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [userId, roomId]); @@ -351,17 +373,26 @@ export function useWebRTC( if (!peer || !localStream) return; users.forEach((user) => { - if (user.id !== userId && !callsRef.current[user.id]) { + if (user.id !== userId && userId.localeCompare(user.id) < 0 && !callsRef.current[user.id]) { const call = peer.call(user.id, localStream); if (call) { - call.on("stream", (userVideoStream: MediaStream) => { - setRemoteStreams((prev) => ({ - ...prev, - [user.id]: userVideoStream, - })); - updateUser(user.id, { stream: userVideoStream }); - }); callsRef.current[user.id] = call; + call.on("stream", (remoteStream: MediaStream) => { + setRemoteStreams((prev) => ({ ...prev, [user.id]: remoteStream })); + updateUser(user.id, { stream: remoteStream }); + }); + call.on("error", (err) => { + console.error(`[useWebRTC] Call error with ${user.id}:`, err); + }); + call.on("close", () => { + if (callsRef.current[user.id] === call) delete callsRef.current[user.id]; + setRemoteStreams((prev) => { + const next = { ...prev }; + delete next[user.id]; + return next; + }); + updateUser(user.id, { stream: undefined }); + }); } } }); @@ -396,17 +427,36 @@ export function useWebRTC( return false; } } else { + const oldTrack = stream.getVideoTracks()[0]; + if (!isVideoOnRef?.current) { + if (oldTrack) { + stream.removeTrack(oldTrack); + oldTrack.stop(); + } + Object.values(callsRef.current).forEach((call) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const connection = (call as any).peerConnection as RTCPeerConnection | undefined; + const sender = connection?.getSenders().find((item) => item.track?.kind === "video"); + if (sender) { + sender.replaceTrack(null).catch((error: unknown) => { + console.error("Failed to stop sending screen video:", error); + }); + } + }); + return false; + } + try { const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true }); const cameraVideoTrack = cameraStream.getVideoTracks()[0]; hotSwapTrack(cameraVideoTrack); - const oldTrack = stream.getVideoTracks()[0]; if (oldTrack) { stream.removeTrack(oldTrack); oldTrack.stop(); } + cameraVideoTrack.enabled = true; stream.addTrack(cameraVideoTrack); return false; @@ -417,5 +467,5 @@ export function useWebRTC( } }; - return { localStream, remoteStreams, toggleScreenShare, ensureVideoTrack, recoverMedia }; + return { localStream, remoteStreams, toggleScreenShare, ensureAudioTrack, ensureVideoTrack, disableVideoTrack, recoverMedia }; } diff --git a/frontend/src/store/useRoomStore.ts b/frontend/src/store/useRoomStore.ts index 81bfd31..45745c2 100644 --- a/frontend/src/store/useRoomStore.ts +++ b/frontend/src/store/useRoomStore.ts @@ -9,10 +9,11 @@ interface User { stream?: MediaStream; isOwner?: boolean; isPrimaryOwner?: boolean; + isCreator?: boolean; isHandRaised?: boolean; } -interface Message { +export interface Message { id: string; userId: string; userName: string; @@ -26,6 +27,7 @@ interface Message { fileSize?: number; fileUrl?: string; fileType?: string; + edited?: boolean; } export interface Poll { @@ -67,22 +69,29 @@ export interface VideoInfo { interface RoomState { users: User[]; messages: Message[]; + setMessages: (messages: Message[]) => void; setUsers: (users: User[]) => void; addUser: (user: User) => void; removeUser: (socketId: string) => void; updateUser: (socketId: string, updates: Partial) => void; addMessage: (message: Message) => void; + updateMessage: (message: Message) => void; + removeMessage: (messageId: string) => void; videoQueue: (string | VideoInfo)[]; setVideoQueue: (queue: (string | VideoInfo)[]) => void; + videoHistory: VideoInfo[]; + setVideoHistory: (history: VideoInfo[]) => void; polls: Poll[]; setPolls: (polls: Poll[]) => void; questions: Question[]; setQuestions: (questions: Question[]) => void; + resetRoom: () => void; } export const useRoomStore = create((set) => ({ users: [], messages: [], + setMessages: (messages) => set({ messages }), setUsers: (users) => set({ users }), addUser: (user) => set((state) => ({ users: [...state.users, user] })), removeUser: (socketId) => set((state) => ({ users: state.users.filter(u => u.id !== socketId) })), @@ -90,10 +99,19 @@ export const useRoomStore = create((set) => ({ users: state.users.map(u => u.id === socketId ? { ...u, ...updates } : u) })), addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })), + updateMessage: (message) => set((state) => ({ + messages: state.messages.map((existing) => existing.id === message.id ? message : existing) + })), + removeMessage: (messageId) => set((state) => ({ + messages: state.messages.filter((message) => message.id !== messageId) + })), videoQueue: [], setVideoQueue: (queue) => set({ videoQueue: queue }), + videoHistory: [], + setVideoHistory: (history) => set({ videoHistory: history }), polls: [], setPolls: (polls) => set({ polls }), questions: [], setQuestions: (questions) => set({ questions }), + resetRoom: () => set({ users: [], messages: [], videoQueue: [], videoHistory: [], polls: [], questions: [] }), })); diff --git a/python-service/requirements.txt b/python-service/requirements.txt index 0bc832b..990d3ff 100644 --- a/python-service/requirements.txt +++ b/python-service/requirements.txt @@ -1,4 +1,5 @@ fastapi==0.110.0 uvicorn==0.29.0 httpx==0.27.0 -pydantic==2.6.4 +pydantic==2.8.0 + diff --git a/start-dev.bat b/start-dev.bat index b819ddc..1c0d73c 100644 --- a/start-dev.bat +++ b/start-dev.bat @@ -1,26 +1,28 @@ @echo off -echo Starting SyncRoom Local Development (Java Spring Boot + Next.js + FastAPI)... +cd /d "%~dp0" -echo Starting Spring Boot Backend (Port 4000)... -cd backend -start cmd /k "mvn spring-boot:run" -cd .. +echo. +echo ========================================== +echo Starting SyncRoom +echo ========================================== +echo. -echo Starting Frontend (Port 3000)... -cd frontend -start cmd /k "npm run dev" -cd .. +echo Starting Spring Boot Backend... +start "SyncRoom Backend" cmd /k "cd /d ""%~dp0backend"" && mvn spring-boot:run" -echo Starting Python Service (Port 8000)... -cd python-service -if not exist "venv\" ( - python -m venv venv - call venv\Scripts\activate.bat - pip install -r requirements.txt -) else ( - call venv\Scripts\activate.bat -) -start cmd /k "uvicorn main:app --host 0.0.0.0 --port 8000 --reload" -cd .. +echo Starting Frontend... +start "SyncRoom Frontend" cmd /k "cd /d ""%~dp0frontend"" && npm run dev" -echo All services started in separate windows! +echo Starting Python Service... +start "SyncRoom Python" cmd /k "cd /d ""%~dp0python-service"" && venv\Scripts\python.exe -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload" + +echo. +echo ========================================== +echo SyncRoom services are starting... +echo ========================================== +echo. +echo Frontend: http://localhost:3000 +echo Backend: http://localhost:4000 +echo Python: http://localhost:8000 +echo. +pause \ No newline at end of file diff --git a/supabase/schema.sql b/supabase/schema.sql index adb78d3..f431005 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -38,11 +38,15 @@ CREATE TABLE IF NOT EXISTS rooms ( room_id VARCHAR(255) NOT NULL UNIQUE, owner_id VARCHAR(255), is_private BOOLEAN DEFAULT FALSE, + is_closed BOOLEAN NOT NULL DEFAULT FALSE, password VARCHAR(255), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); +ALTER TABLE rooms + ADD COLUMN IF NOT EXISTS is_closed BOOLEAN NOT NULL DEFAULT FALSE; + -- Index for fast room lookups by room_id CREATE INDEX IF NOT EXISTS idx_rooms_room_id ON rooms(room_id); @@ -54,12 +58,39 @@ CREATE TABLE IF NOT EXISTS messages ( text TEXT NOT NULL, user_name VARCHAR(255), user_image TEXT, + payload TEXT, + client_message_id VARCHAR(255), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); +-- Store the complete chat message shape while keeping the searchable core columns. +ALTER TABLE messages ADD COLUMN IF NOT EXISTS payload TEXT; +ALTER TABLE messages ADD COLUMN IF NOT EXISTS client_message_id VARCHAR(255); + -- Index for querying chat messages by roomId ordered by timestamp CREATE INDEX IF NOT EXISTS idx_messages_room_created ON messages(room_id, created_at ASC); +CREATE INDEX IF NOT EXISTS idx_messages_room_client_id ON messages(room_id, client_message_id); + +-- 5a. Persistent room playback state, queue, and replay history +CREATE TABLE IF NOT EXISTS room_media_state ( + room_id VARCHAR(255) PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- 6. Pinned Study Room Notes +CREATE TABLE IF NOT EXISTS pinned_notes ( + id VARCHAR(255) PRIMARY KEY DEFAULT gen_random_uuid()::text, + room_id VARCHAR(255) NOT NULL, + note_id VARCHAR(255) NOT NULL, + payload TEXT NOT NULL, + pinned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT uk_pinned_notes_room_note UNIQUE (room_id, note_id) +); + +CREATE INDEX IF NOT EXISTS idx_pinned_notes_room_pinned + ON pinned_notes(room_id, pinned_at DESC); -- ============================================================================== -- Row Level Security (RLS) Configuration (Optional for Supabase Client Access) @@ -69,9 +100,13 @@ ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE user_recent_rooms ENABLE ROW LEVEL SECURITY; ALTER TABLE rooms ENABLE ROW LEVEL SECURITY; ALTER TABLE messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE pinned_notes ENABLE ROW LEVEL SECURITY; +ALTER TABLE room_media_state ENABLE ROW LEVEL SECURITY; -- Allow public / service-role read/write for backend API operation CREATE POLICY "Allow all operations for backend service" ON users FOR ALL USING (true) WITH CHECK (true); CREATE POLICY "Allow all operations for backend service" ON user_recent_rooms FOR ALL USING (true) WITH CHECK (true); CREATE POLICY "Allow all operations for backend service" ON rooms FOR ALL USING (true) WITH CHECK (true); CREATE POLICY "Allow all operations for backend service" ON messages FOR ALL USING (true) WITH CHECK (true); +CREATE POLICY "Allow all operations for backend service" ON pinned_notes FOR ALL USING (true) WITH CHECK (true); +CREATE POLICY "Allow all operations for backend service" ON room_media_state FOR ALL USING (true) WITH CHECK (true);