From 03ddf7bd6d5238ec5f8672b4529cd0374f47e1af Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 18:18:36 -0300 Subject: [PATCH] phase 07: add private direct messaging --- README.md | 24 +- examples/private-chat/README.md | 82 ++ examples/private-chat/public/assets/app.js | 968 ++++++++++++++++++ examples/private-chat/public/assets/style.css | 525 ++++++++++ examples/private-chat/public/index.html | 103 ++ examples/private-chat/server.php | 81 ++ src/Chat/ChatKernel.php | 71 +- src/Chat/DirectMessageRouter.php | 7 +- .../Chat/DirectPrivateMessagingTest.php | 286 ++++++ tests/Unit/Chat/DirectMessageRouterTest.php | 43 + 10 files changed, 2182 insertions(+), 8 deletions(-) create mode 100644 examples/private-chat/README.md create mode 100644 examples/private-chat/public/assets/app.js create mode 100644 examples/private-chat/public/assets/style.css create mode 100644 examples/private-chat/public/index.html create mode 100644 examples/private-chat/server.php create mode 100644 tests/Integration/Chat/DirectPrivateMessagingTest.php create mode 100644 tests/Unit/Chat/DirectMessageRouterTest.php diff --git a/README.md b/README.md index 2534dda..1ee719a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The project is currently in the **Composer foundation phase**. This means the repository already has the initial Composer package structure, PSR-4 namespace configuration, base configuration classes, PHPUnit setup, PHPStan setup, PHP CS Fixer setup, and GitHub Actions workflow. -The modern WebSocket runtime is not implemented yet. +The modern WebSocket runtime, global chat examples, callback-based MediumChat example, and first direct private chat example are now implemented. ## Installation for development @@ -102,6 +102,28 @@ MediumChat demonstrates high-level callbacks such as `user.joined`, `user.left`, EasyChat and MediumChat also include typing indicators and simple message status receipts for sent, received, and read states. +## Running the PrivateChat example + +Start the WebSocket server: + +```bash +php examples/private-chat/server.php +``` + +Open a second terminal and start the browser UI: + +```bash +php -S 127.0.0.1:8002 -t examples/private-chat/public +``` + +Then open: + +```txt +http://127.0.0.1:8002 +``` + +PrivateChat demonstrates global chat plus direct 1:1 private conversations. A direct message is delivered only to the sender and the selected recipient. + ## Requirements The modern version targets: diff --git a/examples/private-chat/README.md b/examples/private-chat/README.md new file mode 100644 index 0000000..996b85a --- /dev/null +++ b/examples/private-chat/README.md @@ -0,0 +1,82 @@ +# PrivateChat Example + +PrivateChat is the direct messaging example for PHPSockets With WebSockets. + +It demonstrates: + +- Unique display names. +- Online users list. +- Global room. +- Private direct 1:1 conversations. +- Direct messages delivered only to the sender and the selected recipient. +- Typing indicators for global and direct conversations. +- Simple message receipts for sent, received and read states. +- Safe rendering with `textContent`. +- Plain HTML, CSS and JavaScript. +- Bootstrap through CDN. + +## Requirements + +From the project root: + +```bash +composer install +``` + +The PHP `sockets` extension must be enabled. + +## Running the WebSocket server + +```bash +php examples/private-chat/server.php +``` + +By default: + +```txt +ws://127.0.0.1:8080 +``` + +## Running the browser UI + +Open a second terminal: + +```bash +php -S 127.0.0.1:8002 -t examples/private-chat/public +``` + +Then open: + +```txt +http://127.0.0.1:8002 +``` + +## Manual test + +Open three browser tabs: + +```txt +Tab 1: William +Tab 2: Ana +Tab 3: Bruno +``` + +Expected behavior: + +- All users enter with unique names. +- All users see the online list. +- Global messages appear for everyone. +- William clicks Ana and sends a private message. +- Ana receives the private message. +- Bruno does not receive the private message. +- Ana can reply to William. +- Typing in the private conversation appears only for the selected recipient. +- Message status moves from sent to received/read. +- Duplicate names are rejected. +- User messages are rendered safely without `innerHTML`. + +## Important notes + +This phase implements direct 1:1 private messaging. + +Private group rooms are implemented in the next phase. diff --git a/examples/private-chat/public/assets/app.js b/examples/private-chat/public/assets/app.js new file mode 100644 index 0000000..42359f3 --- /dev/null +++ b/examples/private-chat/public/assets/app.js @@ -0,0 +1,968 @@ +const state = { + socket: null, + currentUser: null, + users: new Map(), + activeConversationId: 'global', + conversations: new Map(), + messagesByConversation: new Map(), + typingUsersByConversation: new Map(), + typingTimers: new Map(), + pendingMessages: new Map(), + pendingMessageConversations: new Map(), + messageElements: new Map(), + messageReadBy: new Map(), + isTyping: false, + typingStopTimer: null, + lastTypingStartSentAt: 0, + typingHeartbeatMs: 1000, + typingIdleStopMs: 1400, +}; + +state.conversations.set('global', { + id: 'global', + type: 'global', + title: 'Global Room', + subtitle: 'Everyone online', + targetUserId: null, + roomId: 'global', +}); + +const elements = { + alertBox: document.getElementById('alertBox'), + chatPanel: document.getElementById('chatPanel'), + connectionStatus: document.getElementById('connectionStatus'), + conversationEyebrow: document.getElementById('conversationEyebrow'), + conversationTitle: document.getElementById('conversationTitle'), + currentDisplayName: document.getElementById('currentDisplayName'), + displayNameInput: document.getElementById('displayNameInput'), + globalRoomButton: document.getElementById('globalRoomButton'), + joinButton: document.getElementById('joinButton'), + joinForm: document.getElementById('joinForm'), + loginPanel: document.getElementById('loginPanel'), + messageForm: document.getElementById('messageForm'), + messageInput: document.getElementById('messageInput'), + messagesList: document.getElementById('messagesList'), + onlineCount: document.getElementById('onlineCount'), + serverUrlInput: document.getElementById('serverUrlInput'), + typingIndicator: document.getElementById('typingIndicator'), + usersList: document.getElementById('usersList'), +}; + +elements.joinForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const displayName = elements.displayNameInput.value.trim(); + const serverUrl = elements.serverUrlInput.value.trim(); + + if (!displayName || !serverUrl) { + showAlert('Display name and WebSocket server URL are required.', 'danger'); + return; + } + + connect(serverUrl, displayName); +}); + +elements.globalRoomButton.addEventListener('click', () => { + setActiveConversation('global'); +}); + +elements.messageForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const text = elements.messageInput.value.trim(); + const conversation = state.conversations.get(state.activeConversationId); + + if (!text) { + stopTyping(); + return; + } + + if (!conversation) { + showAlert('Choose a conversation before sending a message.', 'warning'); + return; + } + + const clientMessageId = createClientMessageId(); + + clearLocalTypingStateBeforeSend(); + addPendingOwnMessage(text, clientMessageId, conversation.id); + + if (conversation.type === 'global') { + sendEnvelope('message.global', { text, clientMessageId }); + } else { + sendEnvelope('message.direct', { + toUserId: conversation.targetUserId, + text, + clientMessageId, + }); + } + + elements.messageInput.value = ''; + elements.messageInput.focus(); +}); + +elements.messageInput.addEventListener('input', () => { + handleTypingInput(); +}); + +elements.messageInput.addEventListener('blur', () => { + stopTyping(); +}); + +window.addEventListener('beforeunload', () => { + stopTyping(); + + if (state.socket && state.socket.readyState === WebSocket.OPEN) { + state.socket.close(); + } +}); + +renderEmptyMessages(); +renderTypingIndicator(); +renderConversationHeader(); +setStatus('Disconnected', 'offline'); + +function connect(serverUrl, displayName) { + disconnect(); + clearAlert(); + setStatus('Connecting', 'connecting'); + setJoinFormEnabled(false); + + try { + state.socket = new WebSocket(serverUrl); + } catch (error) { + setJoinFormEnabled(true); + setStatus('Disconnected', 'offline'); + showAlert('Invalid WebSocket server URL.', 'danger'); + return; + } + + state.socket.addEventListener('open', () => { + sendEnvelope('auth.join', { displayName }); + }); + + state.socket.addEventListener('message', (event) => { + handleServerMessage(event.data); + }); + + state.socket.addEventListener('close', () => { + const hadCurrentUser = Boolean(state.currentUser); + + setStatus('Disconnected', 'offline'); + + if (hadCurrentUser) { + showAlert('Connection closed. Start the server again and re-enter the chat.', 'warning'); + resetToLogin(false); + return; + } + + resetToLogin(true); + }); + + state.socket.addEventListener('error', () => { + setStatus('Connection error', 'offline'); + + if (!state.currentUser) { + setJoinFormEnabled(true); + } + + showAlert('Could not connect to the WebSocket server.', 'danger'); + }); +} + +function disconnect() { + if ( + state.socket && + (state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING) + ) { + state.socket.close(); + } + + state.socket = null; +} + +function handleServerMessage(rawMessage) { + let envelope; + + try { + envelope = JSON.parse(rawMessage); + } catch (error) { + showAlert('The server sent an invalid JSON message.', 'danger'); + return; + } + + switch (envelope.type) { + case 'session.accepted': + handleSessionAccepted(envelope.payload); + break; + + case 'session.rejected': + handleSessionRejected(envelope.payload); + break; + + case 'presence.snapshot': + handlePresenceSnapshot(envelope.payload); + break; + + case 'presence.user_joined': + handleUserJoined(envelope.payload); + break; + + case 'presence.user_left': + handleUserLeft(envelope.payload); + break; + + case 'message.received': + handleMessageReceived(envelope.payload); + break; + + case 'message.read': + handleMessageRead(envelope.payload); + break; + + case 'typing.started': + handleTypingStarted(envelope.payload); + break; + + case 'typing.stopped': + handleTypingStopped(envelope.payload); + break; + + case 'error': + handleServerError(envelope.payload); + break; + + default: + showAlert(`Unsupported server event: ${envelope.type}`, 'warning'); + break; + } +} + +function handleSessionAccepted(payload) { + const session = payload.session; + + state.currentUser = session; + state.users.set(session.userId, session); + + elements.currentDisplayName.textContent = session.displayName; + elements.loginPanel.classList.add('d-none'); + elements.chatPanel.classList.remove('d-none'); + + setStatus('Connected', 'online'); + setJoinFormEnabled(true); + clearAlert(); + setActiveConversation('global'); + renderUsers(); + + elements.messageInput.focus(); +} + +function handleSessionRejected(payload) { + const message = payload.message || 'Could not enter the chat.'; + + disconnect(); + resetToLogin(true); + showAlert(message, 'danger'); +} + +function handlePresenceSnapshot(payload) { + const users = Array.isArray(payload.users) ? payload.users : []; + + state.users.clear(); + + for (const user of users) { + if (user && user.userId) { + state.users.set(user.userId, user); + ensureDirectConversationFromUserId(user.userId); + } + } + + renderUsers(); +} + +function handleUserJoined(payload) { + const user = payload.user; + + if (user && user.userId) { + state.users.set(user.userId, user); + ensureDirectConversationFromUserId(user.userId); + renderUsers(); + } +} + +function handleUserLeft(payload) { + if (payload.userId) { + state.users.delete(payload.userId); + clearTypingUserInAllConversations(payload.userId); + renderUsers(); + } +} + +function handleMessageReceived(payload) { + if (!payload.message) { + return; + } + + const message = payload.message; + const conversationId = conversationIdForMessage(message); + const conversation = state.conversations.get(conversationId); + + if (conversation && message.roomId && message.roomId !== 'global') { + conversation.roomId = message.roomId; + } + + clearTypingUserForConversation(conversationId, message.fromUserId); + + const isOwn = state.currentUser && message.fromUserId === state.currentUser.userId; + const clientMessageId = message.metadata && message.metadata.clientMessageId; + + if (isOwn && clientMessageId && state.pendingMessages.has(clientMessageId)) { + state.pendingMessages.delete(clientMessageId); + updatePendingMessageAsReceived(clientMessageId, message, conversationId); + return; + } + + addMessage(message, conversationId); + + if (!isOwn) { + sendEnvelope('message.read', { + messageId: message.id, + roomId: message.roomId || 'global', + }); + } +} + +function handleMessageRead(payload) { + if (!payload.messageId || !payload.userId) { + return; + } + + if (state.currentUser && payload.userId === state.currentUser.userId) { + return; + } + + const readBy = state.messageReadBy.get(payload.messageId) || new Map(); + readBy.set(payload.userId, payload.displayName || 'Someone'); + state.messageReadBy.set(payload.messageId, readBy); + + updateMessageStatus(payload.messageId, 'read'); +} + +function handleTypingStarted(payload) { + if (!payload.userId || !payload.displayName) { + return; + } + + if (state.currentUser && payload.userId === state.currentUser.userId) { + return; + } + + const conversationId = conversationIdForTyping(payload); + const typingUsers = typingUsersForConversation(conversationId); + + typingUsers.set(payload.userId, payload.displayName); + + const timerKey = typingTimerKey(conversationId, payload.userId); + const currentTimer = state.typingTimers.get(timerKey); + + if (currentTimer) { + window.clearTimeout(currentTimer); + } + + const timer = window.setTimeout(() => { + clearTypingUserForConversation(conversationId, payload.userId); + }, 4000); + + state.typingTimers.set(timerKey, timer); + renderTypingIndicator(); +} + +function handleTypingStopped(payload) { + if (!payload.userId) { + return; + } + + clearTypingUserForConversation(conversationIdForTyping(payload), payload.userId); +} + +function handleServerError(payload) { + const message = payload.message || 'The server returned an error.'; + + if (!state.currentUser) { + disconnect(); + resetToLogin(true); + } + + showAlert(message, 'danger'); +} + +function sendEnvelope(type, payload) { + if (!state.socket || state.socket.readyState !== WebSocket.OPEN) { + showAlert('WebSocket connection is not open.', 'danger'); + return; + } + + state.socket.send(JSON.stringify({ type, payload })); +} + +function directConversationId(userId) { + return `direct:${userId}`; +} + +function openDirectConversation(userId) { + const user = state.users.get(userId); + + if (!user || (state.currentUser && userId === state.currentUser.userId)) { + return; + } + + ensureDirectConversationFromUserId(userId); + setActiveConversation(directConversationId(userId)); +} + +function ensureDirectConversationFromUserId(userId) { + if (state.currentUser && userId === state.currentUser.userId) { + return; + } + + const user = state.users.get(userId); + + if (!user) { + return; + } + + const conversationId = directConversationId(userId); + + if (!state.conversations.has(conversationId)) { + state.conversations.set(conversationId, { + id: conversationId, + type: 'direct', + title: user.displayName, + subtitle: 'Private conversation', + targetUserId: userId, + roomId: null, + }); + } +} + +function setActiveConversation(conversationId) { + if (!state.conversations.has(conversationId)) { + return; + } + + stopTyping(); + + state.activeConversationId = conversationId; + renderConversationHeader(); + renderUsers(); + renderMessages(); + renderTypingIndicator(); + + elements.messageInput.focus(); +} + +function renderConversationHeader() { + const conversation = state.conversations.get(state.activeConversationId); + + if (!conversation) { + return; + } + + elements.conversationTitle.textContent = conversation.title; + elements.conversationEyebrow.textContent = conversation.type === 'global' ? 'Global room' : 'Private direct'; + elements.globalRoomButton.classList.toggle('conversation-item-active', conversation.id === 'global'); +} + +function conversationIdForMessage(message) { + if (message.roomId === 'global') { + return 'global'; + } + + if (state.currentUser && message.fromUserId !== state.currentUser.userId) { + ensureDirectConversationFromUserId(message.fromUserId); + return directConversationId(message.fromUserId); + } + + const clientMessageId = message.metadata && message.metadata.clientMessageId; + const pendingConversationId = clientMessageId ? state.pendingMessageConversations.get(clientMessageId) : null; + + if (pendingConversationId) { + return pendingConversationId; + } + + return 'global'; +} + +function conversationIdForTyping(payload) { + if (payload.scope === 'direct' && payload.userId) { + ensureDirectConversationFromUserId(payload.userId); + return directConversationId(payload.userId); + } + + return 'global'; +} + +function typingPayloadForActiveConversation() { + const conversation = state.conversations.get(state.activeConversationId); + + if (!conversation || conversation.type === 'global') { + return { roomId: 'global' }; + } + + return { + scope: 'direct', + toUserId: conversation.targetUserId, + roomId: conversation.roomId || null, + }; +} + +function messagesForConversation(conversationId) { + if (!state.messagesByConversation.has(conversationId)) { + state.messagesByConversation.set(conversationId, []); + } + + return state.messagesByConversation.get(conversationId); +} + +function typingUsersForConversation(conversationId) { + if (!state.typingUsersByConversation.has(conversationId)) { + state.typingUsersByConversation.set(conversationId, new Map()); + } + + return state.typingUsersByConversation.get(conversationId); +} + +function createClientMessageId() { + return `client_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +function addPendingOwnMessage(text, clientMessageId, conversationId) { + const message = { + id: clientMessageId, + roomId: conversationId === 'global' ? 'global' : null, + fromUserId: state.currentUser ? state.currentUser.userId : null, + kind: 'text', + body: text, + metadata: { clientMessageId }, + createdAt: new Date().toISOString(), + status: 'sent', + }; + + state.pendingMessages.set(clientMessageId, message); + state.pendingMessageConversations.set(clientMessageId, conversationId); + addMessage(message, conversationId); +} + +function addMessage(message, conversationId) { + messagesForConversation(conversationId).push(message); + + if (conversationId === state.activeConversationId) { + appendMessageElement(message); + elements.messagesList.scrollTop = elements.messagesList.scrollHeight; + } +} + +function updatePendingMessageAsReceived(clientMessageId, message, conversationId) { + message.status = 'received'; + + replaceStoredMessage(conversationId, clientMessageId, message); + state.pendingMessageConversations.delete(clientMessageId); + + const row = state.messageElements.get(clientMessageId); + + if (!row) { + if (conversationId === state.activeConversationId) { + renderMessages(); + } + + return; + } + + state.messageElements.delete(clientMessageId); + state.messageElements.set(message.id, row); + row.dataset.messageId = message.id; + + const status = row.querySelector('.message-status'); + updateStatusElement(status, 'received'); +} + +function replaceStoredMessage(conversationId, currentMessageId, nextMessage) { + const messages = messagesForConversation(conversationId); + const index = messages.findIndex((message) => message.id === currentMessageId); + + if (index === -1) { + messages.push(nextMessage); + return; + } + + messages[index] = nextMessage; +} + +function updateMessageStatus(messageId, statusName) { + for (const messages of state.messagesByConversation.values()) { + const message = messages.find((item) => item.id === messageId); + + if (message) { + message.status = statusName; + } + } + + const row = state.messageElements.get(messageId); + + if (!row) { + return; + } + + const status = row.querySelector('.message-status'); + + if (!status) { + return; + } + + updateStatusElement(status, statusName); +} + +function updateStatusElement(element, statusName) { + if (!element) { + return; + } + + element.classList.remove('message-status-sent', 'message-status-received', 'message-status-read'); + element.classList.add(`message-status-${statusName}`); + + if (statusName === 'sent') { + element.textContent = '✓'; + element.title = 'Message sent'; + return; + } + + if (statusName === 'received') { + element.textContent = '✓✓'; + element.title = 'Message received'; + return; + } + + element.textContent = '✓✓'; + element.title = 'Message read'; +} + +function handleTypingInput() { + if (!state.currentUser) { + return; + } + + const text = elements.messageInput.value.trim(); + + if (!text) { + stopTyping(); + return; + } + + if (!state.isTyping) { + state.isTyping = true; + sendTypingStart(); + } else if (Date.now() - state.lastTypingStartSentAt >= state.typingHeartbeatMs) { + sendTypingStart(); + } + + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + } + + state.typingStopTimer = window.setTimeout(() => { + stopTyping(); + }, state.typingIdleStopMs); +} + +function sendTypingStart() { + state.lastTypingStartSentAt = Date.now(); + sendEnvelope('typing.start', typingPayloadForActiveConversation()); +} + +function stopTyping() { + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + state.typingStopTimer = null; + } + + if (!state.isTyping) { + return; + } + + state.isTyping = false; + state.lastTypingStartSentAt = 0; + + if (state.socket && state.socket.readyState === WebSocket.OPEN && state.currentUser) { + sendEnvelope('typing.stop', typingPayloadForActiveConversation()); + } +} + +function clearLocalTypingStateBeforeSend() { + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + state.typingStopTimer = null; + } + + state.isTyping = false; + state.lastTypingStartSentAt = 0; +} + +function resetToLogin(keepDisplayName) { + state.currentUser = null; + state.users.clear(); + state.conversations.clear(); + state.conversations.set('global', { + id: 'global', + type: 'global', + title: 'Global Room', + subtitle: 'Everyone online', + targetUserId: null, + roomId: 'global', + }); + state.activeConversationId = 'global'; + state.messagesByConversation.clear(); + state.pendingMessages.clear(); + state.pendingMessageConversations.clear(); + state.messageElements.clear(); + state.messageReadBy.clear(); + clearTypingState(); + + elements.chatPanel.classList.add('d-none'); + elements.loginPanel.classList.remove('d-none'); + elements.currentDisplayName.textContent = '-'; + + if (!keepDisplayName) { + elements.displayNameInput.value = ''; + } + + setJoinFormEnabled(true); + renderConversationHeader(); + renderUsers(); + renderMessages(); +} + +function setJoinFormEnabled(enabled) { + elements.displayNameInput.disabled = !enabled; + elements.serverUrlInput.disabled = !enabled; + elements.joinButton.disabled = !enabled; + elements.joinButton.textContent = enabled ? 'Enter Chat' : 'Connecting...'; +} + +function setStatus(label, mode) { + elements.connectionStatus.textContent = label; + elements.connectionStatus.classList.remove('status-online', 'status-offline', 'status-connecting'); + elements.connectionStatus.classList.add(`status-${mode}`); +} + +function showAlert(message, type) { + elements.alertBox.textContent = message; + elements.alertBox.className = `alert app-alert alert-${type}`; +} + +function clearAlert() { + elements.alertBox.textContent = ''; + elements.alertBox.className = 'alert app-alert d-none'; +} + +function renderUsers() { + elements.usersList.replaceChildren(); + elements.onlineCount.textContent = String(state.users.size); + + const users = [...state.users.values()] + .filter((user) => !state.currentUser || user.userId !== state.currentUser.userId) + .sort((first, second) => first.displayName.localeCompare(second.displayName)); + + if (users.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No other users online yet.'; + elements.usersList.appendChild(empty); + return; + } + + for (const user of users) { + const item = document.createElement('button'); + item.className = 'user-item'; + item.type = 'button'; + item.classList.toggle('user-item-active', state.activeConversationId === directConversationId(user.userId)); + item.addEventListener('click', () => { + openDirectConversation(user.userId); + }); + + const avatar = document.createElement('div'); + avatar.className = 'user-avatar'; + avatar.textContent = user.displayName.slice(0, 1).toUpperCase(); + + const name = document.createElement('div'); + name.className = 'user-name'; + name.textContent = user.displayName; + + item.appendChild(avatar); + item.appendChild(name); + + elements.usersList.appendChild(item); + } +} + +function renderMessages() { + state.messageElements.clear(); + elements.messagesList.replaceChildren(); + + const messages = messagesForConversation(state.activeConversationId); + + if (messages.length === 0) { + renderEmptyMessages(); + return; + } + + for (const message of messages) { + appendMessageElement(message); + } + + elements.messagesList.scrollTop = elements.messagesList.scrollHeight; +} + +function renderEmptyMessages() { + elements.messagesList.replaceChildren(); + + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No messages yet. Start the conversation.'; + + elements.messagesList.appendChild(empty); +} + +function appendMessageElement(message) { + const empty = elements.messagesList.querySelector('.empty-state'); + + if (empty) { + empty.remove(); + } + + const isOwn = state.currentUser && message.fromUserId === state.currentUser.userId; + const sender = findDisplayName(message.fromUserId); + const createdAt = formatTime(message.createdAt); + + const row = document.createElement('div'); + row.className = isOwn ? 'message-row is-own' : 'message-row'; + row.dataset.messageId = message.id; + + const footer = document.createElement('div'); + footer.className = 'message-footer'; + + const meta = document.createElement('div'); + meta.className = 'message-meta'; + meta.textContent = `${sender} - ${createdAt}`; + + const status = document.createElement('span'); + status.className = 'message-status'; + updateStatusElement(status, isOwn ? message.status || 'received' : 'received'); + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + bubble.textContent = message.body || ''; + + footer.appendChild(meta); + footer.appendChild(status); + row.appendChild(footer); + row.appendChild(bubble); + + state.messageElements.set(message.id, row); + elements.messagesList.appendChild(row); +} + +function renderTypingIndicator() { + const names = [...typingUsersForConversation(state.activeConversationId).values()]; + + if (names.length === 0) { + elements.typingIndicator.textContent = ''; + elements.typingIndicator.classList.add('d-none'); + return; + } + + elements.typingIndicator.textContent = `${formatTypingNames(names)} ${names.length === 1 ? 'is' : 'are'} typing`; + elements.typingIndicator.classList.remove('d-none'); +} + +function formatTypingNames(names) { + if (names.length === 1) { + return names[0]; + } + + if (names.length === 2) { + return `${names[0]} and ${names[1]}`; + } + + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +} + +function clearTypingUserForConversation(conversationId, userId) { + if (!userId) { + return; + } + + const timerKey = typingTimerKey(conversationId, userId); + const timer = state.typingTimers.get(timerKey); + + if (timer) { + window.clearTimeout(timer); + state.typingTimers.delete(timerKey); + } + + typingUsersForConversation(conversationId).delete(userId); + renderTypingIndicator(); +} + +function clearTypingUserInAllConversations(userId) { + for (const conversationId of state.typingUsersByConversation.keys()) { + clearTypingUserForConversation(conversationId, userId); + } +} + +function clearTypingState() { + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + state.typingStopTimer = null; + } + + for (const timer of state.typingTimers.values()) { + window.clearTimeout(timer); + } + + state.typingUsersByConversation.clear(); + state.typingTimers.clear(); + state.isTyping = false; + state.lastTypingStartSentAt = 0; + renderTypingIndicator(); +} + +function typingTimerKey(conversationId, userId) { + return `${conversationId}:${userId}`; +} + +function findDisplayName(userId) { + const user = state.users.get(userId); + + if (!user) { + return state.currentUser && userId === state.currentUser.userId ? state.currentUser.displayName : 'Unknown user'; + } + + return user.displayName; +} + +function formatTime(value) { + if (!value) { + return 'now'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return 'now'; + } + + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/examples/private-chat/public/assets/style.css b/examples/private-chat/public/assets/style.css new file mode 100644 index 0000000..b7ccfd7 --- /dev/null +++ b/examples/private-chat/public/assets/style.css @@ -0,0 +1,525 @@ +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + overflow: hidden; +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at top left, rgba(16, 185, 129, 0.2), transparent 32rem), + radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.18), transparent 28rem), + #060a12; + color: #f8fafc; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.app-shell { + width: min(1420px, calc(100vw - 32px)); + height: 100vh; + margin: 0 auto; + padding: 24px 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.hero-card, +.panel { + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.84); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38); + backdrop-filter: blur(18px); +} + +.hero-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 26px; + border-radius: 24px; + margin-bottom: 18px; +} + +.hero-card h1 { + margin: 8px 0; + font-size: clamp(2.3rem, 5vw, 4.4rem); + line-height: 0.95; +} + +.hero-card p { + max-width: 760px; + margin: 0; + color: #b6c4d8; + font-size: 1.03rem; +} + +.eyebrow { + display: inline-flex; + color: #6ee7b7; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.status-pill, +.count-pill { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status-pill { + min-width: 150px; + padding: 12px 16px; +} + +.status-online { + border: 1px solid rgba(34, 197, 94, 0.4); + background: rgba(34, 197, 94, 0.14); + color: #86efac; +} + +.status-offline { + border: 1px solid rgba(248, 113, 113, 0.34); + background: rgba(248, 113, 113, 0.12); + color: #fca5a5; +} + +.status-connecting { + border: 1px solid rgba(250, 204, 21, 0.42); + background: rgba(250, 204, 21, 0.12); + color: #fde68a; +} + +.app-alert { + border: 0; + border-radius: 16px; +} + +.panel { + border-radius: 22px; + padding: 22px; +} + +.login-panel { + width: 100%; + margin: 0; +} + +.panel-header { + margin-bottom: 20px; +} + +.panel-header.compact { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-header h2, +.chat-header h2 { + margin: 0; + font-size: 1.24rem; +} + +.panel-header p { + margin: 8px 0 0; + color: #a7b3c6; +} + +.form-label { + color: #dbeafe; + font-weight: 700; +} + +.form-control { + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(3, 7, 18, 0.74); + color: #f8fafc; + border-radius: 14px; +} + +.form-control:focus { + border-color: rgba(52, 211, 153, 0.76); + background: rgba(3, 7, 18, 0.9); + color: #f8fafc; + box-shadow: 0 0 0 0.25rem rgba(52, 211, 153, 0.14); +} + +.form-control::placeholder { + color: #64748b; +} + +.btn-primary { + border: 0; + border-radius: 14px; + background: linear-gradient(135deg, #059669, #2563eb); + font-weight: 800; + box-shadow: 0 16px 34px rgba(16, 185, 129, 0.18); +} + +.btn-primary:hover { + filter: brightness(1.08); +} + +.private-layout { + display: grid; + grid-template-columns: 300px minmax(0, 1fr) 310px; + gap: 18px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.side-panel, +.chat-panel, +.info-panel { + min-height: 0; + max-height: 100%; +} + +.side-panel, +.info-panel { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.count-pill { + min-width: 38px; + height: 32px; + background: rgba(16, 185, 129, 0.15); + color: #6ee7b7; +} + +.conversation-item, +.user-item { + width: 100%; + display: flex; + align-items: center; + gap: 11px; + min-height: 54px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 16px; + background: rgba(15, 23, 42, 0.7); + color: #e2e8f0; + text-align: left; +} + +.conversation-item { + margin-bottom: 16px; +} + +.conversation-item:hover, +.user-item:hover, +.conversation-item-active, +.user-item-active { + border-color: rgba(52, 211, 153, 0.44); + background: rgba(5, 150, 105, 0.18); +} + +.conversation-avatar, +.user-avatar { + display: grid; + place-items: center; + width: 36px; + height: 36px; + flex: 0 0 auto; + border-radius: 50%; + background: rgba(37, 99, 235, 0.24); + color: #bfdbfe; + font-weight: 900; +} + +.conversation-info, +.user-name { + min-width: 0; + display: grid; + gap: 2px; + overflow: hidden; +} + +.conversation-info strong, +.user-name { + color: #f8fafc; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-info small { + color: #94a3b8; + font-weight: 700; +} + +.sidebar-label { + margin: 0 0 10px; + color: #94a3b8; + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.users-list { + display: grid; + gap: 10px; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + +.user-item { + cursor: pointer; +} + +.user-you { + margin-left: auto; + color: #6ee7b7; + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; +} + +.chat-panel { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 22px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); +} + +.current-user { + color: #a7b3c6; + font-size: 0.92rem; +} + +.current-user strong { + color: #f8fafc; +} + +.messages-list { + flex: 1 1 auto; + max-height: 594px; + min-height: 0; + display: flex; + flex-direction: column; + gap: 14px; + padding: 22px; + overflow-y: auto; +} + +.empty-state { + display: grid; + place-items: center; + height: 100%; + color: #718096; + text-align: center; +} + +.message-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.message-row.is-own { + align-items: flex-end; +} + +.message-footer { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: min(680px, 84%); +} + +.message-row.is-own .message-footer { + justify-content: flex-end; +} + +.message-meta { + color: #718096; + font-size: 0.78rem; + font-weight: 700; +} + +.message-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + color: #718096; + font-size: 0.82rem; + font-weight: 900; + letter-spacing: -0.08em; + user-select: none; +} + +.message-status-sent { + color: #94a3b8; +} + +.message-status-received { + color: #cbd5e1; +} + +.message-status-read { + color: #6ee7b7; +} + +.message-bubble { + max-width: min(680px, 84%); + padding: 13px 15px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 17px 17px 17px 6px; + background: rgba(30, 41, 59, 0.88); + color: #f8fafc; + overflow-wrap: anywhere; +} + +.message-row.is-own .message-bubble { + border-radius: 17px 17px 6px 17px; + background: linear-gradient(135deg, rgba(5, 150, 105, 0.96), rgba(37, 99, 235, 0.96)); +} + +.typing-indicator { + min-height: 42px; + padding: 0 22px 16px; + color: #a7f3d0; + font-size: 0.92rem; + font-weight: 700; +} + +.typing-indicator::after { + content: ""; + display: inline-block; + width: 1.2em; + text-align: left; + animation: typingDots 1.2s steps(4, end) infinite; +} + +.message-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 18px; + border-top: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(3, 7, 18, 0.36); +} + +.info-panel { + overflow-y: auto; +} + +.info-card { + display: grid; + gap: 8px; + padding: 14px; + border: 1px solid rgba(52, 211, 153, 0.18); + border-radius: 16px; + background: rgba(5, 150, 105, 0.1); + color: #b6c4d8; + font-size: 0.9rem; +} + +.info-card strong { + color: #f8fafc; +} + +@keyframes typingDots { + 0% { + content: ""; + } + + 25% { + content: "."; + } + + 50% { + content: ".."; + } + + 75%, + 100% { + content: "..."; + } +} + +@media (max-width: 1180px) { + .private-layout { + grid-template-columns: 280px minmax(0, 1fr); + } + + .info-panel { + grid-column: 1 / -1; + min-height: 220px; + } +} + +@media (max-width: 960px) { + html, + body { + overflow: auto; + } + + .app-shell { + height: auto; + min-height: 100vh; + overflow: visible; + } + + .hero-card, + .chat-header { + align-items: flex-start; + flex-direction: column; + } + + .private-layout { + grid-template-columns: 1fr; + overflow: visible; + } + + .side-panel, + .chat-panel, + .info-panel { + max-height: none; + } + + .chat-panel { + min-height: 620px; + } + + .message-form { + grid-template-columns: 1fr; + } +} diff --git a/examples/private-chat/public/index.html b/examples/private-chat/public/index.html new file mode 100644 index 0000000..0305bf3 --- /dev/null +++ b/examples/private-chat/public/index.html @@ -0,0 +1,103 @@ + + + + + + PHPSockets PrivateChat + + + + +
+
+
+ PHPSockets With WebSockets +

