This commit is contained in:
Maximilian Walz
2025-09-17 07:32:25 +02:00
parent 7f704e0d22
commit b4ce1cca7b
7 changed files with 268 additions and 85 deletions

View File

@@ -18,6 +18,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic");
registry.enableSimpleBroker("/topic", "/queue");
registry.setUserDestinationPrefix("/user");
}
}

View File

@@ -1,27 +1,54 @@
package de.w665.testing.controller;
import de.w665.testing.model.ChatMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
@Controller
@RequiredArgsConstructor
public class ChatController {
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
return chatMessage;
private final SimpMessagingTemplate messagingTemplate;
@MessageMapping("/chat/{roomId}/sendMessage")
public void sendMessage(@DestinationVariable String roomId, @Payload ChatMessage chatMessage) {
messagingTemplate.convertAndSend(String.format("/topic/rooms/%s", roomId), chatMessage);
}
@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage chatMessage,
@MessageMapping("/chat/{roomId}/addUser")
public void addUser(@DestinationVariable String roomId, @Payload ChatMessage chatMessage,
SimpMessageHeaderAccessor headerAccessor) {
// Add username in web socket session
String currentRoomId = (String) headerAccessor.getSessionAttributes().put("room_id", roomId);
if (currentRoomId != null) {
ChatMessage leaveMessage = new ChatMessage();
leaveMessage.setType(ChatMessage.MessageType.LEAVE);
leaveMessage.setSender(chatMessage.getSender());
messagingTemplate.convertAndSend(String.format("/topic/rooms/%s", currentRoomId), leaveMessage);
}
headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
return chatMessage;
messagingTemplate.convertAndSend(String.format("/topic/rooms/%s", roomId), chatMessage);
}
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.wrap(event.getMessage());
String username = (String) headerAccessor.getSessionAttributes().get("username");
String roomId = (String) headerAccessor.getSessionAttributes().get("room_id");
if (username != null && roomId != null) {
ChatMessage chatMessage = new ChatMessage();
chatMessage.setType(ChatMessage.MessageType.LEAVE);
chatMessage.setSender(username);
messagingTemplate.convertAndSend(String.format("/topic/rooms/%s", roomId), chatMessage);
}
}
}

View File

@@ -0,0 +1,34 @@
package de.w665.testing.controller;
import de.w665.testing.model.ChatRoom;
import de.w665.testing.service.ChatRoomService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Collection;
@RestController
@RequestMapping("/api/chat-rooms")
@RequiredArgsConstructor
public class ChatRoomController {
private final ChatRoomService chatRoomService;
@PostMapping
public ChatRoom createRoom(@RequestBody CreateRoomRequest request) {
return chatRoomService.createChatRoom(request.name(), request.password());
}
@GetMapping
public Collection<ChatRoom> getRooms() {
return chatRoomService.findAll();
}
@PostMapping("/{roomId}/join")
public boolean joinRoom(@PathVariable String roomId, @RequestBody JoinRoomRequest request) {
return chatRoomService.joinRoom(roomId, request.password());
}
public record CreateRoomRequest(String name, String password) {}
public record JoinRoomRequest(String password) {}
}

View File

@@ -6,6 +6,12 @@ import lombok.Setter;
@Getter
@Setter
public class ChatMessage {
public enum MessageType {
CHAT, JOIN, LEAVE
}
private MessageType type;
private String content;
private String sender;
}

View File

@@ -0,0 +1,32 @@
package de.w665.testing.model;
import lombok.Getter;
import lombok.Setter;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Getter
@Setter
public class ChatRoom {
private String id;
private String name;
private String password;
private Set<String> users = new HashSet<>();
public ChatRoom(String name, String password) {
this.id = UUID.randomUUID().toString();
this.name = name;
this.password = password;
}
public void addUser(String username) {
users.add(username);
}
public void removeUser(String username) {
users.remove(username);
}
}

View File

