From ded1816ae95b26ca145b1bf078d7a1d69b904684 Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Tue, 15 Sep 2026 10:28:04 -0500 Subject: [PATCH] Add real-time typing indicators --- app/Commands/ChatWebSocketServer.php | 6 +- app/Libraries/ChatWebSocketServer.php | 124 +++++++++++++++++- docs/public-api.md | 46 ++++++- docs/user-guide.md | 16 ++- e2e/support/chat-scenarios.ts | 50 ++++++- src/svelte/App.svelte | 110 ++++++++++++++++ src/vue/App.vue | 110 ++++++++++++++++ .../WebSocketFlowIntegrationTest.php | 31 +++++ tests/Tests/Unit/ChatWebSocketTypingTest.php | 94 +++++++++++++ 9 files changed, 579 insertions(+), 8 deletions(-) create mode 100644 tests/Tests/Unit/ChatWebSocketTypingTest.php diff --git a/app/Commands/ChatWebSocketServer.php b/app/Commands/ChatWebSocketServer.php index f620ed7..86219f4 100644 --- a/app/Commands/ChatWebSocketServer.php +++ b/app/Commands/ChatWebSocketServer.php @@ -77,11 +77,15 @@ public function run(array $params): void $loop = Factory::create(); $socket = new SocketServer('0.0.0.0:' . $port, [], $loop); + $chatServer = new ChatServer(); + $loop->addPeriodicTimer(1.0, static function () use ($chatServer): void { + $chatServer->pruneInactiveTypers(); + }); $server = new IoServer( new HttpServer( new WsServer( - new ChatServer() + $chatServer ) ), $socket, diff --git a/app/Libraries/ChatWebSocketServer.php b/app/Libraries/ChatWebSocketServer.php index 5020acc..1524866 100644 --- a/app/Libraries/ChatWebSocketServer.php +++ b/app/Libraries/ChatWebSocketServer.php @@ -41,6 +41,8 @@ */ class ChatWebSocketServer implements MessageComponentInterface { + private const TYPING_TTL_SECONDS = 5; + /** * Connected clients storage * @@ -68,6 +70,13 @@ class ChatWebSocketServer implements MessageComponentInterface */ protected bool $requireAuth; + /** + * Ephemeral typing state keyed by authenticated user ID. + * + * @var array + */ + private array $typingUsers = []; + /** * Constructor * @@ -160,7 +169,7 @@ public function onMessage(ConnectionInterface $from, $msg) $data = json_decode($msg, true); // Validate message format - if (!$data || !isset($data['action'])) { + if (! is_array($data) || (! isset($data['action']) && ! isset($data['type']))) { return; } @@ -168,8 +177,10 @@ public function onMessage(ConnectionInterface $from, $msg) $connectionData = $this->clients[$from] ?? []; $userId = $connectionData['user_id'] ?? 0; - // Handle different actions - switch ($data['action']) { + // Typing events use `type`; existing chat operations keep `action`. + $messageType = $data['type'] ?? $data['action']; + + switch ($messageType) { case 'getMessages': $this->handleGetMessages($from, $data); break; @@ -177,6 +188,11 @@ public function onMessage(ConnectionInterface $from, $msg) case 'sendMessage': $this->handleSendMessage($from, $data, $userId); break; + + case 'typing_start': + case 'typing_stop': + $this->handleTypingEvent($from, $data, $messageType); + break; } } @@ -199,6 +215,11 @@ public function onClose(ConnectionInterface $conn): void // Remove the connection from our storage $this->clients->detach($conn); + if (is_int($userId) && isset($this->typingUsers[$userId])) { + unset($this->typingUsers[$userId]); + $this->broadcastTypingState(); + } + echo 'Connection ' . spl_object_id($conn) . " (user_id: {$userId}) has disconnected\n"; } @@ -250,6 +271,31 @@ public function isUserConnected(int $userId): bool return false; } + /** + * Remove typing entries that have not been refreshed recently. + * + * The React event loop calls this once per second. The optional timestamp + * keeps the expiry behavior deterministic in tests. + */ + public function pruneInactiveTypers(?int $now = null): void + { + $now ??= time(); + $changed = false; + + foreach ($this->typingUsers as $userId => $typingUser) { + if ($now - $typingUser['last_typed_at'] < self::TYPING_TTL_SECONDS) { + continue; + } + + unset($this->typingUsers[$userId]); + $changed = true; + } + + if ($changed) { + $this->broadcastTypingState(); + } + } + /** * Log server startup message * @@ -469,6 +515,78 @@ private function sendSearchError(ConnectionInterface $from, string $message, ?in ])); } + /** @param array $data */ + private function handleTypingEvent(ConnectionInterface $from, array $data, string $messageType): void + { + if (! $this->clients->contains($from)) { + return; + } + + $connectionData = $this->clients[$from]; + $authenticatedUserId = $connectionData['user_id'] ?? 0; + $claimedUserId = $data['user_id'] ?? null; + $username = $data['username'] ?? null; + + if ( + ($connectionData['authenticated'] ?? false) !== true + || ! is_int($authenticatedUserId) + || $authenticatedUserId <= 0 + || ! is_int($claimedUserId) + || $claimedUserId !== $authenticatedUserId + || ! is_string($username) + || trim($username) === '' + || mb_strlen($username) > 255 + ) { + return; + } + + if ($messageType === 'typing_stop') { + if (! isset($this->typingUsers[$authenticatedUserId])) { + return; + } + + unset($this->typingUsers[$authenticatedUserId]); + $this->broadcastTypingState($from); + + return; + } + + $username = trim($username); + $changed = ! isset($this->typingUsers[$authenticatedUserId]) + || $this->typingUsers[$authenticatedUserId]['username'] !== $username; + $this->typingUsers[$authenticatedUserId] = [ + 'user_id' => $authenticatedUserId, + 'username' => $username, + 'last_typed_at' => time(), + ]; + + if ($changed) { + $this->broadcastTypingState($from); + } + } + + private function broadcastTypingState(?ConnectionInterface $except = null): void + { + $users = array_values(array_map( + static fn (array $typingUser): array => [ + 'user_id' => $typingUser['user_id'], + 'username' => $typingUser['username'], + ], + $this->typingUsers, + )); + + usort($users, static fn (array $left, array $right): int => [$left['username'], $left['user_id']] <=> [$right['username'], $right['user_id']]); + $payload = json_encode(['type' => 'typing_state', 'users' => $users]); + + foreach ($this->clients as $client) { + if ($except !== null && $client === $except) { + continue; + } + + $client->send($payload); + } + } + /** * Handle the sendMessage action * diff --git a/docs/public-api.md b/docs/public-api.md index 26e01df..d4e5a36 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -451,7 +451,8 @@ The Vue client chooses `ws:` vs `wss:` based on the page protocol and uses port ### Protocol (messages) -All WebSocket messages are JSON with an `action` field. +All WebSocket messages are JSON. Message-history events use an `action` field, +while ephemeral typing events use a `type` field. #### Client → server: `getMessages` @@ -512,6 +513,44 @@ backward compatible: } ``` +#### Client → server: typing events + +The Vue and Svelte clients announce typing activity with the authenticated +user's numeric ID and username: + +```json +{ "type": "typing_start", "user_id": 42, "username": "alice" } +``` + +```json +{ "type": "typing_stop", "user_id": 42, "username": "alice" } +``` + +The server ignores typing events from unauthenticated connections and events +whose `user_id` does not match the authenticated connection. Repeated +`typing_start` events refresh the user's activity without producing duplicate +state broadcasts. + +#### Server → client: `typing_state` + +When the set of active typers changes, the server sends the complete current +state to other connected clients: + +```json +{ + "type": "typing_state", + "users": [ + { "user_id": 42, "username": "alice" }, + { "user_id": 57, "username": "bob" } + ] +} +``` + +Typing state is ephemeral and is not stored in the database. A user is removed +when the client sends `typing_stop`, the connection closes, or no +`typing_start` refresh arrives for five seconds. The client that originated a +start or stop event is excluded from that event's broadcast. + ### Browser example ```js @@ -579,13 +618,16 @@ The views load bundles using `vite_tags()` from `app/Helpers/vite_helper.php`. - **Realtime**: - Connects to WebSocket on port 8080 for realtime updates - Falls back to HTTP (`/api/v1/messages`) if WebSocket is not connected + - Sends debounced typing activity and renders other active users in an + accessible live status region ### Svelte — `src/svelte/main.js` + `src/svelte/App.svelte` - **Page**: `/chat/svelte` (`app/Views/chat/svelteView.php`) - **API**: reads and posts through `/api/v1/messages` - **Realtime**: uses the same WebSocket protocol as the Vue client and falls - back to the versioned HTTP API + back to the versioned HTTP API, including debounced typing activity and an + accessible live typing indicator --- diff --git a/docs/user-guide.md b/docs/user-guide.md index 2661f7e..d954277 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -21,6 +21,7 @@ This user guide provides comprehensive instructions for using the CodeIgniter Ch - [Searching Messages](#searching-messages) - [Loading Older Messages](#loading-older-messages) - [Real-time Updates](#real-time-updates) + - [Typing Indicators](#typing-indicators) 5. [Troubleshooting](#troubleshooting) 6. [FAQ](#faq) @@ -114,6 +115,7 @@ The Vue.js chat interface uses the Vue.js framework for a modern, reactive user - Message formatting with Markdown - Search by message text, username, and date range - Lazy loading of older messages +- Live typing indicators - Timestamps and user information **Example Usage**: @@ -135,6 +137,7 @@ The Svelte chat interface provides the same modern chat workflow as the Vue.js i - Message formatting with Markdown - Search by message text, username, and date range - Lazy loading of older messages +- Live typing indicators - Timestamps and user information ## User Account Management @@ -211,12 +214,23 @@ By default, the chat shows the most recent messages. To load older messages: ### Real-time Updates -The Vue.js chat interface uses WebSockets for real-time communication. This means: +The Vue.js and Svelte chat interfaces use WebSockets for real-time communication. This means: - New messages from other users appear instantly - No need to refresh the page - Efficient use of network resources +### Typing Indicators + +While another user is entering a message in the Vue.js or Svelte chat, their +name appears above the message composer. The indicator clears when they stop +typing, leave the input, send the message, disconnect, or remain inactive for +five seconds. Your own name is never shown in your typing indicator. + +The animated dots are disabled automatically when your device or browser has +reduced-motion preferences enabled; the text status remains available to screen +readers. + ## Troubleshooting ### Messages Not Appearing diff --git a/e2e/support/chat-scenarios.ts b/e2e/support/chat-scenarios.ts index d15fe97..dca8045 100644 --- a/e2e/support/chat-scenarios.ts +++ b/e2e/support/chat-scenarios.ts @@ -43,6 +43,7 @@ export function chatScenario(scenario: ChatScenario): void { await expect(page.locator(scenario.messageInput)).toBeVisible(); if (isModernChat) { await assertModernChatAccessibility(page, scenario.messageInput); + await assertModernChatTyping(page, scenario.messageInput); await assertModernChatSearch(page); await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest'); await page.context().setOffline(true); @@ -68,6 +69,46 @@ export function chatScenario(scenario: ChatScenario): void { }); } +async function assertModernChatTyping(page: Page, messageInput: string): Promise { + await page.evaluate(() => { + window.__chatTypingRequests = []; + }); + + const input = page.locator(messageInput); + await input.fill('Draft in progress'); + + await expect + .poll(async () => + page.evaluate( + () => window.__chatTypingRequests?.filter((request) => request.type === 'typing_start').length ?? 0, + ), + ) + .toBe(1); + const typingStart = await page.evaluate(() => + window.__chatTypingRequests?.find((request) => request.type === 'typing_start'), + ); + expect(typingStart).toEqual({ type: 'typing_start', user_id: 1, username: 'e2euser' }); + await expect(page.locator('.typing-indicator')).toContainText('Teammate is typing'); + await expect(page.locator('.typing-indicator')).not.toContainText('e2euser'); + + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(page.locator('.typing-dots span').first()).toHaveCSS('animation-name', 'none'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + + await input.blur(); + await expect + .poll(async () => + page.evaluate(() => window.__chatTypingRequests?.filter((request) => request.type === 'typing_stop').length ?? 0), + ) + .toBe(1); + const typingStop = await page.evaluate(() => + window.__chatTypingRequests?.find((request) => request.type === 'typing_stop'), + ); + expect(typingStop).toEqual({ type: 'typing_stop', user_id: 1, username: 'e2euser' }); + await expect(page.locator('.typing-indicator')).toBeHidden(); + await input.fill(''); +} + async function assertModernChatAccessibility(page: Page, messageInput: string): Promise { await expect(page.getByRole('main', { name: 'CodeIgniter Chat' })).toBeVisible(); @@ -184,6 +225,7 @@ async function assertModernChatSearch(page: Page): Promise { async function installModernChatWebSocket(page: Page): Promise { await page.addInitScript(() => { window.__chatSearchRequests = []; + window.__chatTypingRequests = []; window.__expectedSearchDates = { from: Math.floor(new Date(2026, 8, 13, 0, 0, 0, 0).getTime() / 1000), to: Math.floor(new Date(2026, 8, 14, 23, 59, 59, 999).getTime() / 1000), @@ -200,7 +242,13 @@ async function installModernChatWebSocket(page: Page): Promise { const request = JSON.parse(rawMessage); let response = null; - if (request.action === 'getMessages' && request.search) { + if (request.type === 'typing_start' || request.type === 'typing_stop') { + window.__chatTypingRequests.push(request); + response = { + type: 'typing_state', + users: request.type === 'typing_start' ? [{ user_id: 999, username: 'Teammate' }] : [], + }; + } else if (request.action === 'getMessages' && request.search) { window.__chatSearchRequests.push(request); const page = request.page || 1; response = diff --git a/src/svelte/App.svelte b/src/svelte/App.svelte index 58d086d..8e9bd9f 100644 --- a/src/svelte/App.svelte +++ b/src/svelte/App.svelte @@ -122,6 +122,9 @@ let activeSearchKey = $state(''); let searchRequestId = $state(0); let searchAbortController = null; + let typingUsers = $state([]); + let lastTypingSentAt = $state(0); + let typingStopTimeout = null; // ============================================================================ // DERIVED STATE - Computed values @@ -138,6 +141,15 @@ Boolean(config.pushPublicKey && !notificationsEnabled && 'PushManager' in window), ); let hasSearchFilters = $derived(Object.values(normalizedSearch()).some((value) => value !== '')); + let visibleTypingUsers = $derived(typingUsers.filter((user) => Number(user.user_id) !== Number(config.userId))); + let typingIndicatorText = $derived.by(() => { + const names = visibleTypingUsers.map((user) => user.username); + if (names.length === 0) return ''; + if (names.length === 1) return `${names[0]} is typing`; + if (names.length === 2) return `${names[0]} and ${names[1]} are typing`; + if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]} are typing`; + return `${names.slice(0, 3).join(', ')}, and ${names.length - 3} others are typing`; + }); // ============================================================================ // LIFECYCLE - Component mount and cleanup @@ -253,6 +265,11 @@ webSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); + if (data.type === 'typing_state') { + typingUsers = Array.isArray(data.users) ? data.users : []; + return; + } + // Handle different message types (actions) from the server switch (data.action) { case 'messages': @@ -340,6 +357,8 @@ webSocket.addEventListener('close', (event) => { console.log('WebSocket connection closed'); webSocketConnected = false; + typingUsers = []; + lastTypingSentAt = 0; // Attempt to reconnect with exponential backoff if (!reconnectInterval) { @@ -378,6 +397,7 @@ clearTimeout(searchDebounce); } cancelSearchRequest(); + stopTyping(); if (webSocket) { webSocket.close(); @@ -741,6 +761,53 @@ return `${config.chatRoutes.api.replace(/\/$/, '')}/search`; } + function handleTypingInput(event) { + if (!event.target.value.trim()) { + stopTyping(); + return; + } + + startTyping(); + } + + function startTyping() { + if (!webSocketConnected) return; + + const now = Date.now(); + if (now - lastTypingSentAt >= 3000) { + webSocket.send( + JSON.stringify({ + type: 'typing_start', + user_id: Number(config.userId), + username: config.username, + }), + ); + lastTypingSentAt = now; + } + + if (typingStopTimeout) clearTimeout(typingStopTimeout); + typingStopTimeout = setTimeout(() => stopTyping(), 5000); + } + + function stopTyping() { + if (typingStopTimeout) { + clearTimeout(typingStopTimeout); + typingStopTimeout = null; + } + + if (webSocketConnected && lastTypingSentAt > 0) { + webSocket.send( + JSON.stringify({ + type: 'typing_stop', + user_id: Number(config.userId), + username: config.username, + }), + ); + } + + lastTypingSentAt = 0; + } + // ============================================================================ // MESSAGE SENDING FUNCTIONS // ============================================================================ @@ -764,6 +831,8 @@ return; } + stopTyping(); + sending = true; if (webSocketConnected) { @@ -865,6 +934,7 @@ } function clearMessage() { + stopTyping(); message = ''; error = ''; setTimeout(() => document.getElementById('message-input')?.focus(), 0); @@ -1191,6 +1261,13 @@ {/if} + {#if typingIndicatorText} +
+ {typingIndicatorText} + +
+ {/if} +
{ e.preventDefault(); sendMessage(); }} class="message-form"> @@ -1208,6 +1285,8 @@ maxlength="500" aria-invalid={Boolean(error)} aria-describedby={error ? 'formatting-help message-error' : 'formatting-help'} + oninput={handleTypingInput} + onblur={stopTyping} onkeydown={handleKeydown} > @@ -1578,6 +1657,37 @@ } } + .typing-indicator { + min-height: 24px; + padding: 6px 15px; + color: $light-text-color; + font-size: 14px; + } + + .typing-dots span { + display: inline-block; + animation: typing-dot 1.2s ease-in-out infinite; + + &:nth-child(2) { + animation-delay: 0.15s; + } + + &:nth-child(3) { + animation-delay: 0.3s; + } + } + + @keyframes typing-dot { + 0%, 60%, 100% { opacity: 0.35; } + 30% { opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .typing-dots span { + animation: none; + } + } + /* Message form container */ .message-form-container { padding: 15px; diff --git a/src/vue/App.vue b/src/vue/App.vue index 1c854ee..6531f10 100644 --- a/src/vue/App.vue +++ b/src/vue/App.vue @@ -129,6 +129,11 @@
+
+ {{ typingIndicatorText }} + +
+

Compose a message

@@ -143,6 +148,8 @@ maxlength="500" :aria-invalid="Boolean(error)" :aria-describedby="error ? 'formatting-help message-error' : 'formatting-help'" + @input="handleTypingInput" + @blur="stopTyping" @keydown.enter.exact.prevent="sendMessage" @keydown.esc.prevent="clearMessage" > @@ -243,6 +250,9 @@ activeSearchKey: '', searchRequestId: 0, searchAbortController: null, + typingUsers: [], + lastTypingSentAt: 0, + typingStopTimeout: null, }; }, computed: { @@ -252,6 +262,17 @@ hasSearchFilters() { return Object.values(this.normalizedSearch()).some((value) => value !== ''); }, + visibleTypingUsers() { + return this.typingUsers.filter((user) => Number(user.user_id) !== Number(this.userId)); + }, + typingIndicatorText() { + const names = this.visibleTypingUsers.map((user) => user.username); + if (names.length === 0) return ''; + if (names.length === 1) return `${names[0]} is typing`; + if (names.length === 2) return `${names[0]} and ${names[1]} are typing`; + if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]} are typing`; + return `${names.slice(0, 3).join(', ')}, and ${names.length - 3} others are typing`; + }, }, mounted() { this.unsubscribePwa = subscribePwa((state) => { @@ -270,6 +291,7 @@ }, methods: { clearMessage() { + this.stopTyping(); this.message = ''; this.error = ''; this.$nextTick(() => document.getElementById('message-input')?.focus()); @@ -356,6 +378,11 @@ this.webSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); + if (data.type === 'typing_state') { + this.typingUsers = Array.isArray(data.users) ? data.users : []; + return; + } + // Handle different message types (actions) from the server switch (data.action) { case 'messages': @@ -449,6 +476,8 @@ this.webSocket.addEventListener('close', () => { console.log('WebSocket connection closed'); this.webSocketConnected = false; + this.typingUsers = []; + this.lastTypingSentAt = 0; // Attempt to reconnect if (!this.reconnectInterval) { @@ -489,6 +518,7 @@ clearTimeout(this.searchDebounce); } this.cancelSearchRequest(); + this.stopTyping(); if (this.webSocket) { this.webSocket.close(); @@ -839,6 +869,53 @@ return `${this.$chatRoutes.api.replace(/\/$/, '')}/search`; }, + handleTypingInput(event) { + if (!event.target.value.trim()) { + this.stopTyping(); + return; + } + + this.startTyping(); + }, + + startTyping() { + if (!this.webSocketConnected) return; + + const now = Date.now(); + if (now - this.lastTypingSentAt >= 3000) { + this.webSocket.send( + JSON.stringify({ + type: 'typing_start', + user_id: Number(this.userId), + username: this.username, + }), + ); + this.lastTypingSentAt = now; + } + + if (this.typingStopTimeout) clearTimeout(this.typingStopTimeout); + this.typingStopTimeout = setTimeout(() => this.stopTyping(), 5000); + }, + + stopTyping() { + if (this.typingStopTimeout) { + clearTimeout(this.typingStopTimeout); + this.typingStopTimeout = null; + } + + if (this.webSocketConnected && this.lastTypingSentAt > 0) { + this.webSocket.send( + JSON.stringify({ + type: 'typing_stop', + user_id: Number(this.userId), + username: this.username, + }), + ); + } + + this.lastTypingSentAt = 0; + }, + sendMessage() { // Clear previous errors this.error = ''; @@ -854,6 +931,8 @@ return; } + this.stopTyping(); + this.sending = true; if (this.webSocketConnected) { @@ -1358,6 +1437,37 @@ $transition-speed: 0.2s; } } +.typing-indicator { + min-height: 24px; + padding: 6px 15px; + color: $light-text-color; + font-size: 14px; +} + +.typing-dots span { + display: inline-block; + animation: typing-dot 1.2s ease-in-out infinite; + + &:nth-child(2) { + animation-delay: 0.15s; + } + + &:nth-child(3) { + animation-delay: 0.3s; + } +} + +@keyframes typing-dot { + 0%, 60%, 100% { opacity: 0.35; } + 30% { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .typing-dots span { + animation: none; + } +} + // Message form .message-form-container { padding: 15px; diff --git a/tests/Tests/Integration/WebSocketFlowIntegrationTest.php b/tests/Tests/Integration/WebSocketFlowIntegrationTest.php index 348c790..4a7f50d 100644 --- a/tests/Tests/Integration/WebSocketFlowIntegrationTest.php +++ b/tests/Tests/Integration/WebSocketFlowIntegrationTest.php @@ -88,6 +88,37 @@ public function testGetMessagesSearchRejectsAnInvalidRange(): void $this->assertSame('error', $response['action']); $this->assertSame('INVALID_SEARCH', $response['data']['code']); } + + public function testAuthenticatedTypingEventsReachTheOtherClient(): void + { + $senderToken = WebSocketTokenHelper::generateToken(42); + $observerToken = WebSocketTokenHelper::generateToken(43); + $sender = new RecordingConnection("/?token={$senderToken}&user_id=42"); + $observer = new RecordingConnection("/?token={$observerToken}&user_id=43"); + $server = new ChatWebSocketServer(chatModel: new ChatModel()); + + $this->expectOutputRegex('/Chat WebSocket Server started.*New connection!.*New connection!/s'); + $server->onOpen($sender); + $server->onOpen($observer); + $server->onMessage($sender, json_encode([ + 'type' => 'typing_start', + 'user_id' => 42, + 'username' => 'Alice', + ], JSON_THROW_ON_ERROR)); + + $this->assertSame([], $sender->sent); + $typingState = json_decode($observer->sent[0], true, flags: JSON_THROW_ON_ERROR); + $this->assertSame('typing_state', $typingState['type']); + $this->assertSame([['user_id' => 42, 'username' => 'Alice']], $typingState['users']); + + $server->onMessage($sender, json_encode([ + 'type' => 'typing_stop', + 'user_id' => 42, + 'username' => 'Alice', + ], JSON_THROW_ON_ERROR)); + $stoppedState = json_decode($observer->sent[1], true, flags: JSON_THROW_ON_ERROR); + $this->assertSame([], $stoppedState['users']); + } } final class RecordingConnection implements ConnectionInterface diff --git a/tests/Tests/Unit/ChatWebSocketTypingTest.php b/tests/Tests/Unit/ChatWebSocketTypingTest.php new file mode 100644 index 0000000..696520f --- /dev/null +++ b/tests/Tests/Unit/ChatWebSocketTypingTest.php @@ -0,0 +1,94 @@ +expectOutputRegex('/Chat WebSocket Server started.*New connection!.*New connection!.*New connection!/s'); + $sender = new TypingRecordingConnection('/?user_id=42'); + $observer = new TypingRecordingConnection('/?user_id=43'); + $unauthenticated = new TypingRecordingConnection('/'); + $server = new ChatWebSocketServer(chatModel: $this->createStub(ChatModel::class), requireAuth: false); + + $server->onOpen($sender); + $server->onOpen($observer); + $server->onOpen($unauthenticated); + + $server->onMessage($sender, $this->typingEvent('typing_start', 42, 'Alice')); + $this->assertSame([], $sender->sent); + $this->assertSame([['user_id' => 42, 'username' => 'Alice']], $this->typingUsers($observer->sent[0])); + + $server->onMessage($sender, $this->typingEvent('typing_start', 42, 'Alice')); + $server->onMessage($observer, $this->typingEvent('typing_start', 99, 'Mallory')); + $server->onMessage($unauthenticated, $this->typingEvent('typing_start', 0, 'Anonymous')); + $this->assertCount(1, $observer->sent, 'Repeated and unauthenticated typing events must not rebroadcast state.'); + + $server->pruneInactiveTypers(time() + 5); + $this->assertSame([], $this->typingUsers($observer->sent[1])); + } + + public function testDisconnectRemovesTypingUserImmediately(): void + { + $this->expectOutputRegex('/Chat WebSocket Server started.*New connection!.*New connection!.*has disconnected/s'); + $sender = new TypingRecordingConnection('/?user_id=42'); + $observer = new TypingRecordingConnection('/?user_id=43'); + $server = new ChatWebSocketServer(chatModel: $this->createStub(ChatModel::class), requireAuth: false); + + $server->onOpen($sender); + $server->onOpen($observer); + $server->onMessage($sender, $this->typingEvent('typing_start', 42, 'Alice')); + $server->onClose($sender); + + $this->assertSame([], $this->typingUsers($observer->sent[1])); + } + + private function typingEvent(string $type, int $userId, string $username): string + { + return json_encode([ + 'type' => $type, + 'user_id' => $userId, + 'username' => $username, + ], JSON_THROW_ON_ERROR); + } + + /** @return list */ + private function typingUsers(string $payload): array + { + $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + $this->assertSame('typing_state', $decoded['type']); + + return $decoded['users']; + } +} + +final class TypingRecordingConnection implements ConnectionInterface +{ + public ServerRequest $httpRequest; + + /** @var list */ + public array $sent = []; + + public function __construct(string $uri) + { + $this->httpRequest = new ServerRequest('GET', $uri); + } + + public function send($data): ConnectionInterface + { + $this->sent[] = (string) $data; + + return $this; + } + + public function close(): void + { + } +}