PrivateChat

+

Direct 1:1 private conversations powered by native PHP WebSockets.

+
+ +
Disconnected
+
+ + + + + +
+ + +
+
+
+ Global room +

Global Room

+
+ +
+ Signed in as - +
+
+ +
+
+ +
+ + +
+
+ + +
+
+ + + + diff --git a/examples/private-chat/server.php b/examples/private-chat/server.php new file mode 100644 index 0000000..f5fa244 --- /dev/null +++ b/examples/private-chat/server.php @@ -0,0 +1,81 @@ +on('open', function (Connection $connection): void { + echo "[socket.open] {$connection->id()} connected from {$connection->remoteAddress()}\n"; +}); + +$server->on('close', function (Connection $connection, int $code, string $reason): void { + echo "[socket.close] {$connection->id()} closed with code {$code}"; + + if ($reason !== '') { + echo " and reason {$reason}"; + } + + echo "\n"; +}); + +$server->on('error', function (Throwable $exception, ?Connection $connection): void { + $connectionId = $connection instanceof Connection ? $connection->id() : 'server'; + + echo "[socket.error] {$connectionId}: {$exception->getMessage()}\n"; +}); + +$server->on('user.joined', function (array $event): void { + $session = $event['session'] ?? null; + + if (!$session instanceof UserSession) { + return; + } + + $onlineCount = $event['onlineCount'] ?? 0; + + echo "[private.user.joined] {$session->displayName} joined. Online users: {$onlineCount}\n"; +}); + +$server->on('user.left', function (array $event): void { + $session = $event['session'] ?? null; + $userId = (string) ($event['userId'] ?? 'unknown'); + + if ($session instanceof UserSession) { + echo "[private.user.left] {$session->displayName} left.\n"; + return; + } + + echo "[private.user.left] {$userId} left.\n"; +}); + +$server->on('message.received', function (array $event): void { + $message = $event['message'] ?? null; + $scope = (string) ($event['scope'] ?? 'unknown'); + + if (!$message instanceof ChatMessage) { + return; + } + + echo "[private.message.received] scope={$scope} room={$message->roomId} from={$message->fromUserId}: {$message->body}\n"; +}); + +$server->run(); diff --git a/src/Chat/ChatKernel.php b/src/Chat/ChatKernel.php index 137f293..f12bb46 100644 --- a/src/Chat/ChatKernel.php +++ b/src/Chat/ChatKernel.php @@ -116,8 +116,8 @@ public function handleMessage( 'message.read' => $this->handleMessageRead($connections, $connection, $envelope), 'room.create' => $this->handleRoomCreate($connections, $connection, $envelope), 'room.message' => $this->handleRoomMessage($connections, $connection, $envelope), - 'typing.start' => $this->handleTypingStatus($connections, $connection, 'typing.started'), - 'typing.stop' => $this->handleTypingStatus($connections, $connection, 'typing.stopped'), + 'typing.start' => $this->handleTypingStatus($connections, $connection, $envelope, 'typing.started'), + 'typing.stop' => $this->handleTypingStatus($connections, $connection, $envelope, 'typing.stopped'), default => throw new InvalidPayloadException('Unsupported message type.'), }; } catch (Throwable $exception) { @@ -213,10 +213,18 @@ private function handleDirectMessage( $this->assertOnlineUser($toUserId); + $clientMessageId = $this->validator->clientMessageId($envelope); + $metadata = []; + + if ($clientMessageId !== null) { + $metadata['clientMessageId'] = $clientMessageId; + } + $message = $this->directMessages->send( fromUserId: $fromUserId, toUserId: $toUserId, text: $this->validator->text($envelope), + metadata: $metadata, ); $this->emit('message.received', [ @@ -303,13 +311,34 @@ private function handleMessageRead( throw new InvalidPayloadException('Connection session was not found.'); } - $this->broadcastAuthenticatedExcept($connections, $userId, MessageEnvelope::server('message.read', [ + $roomId = $envelope->payload['roomId'] ?? 'global'; + + if (!is_string($roomId) || trim($roomId) === '') { + $roomId = 'global'; + } + + $roomId = trim($roomId); + $payload = [ 'messageId' => $this->validator->messageId($envelope), - 'roomId' => $envelope->payload['roomId'] ?? 'global', + 'roomId' => $roomId, 'userId' => $userId, 'displayName' => $session->displayName, 'readAt' => (new DateTimeImmutable())->format(DATE_ATOM), - ])); + ]; + + if ($roomId === 'global') { + $this->broadcastAuthenticatedExcept($connections, $userId, MessageEnvelope::server('message.read', $payload)); + + return; + } + + $room = $this->roomManager->assertMember($roomId, $userId); + $recipientUserIds = array_values(array_filter( + $room->memberUserIds, + static fn (string $memberUserId): bool => $memberUserId !== $userId, + )); + + $this->deliverToUsers($connections, $recipientUserIds, MessageEnvelope::server('message.read', $payload)); } private function handleClose(ConnectionRegistryInterface $connections, Connection $connection): void @@ -343,6 +372,7 @@ private function handleClose(ConnectionRegistryInterface $connections, Connectio private function handleTypingStatus( ConnectionRegistryInterface $connections, Connection $connection, + MessageEnvelope $envelope, string $eventType, ): void { $userId = $this->requireAuthenticated($connection); @@ -352,10 +382,41 @@ private function handleTypingStatus( throw new InvalidPayloadException('Connection session was not found.'); } + $toUserId = $envelope->payload['toUserId'] ?? null; + + if ($toUserId !== null && !is_string($toUserId)) { + throw new InvalidPayloadException('Payload field toUserId must be a string.'); + } + + if (is_string($toUserId)) { + $toUserId = trim($toUserId); + + if ($toUserId === '') { + $toUserId = null; + } + } + + if ($toUserId !== null) { + $this->assertOnlineUser($toUserId); + + $room = $this->roomManager->createDirectRoom($userId, $toUserId); + + $this->deliverToUsers($connections, [$toUserId], MessageEnvelope::server($eventType, [ + 'userId' => $userId, + 'displayName' => $session->displayName, + 'roomId' => $room->id, + 'scope' => 'direct', + 'toUserId' => $toUserId, + ])); + + return; + } + $this->broadcastAuthenticatedExcept($connections, $userId, MessageEnvelope::server($eventType, [ 'userId' => $userId, 'displayName' => $session->displayName, 'roomId' => 'global', + 'scope' => 'global', ])); } diff --git a/src/Chat/DirectMessageRouter.php b/src/Chat/DirectMessageRouter.php index 7480f10..549890c 100644 --- a/src/Chat/DirectMessageRouter.php +++ b/src/Chat/DirectMessageRouter.php @@ -14,10 +14,13 @@ public function __construct( ) { } - public function send(string $fromUserId, string $toUserId, string $text): ChatMessage + /** + * @param array $metadata + */ + public function send(string $fromUserId, string $toUserId, string $text, array $metadata = []): ChatMessage { $room = $this->rooms->createDirectRoom($fromUserId, $toUserId); - $message = ChatMessage::text($room->id, $fromUserId, $text); + $message = ChatMessage::text($room->id, $fromUserId, $text, $metadata); $this->messages->save($message); diff --git a/tests/Integration/Chat/DirectPrivateMessagingTest.php b/tests/Integration/Chat/DirectPrivateMessagingTest.php new file mode 100644 index 0000000..5bfc3b0 --- /dev/null +++ b/tests/Integration/Chat/DirectPrivateMessagingTest.php @@ -0,0 +1,286 @@ + + */ + private array $sockets = []; + + protected function tearDown(): void + { + foreach ($this->sockets as $socket) { + socket_close($socket); + } + + $this->sockets = []; + } + + public function testDirectMessageIsDeliveredOnlyToSenderAndRecipient(): void + { + $server = ChatServer::create(ServerConfig::new(), ChatConfig::new()); + [$williamConnection, $williamSocket] = $this->authenticatedConnection($server, 'conn_william', 'William'); + [$anaConnection, $anaSocket] = $this->authenticatedConnection($server, 'conn_ana', 'Ana'); + [$brunoConnection, $brunoSocket] = $this->authenticatedConnection($server, 'conn_bruno', 'Bruno'); + + $this->drainAvailableEnvelopes($williamSocket); + $this->drainAvailableEnvelopes($anaSocket); + $this->drainAvailableEnvelopes($brunoSocket); + + $this->dispatchClientMessage($server, $williamConnection, [ + 'type' => 'message.direct', + 'payload' => [ + 'toUserId' => $anaConnection->userId(), + 'text' => 'Private hello', + 'clientMessageId' => 'client_direct_123', + ], + ]); + + $williamEnvelope = $this->receiveServerEnvelope($williamSocket, 'message.received'); + $anaEnvelope = $this->receiveServerEnvelope($anaSocket, 'message.received'); + $brunoEnvelopes = $this->drainAvailableEnvelopes($brunoSocket); + $message = $williamEnvelope['payload']['message'] ?? null; + + self::assertIsArray($message); + self::assertSame($message, $anaEnvelope['payload']['message'] ?? null); + self::assertSame('client_direct_123', $message['metadata']['clientMessageId'] ?? null); + self::assertSame('Private hello', $message['body'] ?? null); + self::assertFalse($this->hasEnvelopeType($brunoEnvelopes, 'message.received')); + + $room = $server->kernel()->roomStore()->find((string) ($message['roomId'] ?? '')); + + self::assertInstanceOf(Room::class, $room); + self::assertSame(Room::TYPE_DIRECT, $room->type); + self::assertTrue($room->hasMember((string) $williamConnection->userId())); + self::assertTrue($room->hasMember((string) $anaConnection->userId())); + self::assertFalse($room->hasMember((string) $brunoConnection->userId())); + + $messages = $server->kernel()->messageStore()->messagesForRoom($room->id); + + self::assertSame('Private hello', $messages[0]->body); + self::assertSame('client_direct_123', $messages[0]->metadata['clientMessageId'] ?? null); + + $this->expectException(RoomAccessDeniedException::class); + + $server->kernel()->roomStore()->find($room->id); + (new \Micilini\PhpSockets\Chat\RoomManager($server->kernel()->roomStore()))->assertMember( + $room->id, + (string) $brunoConnection->userId(), + ); + } + + public function testDirectTypingIsDeliveredOnlyToRecipient(): void + { + $server = ChatServer::create(ServerConfig::new(), ChatConfig::new()); + [$williamConnection] = $this->authenticatedConnection($server, 'conn_william', 'William'); + [$anaConnection, $anaSocket] = $this->authenticatedConnection($server, 'conn_ana', 'Ana'); + [, $brunoSocket] = $this->authenticatedConnection($server, 'conn_bruno', 'Bruno'); + + $this->drainAvailableEnvelopes($anaSocket); + $this->drainAvailableEnvelopes($brunoSocket); + + $this->dispatchClientMessage($server, $williamConnection, [ + 'type' => 'typing.start', + 'payload' => [ + 'scope' => 'direct', + 'toUserId' => $anaConnection->userId(), + ], + ]); + + $anaEnvelope = $this->receiveServerEnvelope($anaSocket, 'typing.started'); + $brunoEnvelopes = $this->drainAvailableEnvelopes($brunoSocket); + + self::assertSame('direct', $anaEnvelope['payload']['scope'] ?? null); + self::assertSame($williamConnection->userId(), $anaEnvelope['payload']['userId'] ?? null); + self::assertSame($anaConnection->userId(), $anaEnvelope['payload']['toUserId'] ?? null); + self::assertFalse($this->hasEnvelopeType($brunoEnvelopes, 'typing.started')); + } + + /** + * @return array{0: Connection, 1: Socket} + */ + private function authenticatedConnection(ChatServer $server, string $id, string $displayName): array + { + [$connection, $socket] = $this->registeredConnection($server, $id); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'auth.join', + 'payload' => [ + 'displayName' => $displayName, + ], + ]); + + return [$connection, $socket]; + } + + /** + * @param array $message + */ + private function dispatchClientMessage(ChatServer $server, Connection $connection, array $message): void + { + $json = json_encode($message, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + $server->webSocketServer()->dispatcher()->dispatch( + new MessageReceived($connection, Frame::text($json)), + ); + } + + /** + * @return array{0: Connection, 1: Socket} + */ + private function registeredConnection(ChatServer $server, string $id): array + { + [$clientSocket, $peerSocket] = $this->connectedSocketPair(); + socket_set_nonblock($clientSocket); + + $connection = new Connection($id, $peerSocket, new FrameCodec()); + + $server->webSocketServer()->connections()->add($connection); + + return [$connection, $clientSocket]; + } + + /** + * @return array + */ + private function receiveServerEnvelope(Socket $socket, string $expectedType): array + { + for ($attempt = 0; $attempt < 10; $attempt++) { + foreach ($this->receiveAvailableEnvelopes($socket, 200000) as $envelope) { + if (($envelope['type'] ?? null) === $expectedType) { + return $envelope; + } + } + } + + throw new RuntimeException("Expected server envelope {$expectedType} was not received."); + } + + /** + * @return list> + */ + private function drainAvailableEnvelopes(Socket $socket): array + { + $envelopes = []; + + do { + $batch = $this->receiveAvailableEnvelopes($socket, 0); + $envelopes = [...$envelopes, ...$batch]; + } while ($batch !== []); + + return $envelopes; + } + + /** + * @return list> + */ + private function receiveAvailableEnvelopes(Socket $socket, int $timeoutMicroseconds): array + { + $readSockets = [$socket]; + $writeSockets = null; + $exceptSockets = null; + $changed = socket_select($readSockets, $writeSockets, $exceptSockets, 0, $timeoutMicroseconds); + + if ($changed === false || $changed === 0) { + return []; + } + + $data = ''; + $bytes = socket_recv($socket, $data, 8192, 0); + + if ($bytes === false || $bytes === 0) { + return []; + } + + $codec = new FrameCodec(); + $envelopes = []; + + foreach ($codec->decodeAll($data, fromClient: false) as $frame) { + $envelope = json_decode($frame->payload, true, 512, JSON_THROW_ON_ERROR); + + if (is_array($envelope)) { + $envelopes[] = $envelope; + } + } + + return $envelopes; + } + + /** + * @param list> $envelopes + */ + private function hasEnvelopeType(array $envelopes, string $type): bool + { + foreach ($envelopes as $envelope) { + if (($envelope['type'] ?? null) === $type) { + return true; + } + } + + return false; + } + + /** + * @return array{0: Socket, 1: Socket} + */ + private function connectedSocketPair(): array + { + $serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $clientSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + + if ($serverSocket === false || $clientSocket === false) { + throw new RuntimeException('Failed to create test sockets.'); + } + + $this->sockets[] = $serverSocket; + $this->sockets[] = $clientSocket; + + socket_set_option($serverSocket, SOL_SOCKET, SO_REUSEADDR, 1); + + if (!socket_bind($serverSocket, '127.0.0.1', 0)) { + throw new RuntimeException('Failed to bind test server socket.'); + } + + if (!socket_listen($serverSocket, 1)) { + throw new RuntimeException('Failed to listen on test server socket.'); + } + + $address = ''; + $port = 0; + + if (!socket_getsockname($serverSocket, $address, $port)) { + throw new RuntimeException('Failed to read test server socket address.'); + } + + if (!socket_connect($clientSocket, $address, $port)) { + throw new RuntimeException('Failed to connect test client socket.'); + } + + $peerSocket = socket_accept($serverSocket); + + if ($peerSocket === false) { + throw new RuntimeException('Failed to accept test socket connection.'); + } + + $this->sockets[] = $peerSocket; + + return [$clientSocket, $peerSocket]; + } +} diff --git a/tests/Unit/Chat/DirectMessageRouterTest.php b/tests/Unit/Chat/DirectMessageRouterTest.php new file mode 100644 index 0000000..8b71d13 --- /dev/null +++ b/tests/Unit/Chat/DirectMessageRouterTest.php @@ -0,0 +1,43 @@ +send( + fromUserId: 'usr_william', + toUserId: 'usr_ana', + text: 'Hello Ana', + metadata: ['clientMessageId' => 'client_123'], + ); + $secondMessage = $router->send( + fromUserId: 'usr_ana', + toUserId: 'usr_william', + text: 'Hello William', + ); + + self::assertSame($firstMessage->roomId, $secondMessage->roomId); + self::assertSame('client_123', $firstMessage->metadata['clientMessageId'] ?? null); + + $room = $roomStore->find($firstMessage->roomId); + + self::assertNotNull($room); + self::assertSame(['usr_ana', 'usr_william'], $room->memberUserIds); + self::assertSame([$firstMessage, $secondMessage], $messageStore->messagesForRoom($firstMessage->roomId)); + } +}