@@ -0,0 +1,35 @@
package de.w665.testing.service;
import de.w665.testing.model.ChatRoom;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ChatRoomService {
private final Map<String, ChatRoom> chatRooms = new ConcurrentHashMap<>();
public ChatRoom createChatRoom(String name, String password) {
ChatRoom chatRoom = new ChatRoom(name, password);
chatRooms.put(chatRoom.getId(), chatRoom);
return chatRoom;
}
public Optional<ChatRoom> findById(String id) {
return Optional.ofNullable(chatRooms.get(id));
}
public Collection<ChatRoom> findAll() {
return chatRooms.values();
}
public boolean joinRoom(String roomId, String password) {
return findById(roomId)
.map(room -> room.getPassword() == null || room.getPassword().isEmpty() || room.getPassword().equals(password))
.orElse(false);
}
}

View File

@@ -4,50 +4,28 @@
<title>QChat</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<style>
body {
background-color: #f4f4f4;
}
#username-page, #chat-page {
display: none;
}
#chat-page {
margin-top: 20px;
}
.chat-header {
background-color: #007bff;
color: white;
padding: 15px;
border-radius: 5px 5px 0 0;
}
#message-area {
height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 15px;
background-color: white;
}
.message-form {
margin-top: 15px;
}
body { background-color: #f4f4f4; }
#username-page, #room-selection-page, #chat-page { display: none; }
.chat-header { background-color: #007bff; color: white; padding: 15px; border-radius: 5px 5px 0 0; }
#message-area { height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 15px; background-color: white; }
</style>
</head>
<body>
<div class="container">
<div class="container mt-4">
<!-- Username Page -->
<div id="username-page">
<div class="row justify-content-center mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header text-center">
<h3>Enter Your Name</h3>
</div>
<div class="card-header text-center"><h3>Enter Your Name</h3></div>
<div class="card-body">
<form id="username-form">
<div class="form-group">
<input type="text" id="name" placeholder="Username" class="form-control" autocomplete="off">
</div>
<div class="form-group text-center">
<button type="submit" class="btn btn-primary">Join Chat</button>
<button type="submit" class="btn btn-primary">Continue</button>
</div>
</form>
</div>
@@ -56,13 +34,29 @@
</div>
</div>
<!-- Room Selection Page -->
<div id="room-selection-page">
<div class="card">
<div class="card-header"><h3>Chat Rooms</h3></div>
<div class="card-body">
<h4>Create a New Room</h4>
<form id="create-room-form" class="form-inline mb-4">
<input type="text" id="room-name" placeholder="Room Name" class="form-control mr-2" required>
<input type="password" id="room-password" placeholder="Password (optional)" class="form-control mr-2">
<button type="submit" class="btn btn-success">Create</button>
</form>
<h4>Available Rooms</h4>
<ul id="room-list" class="list-group"></ul>
</div>
</div>
</div>
<!-- Chat Page -->
<div id="chat-page">
<div class="chat-container">
<div class="chat-header">
<h2>QChat</h2>
</div>
<div class="chat-header"><h2 id="chat-room-name"></h2></div>
<ul id="message-area" class="list-unstyled"></ul>
<form id="message-form" name="messageForm" class="message-form">
<form id="message-form" class="mt-3">
<div class="input-group">
<input type="text" id="message" placeholder="Type a message..." class="form-control" autocomplete="off"/>
<div class="input-group-append">
@@ -78,70 +72,123 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const usernamePage = document.querySelector('#username-page');
const chatPage = document.querySelector('#chat-page');
const pages = {
username: document.querySelector('#username-page'),
roomSelection: document.querySelector('#room-selection-page'),
chat: document.querySelector('#chat-page')
};
const usernameForm = document.querySelector('#username-form');
const createRoomForm = document.querySelector('#create-room-form');
const messageForm = document.querySelector('#message-form');
const messageInput = document.querySelector('#message');
const messageArea = document.querySelector('#message-area');
const roomList = document.querySelector('#room-list');
let stompClient = null;
let username = null;
let currentRoom = null;
usernamePage.style.display = 'block';
function showPage(pageName) {
Object.values(pages).forEach(p => p.style.display = 'none');
pages[pageName].style.display = 'block';
}
usernameForm.addEventListener('submit', connect, true);
messageForm.addEventListener('submit', sendMessage, true);
function connect(event) {
usernameForm.addEventListener('submit', (e) => {
e.preventDefault();
username = document.querySelector('#name').value.trim();
if (username) {
usernamePage.style.display = 'none';
chatPage.style.display = 'block';
showPage('roomSelection');
loadRooms();
}
}, true);
createRoomForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('room-name').value.trim();
const password = document.getElementById('room-password').value.trim();
if (name) {
await fetch('/api/chat-rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, password })
});
document.getElementById('room-name').value = '';
document.getElementById('room-password').value = '';
loadRooms();
}
});
async function loadRooms() {
const response = await fetch('/api/chat-rooms');
const rooms = await response.json();
roomList.innerHTML = '';
rooms.forEach(room => {
const roomElement = document.createElement('li');
roomElement.className = 'list-group-item d-flex justify-content-between align-items-center';
roomElement.textContent = room.name;
const joinButton = document.createElement('button');
joinButton.className = 'btn btn-primary btn-sm';
joinButton.textContent = 'Join';
joinButton.onclick = () => joinRoom(room);
roomElement.appendChild(joinButton);
roomList.appendChild(roomElement);
});
}
async function joinRoom(room) {
let password = '';
if (room.password) {
password = prompt('This room is password protected. Please enter the password:');
if (password === null) return; // User cancelled
}
const response = await fetch(`/api/chat-rooms/${room.id}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (response.ok && await response.json()) {
currentRoom = room;
showPage('chat');
document.getElementById('chat-room-name').textContent = currentRoom.name;
connectToChat();
} else {
alert('Failed to join room. Please check the password and try again.');
}
}
function connectToChat() {
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.connect({}, onConnected, onError);
}
event.preventDefault();
}
function onConnected() {
// Subscribe to the Public Topic
stompClient.subscribe('/topic/public', onMessageReceived);
// Tell your username to the server
stompClient.send("/app/chat.addUser",
{},
JSON.stringify({sender: username, type: 'JOIN'})
)
stompClient.subscribe(`/topic/rooms/${currentRoom.id}`, onMessageReceived);
stompClient.send(`/app/chat/${currentRoom.id}/addUser`, {}, JSON.stringify({ sender: username, type: 'JOIN' }));
}
function onError(error) {
const connectingElement = document.createElement('li');
connectingElement.classList.add('list-group-item', 'list-group-item-danger');
connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
messageArea.appendChild(connectingElement);
console.error('WebSocket Error:', error);
alert('Could not connect to chat. Please try again.');
showPage('roomSelection');
}
function sendMessage(event) {
messageForm.addEventListener('submit', (e) => {
e.preventDefault();
const messageContent = messageInput.value.trim();
if (messageContent && stompClient) {
const chatMessage = {
sender: username,
content: messageInput.value
};
stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
const chatMessage = { sender: username, content: messageContent, type: 'CHAT' };
stompClient.send(`/app/chat/${currentRoom.id}/sendMessage`, {}, JSON.stringify(chatMessage));
messageInput.value = '';
}
event.preventDefault();
}
}, true);
function onMessageReceived(payload) {
const message = JSON.parse(payload.body);
const messageElement = document.createElement('li');
messageElement.classList.add('list-group-item');
@@ -158,10 +205,11 @@
}
messageElement.appendChild(document.createTextNode(message.content));
messageArea.appendChild(messageElement);
messageArea.scrollTop = messageArea.scrollHeight;
}
showPage('username');
});
</script>
</body>