From eb56d515e7a405d31da6c34febed0fac3e4ae4c1 Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 17:42:00 -0300 Subject: [PATCH 1/2] phase 06: add medium chat example with callbacks --- README.md | 42 ++ examples/medium-chat/README.md | 119 ++++ examples/medium-chat/public/assets/app.js | 628 ++++++++++++++++++ examples/medium-chat/public/assets/style.css | 445 +++++++++++++ examples/medium-chat/public/index.html | 94 +++ examples/medium-chat/server.php | 92 +++ src/Chat/ChatKernel.php | 65 ++ src/Chat/ChatServer.php | 16 + .../Chat/ChatServerCallbacksTest.php | 146 ++++ 9 files changed, 1647 insertions(+) create mode 100644 examples/medium-chat/README.md create mode 100644 examples/medium-chat/public/assets/app.js create mode 100644 examples/medium-chat/public/assets/style.css create mode 100644 examples/medium-chat/public/index.html create mode 100644 examples/medium-chat/server.php create mode 100644 tests/Integration/Chat/ChatServerCallbacksTest.php diff --git a/README.md b/README.md index f660ceb..7e8e49e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,48 @@ Fix code style automatically: composer cs:fix ``` +## Running the EasyChat example + +Start the WebSocket server: + +```bash +php examples/easy-chat/server.php +``` + +Open a second terminal and start the browser UI: + +```bash +php -S 127.0.0.1:8000 -t examples/easy-chat/public +``` + +Then open: + +```txt +http://127.0.0.1:8000 +``` + +## Running the MediumChat example + +Start the WebSocket server: + +```bash +php examples/medium-chat/server.php +``` + +Open a second terminal and start the browser UI: + +```bash +php -S 127.0.0.1:8001 -t examples/medium-chat/public +``` + +Then open: + +```txt +http://127.0.0.1:8001 +``` + +MediumChat demonstrates high-level callbacks such as `user.joined`, `user.left`, `message.received`, and `room.created`, plus low-level socket callbacks such as `open`, `close`, and `error`. + ## Requirements The modern version targets: diff --git a/examples/medium-chat/README.md b/examples/medium-chat/README.md new file mode 100644 index 0000000..87e5842 --- /dev/null +++ b/examples/medium-chat/README.md @@ -0,0 +1,119 @@ +# MediumChat Example + +MediumChat is the intermediate PHPSockets example. + +It demonstrates the same global chat flow from EasyChat, but adds: + +- High-level chat callbacks. +- Socket lifecycle callbacks. +- Server-side terminal logs. +- Browser-side realtime event log. +- Typing indicators. +- Safe message rendering with `textContent`. +- Plain HTML, CSS and JavaScript. +- Bootstrap through CDN. + +## Difference from EasyChat + +EasyChat is focused on the simplest possible global chat. + +MediumChat is focused on customization. + +The server registers callbacks like: + +```php +$server->on('user.joined', function (array $event): void { + // User joined the chat. +}); + +$server->on('message.received', function (array $event): void { + // Message was received by the chat core. +}); +``` + +It also supports low-level socket events: + +```php +$server->on('open', function ($connection): void { + // Raw WebSocket connection opened. +}); + +$server->on('close', function ($connection, int $code, string $reason): void { + // Raw WebSocket connection closed. +}); +``` + +## Requirements + +From the project root, install dependencies first: + +```bash +composer install +``` + +The PHP `sockets` extension must be enabled. + +## Running the WebSocket server + +From the project root: + +```bash +php examples/medium-chat/server.php +``` + +By default, the WebSocket server runs at: + +```txt +ws://127.0.0.1:8080 +``` + +## Running the browser UI + +Open a second terminal and run: + +```bash +php -S 127.0.0.1:8001 -t examples/medium-chat/public +``` + +Then open: + +```txt +http://127.0.0.1:8001 +``` + +## Manual test + +Open two browser tabs: + +```txt +Tab 1: William +Tab 2: Ana +``` + +Expected behavior: + +- Both users should enter the chat. +- Both users should appear in the online users list. +- Messages sent by one tab should appear in the other tab. +- Typing indicators should work. +- Duplicate display names should be rejected. +- Browser events should appear in the right-side panel. +- Server callbacks should appear in the terminal running `server.php`. + +## Server events demonstrated + +```txt +open +close +error +user.joined +user.left +message.received +room.created +``` + +## Important notes + +This example still uses the global room only. + +Direct private messages and private group rooms are demonstrated in later phases. diff --git a/examples/medium-chat/public/assets/app.js b/examples/medium-chat/public/assets/app.js new file mode 100644 index 0000000..91f5b2d --- /dev/null +++ b/examples/medium-chat/public/assets/app.js @@ -0,0 +1,628 @@ +const state = { + socket: null, + currentUser: null, + users: new Map(), + typingUsers: new Map(), + typingTimers: new Map(), + isTyping: false, + typingStopTimer: null, + lastTypingStartSentAt: 0, + typingHeartbeatMs: 1000, + typingIdleStopMs: 1400, +}; + +const elements = { + alertBox: document.getElementById('alertBox'), + chatPanel: document.getElementById('chatPanel'), + clearEventsButton: document.getElementById('clearEventsButton'), + connectionStatus: document.getElementById('connectionStatus'), + currentDisplayName: document.getElementById('currentDisplayName'), + displayNameInput: document.getElementById('displayNameInput'), + eventLog: document.getElementById('eventLog'), + 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.messageForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const text = elements.messageInput.value.trim(); + + if (!text) { + stopTyping(); + return; + } + + clearLocalTypingStateBeforeSend(); + sendEnvelope('message.global', { text }); + elements.messageInput.value = ''; + elements.messageInput.focus(); +}); + +elements.messageInput.addEventListener('input', () => { + handleTypingInput(); +}); + +elements.messageInput.addEventListener('blur', () => { + stopTyping(); +}); + +elements.clearEventsButton.addEventListener('click', () => { + elements.eventLog.replaceChildren(); +}); + +window.addEventListener('beforeunload', () => { + stopTyping(); + + if (state.socket && state.socket.readyState === WebSocket.OPEN) { + state.socket.close(); + } +}); + +renderEmptyMessages(); +renderTypingIndicator(); +setStatus('Disconnected', 'offline'); +logBrowserEvent('browser.ready'); + +function connect(serverUrl, displayName) { + disconnect(); + clearAlert(); + setStatus('Connecting', 'connecting'); + setJoinFormEnabled(false); + logBrowserEvent('socket.connecting'); + + try { + state.socket = new WebSocket(serverUrl); + } catch (error) { + setJoinFormEnabled(true); + setStatus('Disconnected', 'offline'); + showAlert('Invalid WebSocket server URL.', 'danger'); + logBrowserEvent('socket.error'); + return; + } + + state.socket.addEventListener('open', () => { + logBrowserEvent('socket.open'); + sendEnvelope('auth.join', { displayName }); + }); + + state.socket.addEventListener('message', (event) => { + handleServerMessage(event.data); + }); + + state.socket.addEventListener('close', () => { + const hadCurrentUser = Boolean(state.currentUser); + + logBrowserEvent('socket.close'); + 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', () => { + logBrowserEvent('socket.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; + } + + logBrowserEvent(envelope.type || 'server.unknown'); + + 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 '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(); + renderUsers(); + renderEmptyMessages(); + renderTypingIndicator(); + + 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); + } + } + + renderUsers(); +} + +function handleUserJoined(payload) { + const user = payload.user; + + if (user && user.userId) { + state.users.set(user.userId, user); + renderUsers(); + } +} + +function handleUserLeft(payload) { + if (payload.userId) { + state.users.delete(payload.userId); + clearTypingUser(payload.userId); + renderUsers(); + } +} + +function handleMessageReceived(payload) { + if (!payload.message) { + return; + } + + clearTypingUser(payload.message.fromUserId); + addMessage(payload.message); +} + +function handleTypingStarted(payload) { + if (!payload.userId || !payload.displayName) { + return; + } + + if (state.currentUser && payload.userId === state.currentUser.userId) { + return; + } + + state.typingUsers.set(payload.userId, payload.displayName); + + const currentTimer = state.typingTimers.get(payload.userId); + + if (currentTimer) { + window.clearTimeout(currentTimer); + } + + const timer = window.setTimeout(() => { + clearTypingUser(payload.userId); + }, 4000); + + state.typingTimers.set(payload.userId, timer); + renderTypingIndicator(); +} + +function handleTypingStopped(payload) { + if (!payload.userId) { + return; + } + + clearTypingUser(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; + } + + logBrowserEvent(clientEventName(type)); + state.socket.send(JSON.stringify({ type, payload })); +} + +function clientEventName(type) { + const clientEvents = { + 'auth.join': 'client.auth.join', + 'message.global': 'client.message.global', + 'typing.start': 'client.typing.start', + 'typing.stop': 'client.typing.stop', + }; + + return clientEvents[type] || `client.${type}`; +} + +function logBrowserEvent(name) { + const item = document.createElement('div'); + item.className = 'event-item'; + + const title = document.createElement('div'); + title.className = 'event-title'; + title.textContent = name; + + const time = document.createElement('div'); + time.className = 'event-time'; + time.textContent = formatTime(new Date().toISOString()); + + item.appendChild(title); + item.appendChild(time); + elements.eventLog.prepend(item); + + while (elements.eventLog.children.length > 80) { + elements.eventLog.lastElementChild.remove(); + } +} + +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', { roomId: 'global' }); +} + +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', { roomId: 'global' }); + } +} + +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(); + clearTypingState(); + + elements.chatPanel.classList.add('d-none'); + elements.loginPanel.classList.remove('d-none'); + elements.currentDisplayName.textContent = '-'; + + if (!keepDisplayName) { + elements.displayNameInput.value = ''; + } + + setJoinFormEnabled(true); + renderUsers(); + renderEmptyMessages(); +} + +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); + + if (state.users.size === 0) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No users online yet.'; + elements.usersList.appendChild(empty); + return; + } + + const users = [...state.users.values()].sort((first, second) => { + return first.displayName.localeCompare(second.displayName); + }); + + for (const user of users) { + const item = document.createElement('div'); + item.className = 'user-item'; + + 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); + + if (state.currentUser && user.userId === state.currentUser.userId) { + const you = document.createElement('span'); + you.className = 'user-you'; + you.textContent = 'You'; + item.appendChild(you); + } + + elements.usersList.appendChild(item); + } +} + +function renderTypingIndicator() { + const names = [...state.typingUsers.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 clearTypingUser(userId) { + if (!userId) { + return; + } + + const timer = state.typingTimers.get(userId); + + if (timer) { + window.clearTimeout(timer); + state.typingTimers.delete(userId); + } + + state.typingUsers.delete(userId); + renderTypingIndicator(); +} + +function clearTypingState() { + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + state.typingStopTimer = null; + } + + for (const timer of state.typingTimers.values()) { + window.clearTimeout(timer); + } + + state.typingUsers.clear(); + state.typingTimers.clear(); + state.isTyping = false; + state.lastTypingStartSentAt = 0; + renderTypingIndicator(); +} + +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 addMessage(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'; + + const meta = document.createElement('div'); + meta.className = 'message-meta'; + meta.textContent = `${sender} - ${createdAt}`; + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + bubble.textContent = message.body || ''; + + row.appendChild(meta); + row.appendChild(bubble); + + elements.messagesList.appendChild(row); + elements.messagesList.scrollTop = elements.messagesList.scrollHeight; +} + +function findDisplayName(userId) { + const user = state.users.get(userId); + + if (!user) { + return '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/medium-chat/public/assets/style.css b/examples/medium-chat/public/assets/style.css new file mode 100644 index 0000000..a56f895 --- /dev/null +++ b/examples/medium-chat/public/assets/style.css @@ -0,0 +1,445 @@ +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at top left, rgba(124, 58, 237, 0.26), transparent 30rem), + radial-gradient(circle at bottom right, rgba(6, 182, 212, 0.18), transparent 28rem), + #070816; + 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)); + margin: 0 auto; + padding: 36px 0; +} + +.hero-card, +.panel { + border: 1px solid rgba(165, 180, 252, 0.2); + background: rgba(17, 24, 39, 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: 28px; + border-radius: 24px; + margin-bottom: 18px; +} + +.hero-card h1 { + margin: 8px 0; + font-size: clamp(2.3rem, 5vw, 4.6rem); + line-height: 0.95; +} + +.hero-card p { + max-width: 760px; + margin: 0; + color: #b9c3d6; + font-size: 1.03rem; +} + +.eyebrow { + display: inline-flex; + color: #67e8f9; + 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 { + max-width: 540px; + margin: 0 auto; +} + +.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(165, 180, 252, 0.24); + background: rgba(3, 7, 18, 0.74); + color: #f8fafc; + border-radius: 14px; +} + +.form-control:focus { + border-color: rgba(34, 211, 238, 0.76); + background: rgba(3, 7, 18, 0.9); + color: #f8fafc; + box-shadow: 0 0 0 0.25rem rgba(34, 211, 238, 0.14); +} + +.form-control::placeholder { + color: #64748b; +} + +.btn-primary { + border: 0; + border-radius: 14px; + background: linear-gradient(135deg, #7c3aed, #06b6d4); + font-weight: 800; + box-shadow: 0 16px 34px rgba(6, 182, 212, 0.2); +} + +.btn-primary:hover { + filter: brightness(1.08); +} + +.medium-layout { + display: grid; + grid-template-columns: 280px minmax(0, 1fr) 330px; + gap: 18px; + min-height: 690px; +} + +.side-panel, +.chat-panel, +.event-panel { + min-height: 690px; +} + +.side-panel, +.event-panel { + display: flex; + flex-direction: column; +} + +.count-pill { + min-width: 38px; + height: 32px; + background: rgba(34, 211, 238, 0.15); + color: #67e8f9; +} + +.users-list { + display: grid; + gap: 10px; +} + +.user-item { + display: flex; + align-items: center; + gap: 11px; + min-height: 50px; + padding: 12px; + border: 1px solid rgba(165, 180, 252, 0.14); + border-radius: 16px; + background: rgba(15, 23, 42, 0.7); +} + +.user-avatar { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(124, 58, 237, 0.24); + color: #ddd6fe; + font-weight: 900; +} + +.user-name { + min-width: 0; + color: #e2e8f0; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-you { + margin-left: auto; + color: #67e8f9; + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; +} + +.debug-note { + margin-top: auto; + padding: 14px; + border: 1px solid rgba(34, 211, 238, 0.16); + border-radius: 16px; + background: rgba(8, 145, 178, 0.1); + color: #b6c4d8; + font-size: 0.9rem; +} + +.debug-note strong, +.debug-note span { + display: block; +} + +.debug-note code { + color: #a5f3fc; +} + +.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(165, 180, 252, 0.14); +} + +.current-user { + color: #a7b3c6; + font-size: 0.92rem; +} + +.current-user strong { + color: #f8fafc; +} + +.messages-list { + flex: 1; + 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-meta { + color: #718096; + font-size: 0.78rem; + font-weight: 700; +} + +.message-bubble { + max-width: min(680px, 84%); + padding: 13px 15px; + border: 1px solid rgba(165, 180, 252, 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(109, 40, 217, 0.96), rgba(8, 145, 178, 0.96)); +} + +.typing-indicator { + min-height: 42px; + padding: 0 22px 16px; + color: #a5f3fc; + 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(165, 180, 252, 0.14); + background: rgba(3, 7, 18, 0.36); +} + +.event-log { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + overflow-y: auto; +} + +.event-item { + padding: 11px 12px; + border: 1px solid rgba(165, 180, 252, 0.13); + border-radius: 14px; + background: rgba(15, 23, 42, 0.68); +} + +.event-title { + color: #e0e7ff; + font-size: 0.86rem; + font-weight: 800; + overflow-wrap: anywhere; +} + +.event-time { + margin-top: 4px; + color: #718096; + font-size: 0.74rem; + font-weight: 700; +} + +@keyframes typingDots { + 0% { + content: ""; + } + + 25% { + content: "."; + } + + 50% { + content: ".."; + } + + 75%, + 100% { + content: "..."; + } +} + +@media (max-width: 1180px) { + .medium-layout { + grid-template-columns: 260px minmax(0, 1fr); + } + + .event-panel { + grid-column: 1 / -1; + min-height: 280px; + } +} + +@media (max-width: 860px) { + .hero-card, + .chat-header { + align-items: flex-start; + flex-direction: column; + } + + .medium-layout { + grid-template-columns: 1fr; + } + + .side-panel, + .chat-panel, + .event-panel { + min-height: auto; + } + + .chat-panel { + min-height: 620px; + } + + .message-form { + grid-template-columns: 1fr; + } +} diff --git a/examples/medium-chat/public/index.html b/examples/medium-chat/public/index.html new file mode 100644 index 0000000..176830c --- /dev/null +++ b/examples/medium-chat/public/index.html @@ -0,0 +1,94 @@ + + + + + + PHPSockets MediumChat + + + + +
+
+
+ PHPSockets With WebSockets +

MediumChat

+

Intermediate chat example with high-level callbacks, socket lifecycle logs and realtime browser diagnostics.

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

Customizable realtime flow

+
+ +
+ Signed in as - +
+
+ +
+
+ +
+ + +
+
+ + +
+
+ + + + diff --git a/examples/medium-chat/server.php b/examples/medium-chat/server.php new file mode 100644 index 0000000..f736f44 --- /dev/null +++ b/examples/medium-chat/server.php @@ -0,0 +1,92 @@ +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 "[chat.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 "[chat.user.left] {$session->displayName} left.\n"; + return; + } + + echo "[chat.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 "[chat.message.received] scope={$scope} room={$message->roomId} from={$message->fromUserId}: {$message->body}\n"; +}); + +$server->on('room.created', function (array $event): void { + $room = $event['room'] ?? null; + + if (!$room instanceof Room) { + return; + } + + echo "[chat.room.created] {$room->id} type={$room->type} members=" . count($room->memberUserIds) . "\n"; +}); + +$server->run(); diff --git a/src/Chat/ChatKernel.php b/src/Chat/ChatKernel.php index f81950d..fa6052b 100644 --- a/src/Chat/ChatKernel.php +++ b/src/Chat/ChatKernel.php @@ -31,6 +31,11 @@ final class ChatKernel private readonly DirectMessageRouter $directMessages; private readonly PrivateGroupRouter $privateGroups; + /** + * @var array): void>> + */ + private array $listeners = []; + public function __construct( private readonly ChatConfig $config, ?SessionStoreInterface $sessionStore = null, @@ -61,6 +66,15 @@ public function attach(WebSocketServer $server): void }); } + /** + * @param callable(array): void $listener + */ + public function on(string $eventName, callable $listener): void + { + $this->listeners[$eventName] ??= []; + $this->listeners[$eventName][] = $listener; + } + public function presence(): PresenceManager { return $this->presence; @@ -135,6 +149,12 @@ private function handleJoin( 'users' => $this->presence->snapshot(), ])); + $this->emit('user.joined', [ + 'session' => $session, + 'connection' => $connection, + 'onlineCount' => count($this->presence->connectedSessions()), + ]); + $this->broadcastAuthenticated($connections, MessageEnvelope::server('presence.user_joined', [ 'user' => $session->toArray(), ])); @@ -151,6 +171,13 @@ private function handleGlobalMessage( $this->messages->save($message); + $this->emit('message.received', [ + 'message' => $message, + 'room' => $room, + 'connection' => $connection, + 'scope' => 'global', + ]); + $this->broadcastAuthenticatedExcept($connections, $fromUserId, MessageEnvelope::server('typing.stopped', [ 'userId' => $fromUserId, 'roomId' => $room->id, @@ -178,6 +205,13 @@ private function handleDirectMessage( text: $this->validator->text($envelope), ); + $this->emit('message.received', [ + 'message' => $message, + 'connection' => $connection, + 'scope' => 'direct', + 'targetUserId' => $toUserId, + ]); + $this->deliverToUsers($connections, [$fromUserId, $toUserId], MessageEnvelope::server('message.received', [ 'roomId' => $message->roomId, 'message' => $message->toArray(), @@ -209,6 +243,12 @@ private function handleRoomCreate( maxMembers: $this->config->maxPrivateGroupMembers, ); + $this->emit('room.created', [ + 'room' => $room, + 'connection' => $connection, + 'createdByUserId' => $createdByUserId, + ]); + $this->deliverToUsers($connections, $room->memberUserIds, MessageEnvelope::server('room.created', [ 'room' => $room->toArray(), ])); @@ -224,6 +264,13 @@ private function handleRoomMessage( $room = $this->roomManager->assertMember($roomId, $fromUserId); $message = $this->privateGroups->send($roomId, $fromUserId, $this->validator->text($envelope)); + $this->emit('message.received', [ + 'message' => $message, + 'room' => $room, + 'connection' => $connection, + 'scope' => 'room', + ]); + $this->deliverToUsers($connections, $room->memberUserIds, MessageEnvelope::server('message.received', [ 'roomId' => $room->id, 'message' => $message->toArray(), @@ -238,8 +285,16 @@ private function handleClose(ConnectionRegistryInterface $connections, Connectio return; } + $session = $this->sessions->findByUserId($userId); + $this->presence->leave($userId); + $this->emit('user.left', [ + 'userId' => $userId, + 'session' => $session, + 'connection' => $connection, + ]); + $this->broadcastAuthenticated($connections, MessageEnvelope::server('typing.stopped', [ 'userId' => $userId, 'roomId' => 'global', @@ -309,6 +364,16 @@ private function sendEnvelope(Connection $connection, MessageEnvelope $envelope) $connection->send($envelope->toJson()); } + /** + * @param array $payload + */ + private function emit(string $eventName, array $payload): void + { + foreach ($this->listeners[$eventName] ?? [] as $listener) { + $listener($payload); + } + } + private function broadcastAuthenticated(ConnectionRegistryInterface $connections, MessageEnvelope $envelope): void { foreach ($connections->all() as $connection) { diff --git a/src/Chat/ChatServer.php b/src/Chat/ChatServer.php index 037eca9..af1b332 100644 --- a/src/Chat/ChatServer.php +++ b/src/Chat/ChatServer.php @@ -10,6 +10,16 @@ final readonly class ChatServer { + /** + * @var array + */ + private const CHAT_EVENT_NAMES = [ + 'user.joined' => true, + 'user.left' => true, + 'message.received' => true, + 'room.created' => true, + ]; + public function __construct( private WebSocketServer $server, private ChatKernel $kernel, @@ -27,6 +37,12 @@ public static function create(ServerConfig $serverConfig, ChatConfig $chatConfig public function on(string $eventName, callable $listener): self { + if (isset(self::CHAT_EVENT_NAMES[$eventName])) { + $this->kernel->on($eventName, $listener); + + return $this; + } + $this->server->on($eventName, $listener); return $this; diff --git a/tests/Integration/Chat/ChatServerCallbacksTest.php b/tests/Integration/Chat/ChatServerCallbacksTest.php new file mode 100644 index 0000000..0f5056b --- /dev/null +++ b/tests/Integration/Chat/ChatServerCallbacksTest.php @@ -0,0 +1,146 @@ + + */ + private array $sockets = []; + + protected function tearDown(): void + { + foreach ($this->sockets as $socket) { + socket_close($socket); + } + + $this->sockets = []; + } + + public function testHighLevelChatCallbacksAreDispatched(): void + { + $server = ChatServer::create(ServerConfig::new(), ChatConfig::new()); + $connection = $this->registeredConnection($server, 'conn_william'); + $joinedDisplayName = null; + $receivedMessageBody = null; + + $server->on('user.joined', function (array $event) use (&$joinedDisplayName): void { + $session = $event['session'] ?? null; + + if ($session instanceof UserSession) { + $joinedDisplayName = $session->displayName; + } + }); + + $server->on('message.received', function (array $event) use (&$receivedMessageBody): void { + $message = $event['message'] ?? null; + + if ($message instanceof ChatMessage) { + $receivedMessageBody = $message->body; + } + }); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'auth.join', + 'payload' => [ + 'displayName' => 'William', + ], + ]); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'message.global', + 'payload' => [ + 'text' => 'MediumChat callbacks are alive', + ], + ]); + + self::assertSame('William', $joinedDisplayName); + self::assertSame('MediumChat callbacks are alive', $receivedMessageBody); + } + + /** + * @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)), + ); + } + + private function registeredConnection(ChatServer $server, string $id): Connection + { + [, $peerSocket] = $this->connectedSocketPair(); + + $connection = new Connection($id, $peerSocket, new FrameCodec()); + + $server->webSocketServer()->connections()->add($connection); + + return $connection; + } + + /** + * @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]; + } +} From 34e1ab9f3efbd2dcf3a499d40322d76fefc34d7a Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 17:59:26 -0300 Subject: [PATCH 2/2] phase 06: polish chat layouts and add message receipts --- README.md | 2 + examples/easy-chat/README.md | 5 + examples/easy-chat/public/assets/app.js | 148 +++++++++++++- examples/easy-chat/public/assets/style.css | 88 ++++++++- examples/easy-chat/public/index.html | 4 +- examples/medium-chat/README.md | 5 + examples/medium-chat/public/assets/app.js | 147 +++++++++++++- examples/medium-chat/public/assets/style.css | 84 +++++++- examples/medium-chat/public/index.html | 2 +- src/Chat/ChatKernel.php | 37 +++- src/Chat/ChatMessage.php | 6 +- src/Chat/PayloadValidator.php | 31 +++ .../Chat/MessageReadReceiptTest.php | 181 ++++++++++++++++++ tests/Unit/Chat/PayloadValidatorTest.php | 29 +++ 14 files changed, 737 insertions(+), 32 deletions(-) create mode 100644 tests/Integration/Chat/MessageReadReceiptTest.php diff --git a/README.md b/README.md index 7e8e49e..2534dda 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ http://127.0.0.1:8001 MediumChat demonstrates high-level callbacks such as `user.joined`, `user.left`, `message.received`, and `room.created`, plus low-level socket callbacks such as `open`, `close`, and `error`. +EasyChat and MediumChat also include typing indicators and simple message status receipts for sent, received, and read states. + ## Requirements The modern version targets: diff --git a/examples/easy-chat/README.md b/examples/easy-chat/README.md index 90aa4b8..8d36c04 100644 --- a/examples/easy-chat/README.md +++ b/examples/easy-chat/README.md @@ -78,6 +78,9 @@ Expected behavior: - Both users should enter the chat. - Both users should appear in the online users list. - Messages sent by one tab should appear in the other tab. +- Own messages should show a message status icon. +- When the server echoes the message, the status should move from sent to received. +- When another browser receives the message, the sender should see the message as read. - Duplicate display names should be rejected. - User messages must be rendered safely without `innerHTML`. @@ -85,4 +88,6 @@ Expected behavior: This example is intentionally simple. +Message receipts are browser-only example receipts. They are not persisted and do not represent a full per-user room read history. + It only demonstrates the global chat flow. Private direct messages and private group rooms will be demonstrated in later examples. diff --git a/examples/easy-chat/public/assets/app.js b/examples/easy-chat/public/assets/app.js index d584e4c..4762054 100644 --- a/examples/easy-chat/public/assets/app.js +++ b/examples/easy-chat/public/assets/app.js @@ -4,6 +4,9 @@ const state = { users: new Map(), typingUsers: new Map(), typingTimers: new Map(), + pendingMessages: new Map(), + messageElements: new Map(), + messageReadBy: new Map(), isTyping: false, typingStopTimer: null, lastTypingStartSentAt: 0, @@ -53,8 +56,11 @@ elements.messageForm.addEventListener('submit', (event) => { return; } + const clientMessageId = createClientMessageId(); + clearLocalTypingStateBeforeSend(); - sendEnvelope('message.global', { text }); + addPendingOwnMessage(text, clientMessageId); + sendEnvelope('message.global', { text, clientMessageId }); elements.messageInput.value = ''; elements.messageInput.focus(); }); @@ -173,6 +179,10 @@ function handleServerMessage(rawMessage) { handleMessageReceived(envelope.payload); break; + case 'message.read': + handleMessageRead(envelope.payload); + break; + case 'typing.started': handleTypingStarted(envelope.payload); break; @@ -255,8 +265,42 @@ function handleMessageReceived(payload) { return; } - clearTypingUser(payload.message.fromUserId); - addMessage(payload.message); + const message = payload.message; + const isOwn = state.currentUser && message.fromUserId === state.currentUser.userId; + const clientMessageId = message.metadata && message.metadata.clientMessageId; + + clearTypingUser(message.fromUserId); + + if (isOwn && clientMessageId && state.pendingMessages.has(clientMessageId)) { + state.pendingMessages.delete(clientMessageId); + updatePendingMessageAsReceived(clientMessageId, message); + return; + } + + addMessage(message); + + 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) { @@ -312,6 +356,83 @@ function sendEnvelope(type, payload) { state.socket.send(JSON.stringify({ type, payload })); } +function createClientMessageId() { + return `client_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +function addPendingOwnMessage(text, clientMessageId) { + const message = { + id: clientMessageId, + roomId: 'global', + fromUserId: state.currentUser ? state.currentUser.userId : null, + kind: 'text', + body: text, + metadata: { clientMessageId }, + createdAt: new Date().toISOString(), + status: 'sent', + }; + + state.pendingMessages.set(clientMessageId, message); + addMessage(message); +} + +function updatePendingMessageAsReceived(clientMessageId, message) { + const row = state.messageElements.get(clientMessageId); + + if (!row) { + addMessage(message); + updateMessageStatus(message.id, 'received'); + 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 updateMessageStatus(messageId, 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; @@ -376,6 +497,9 @@ function clearLocalTypingStateBeforeSend() { function resetToLogin(keepDisplayName) { state.currentUser = null; state.users.clear(); + state.pendingMessages.clear(); + state.messageElements.clear(); + state.messageReadBy.clear(); clearTypingState(); elements.chatPanel.classList.add('d-none'); @@ -515,6 +639,9 @@ function clearTypingState() { } function renderEmptyMessages() { + state.pendingMessages.clear(); + state.messageElements.clear(); + state.messageReadBy.clear(); elements.messagesList.replaceChildren(); const empty = document.createElement('div'); @@ -537,18 +664,29 @@ function addMessage(message) { 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}`; + 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 || ''; - row.appendChild(meta); + footer.appendChild(meta); + footer.appendChild(status); + row.appendChild(footer); row.appendChild(bubble); + state.messageElements.set(message.id, row); elements.messagesList.appendChild(row); elements.messagesList.scrollTop = elements.messagesList.scrollHeight; } diff --git a/examples/easy-chat/public/assets/style.css b/examples/easy-chat/public/assets/style.css index b2d907c..2896798 100644 --- a/examples/easy-chat/public/assets/style.css +++ b/examples/easy-chat/public/assets/style.css @@ -6,8 +6,13 @@ box-sizing: border-box; } +html, +body { + height: 100%; + overflow: hidden; +} + body { - min-height: 100vh; margin: 0; background: radial-gradient(circle at top left, rgba(72, 101, 255, 0.28), transparent 32rem), @@ -19,8 +24,12 @@ body { .app-shell { width: min(1180px, calc(100vw - 32px)); + height: 100vh; margin: 0 auto; - padding: 40px 0; + padding: 24px 0; + overflow: hidden; + display: flex; + flex-direction: column; } .hero-card, @@ -112,8 +121,8 @@ body { } .login-panel { - max-width: 520px; - margin: 0 auto; + width: 100%; + margin: 0; } .panel-header { @@ -177,12 +186,21 @@ body { display: grid; grid-template-columns: 310px minmax(0, 1fr); gap: 18px; - min-height: 680px; + flex: 1; + min-height: 0; + overflow: hidden; } .users-panel, .chat-panel { - min-height: 680px; + min-height: 0; + max-height: 100%; +} + +.users-panel { + display: flex; + flex-direction: column; + overflow: hidden; } .count-pill { @@ -195,6 +213,9 @@ body { .users-list { display: grid; gap: 10px; + min-height: 0; + overflow-y: auto; + padding-right: 4px; } .user-item { @@ -262,7 +283,9 @@ body { } .messages-list { - flex: 1; + flex: 1 1 auto; + max-height: 594px; + min-height: 0; display: flex; flex-direction: column; gap: 14px; @@ -295,6 +318,41 @@ body { font-weight: 700; } +.message-footer { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: min(620px, 82%); +} + +.message-row.is-own .message-footer { + justify-content: flex-end; +} + +.message-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + color: #64748b; + 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: #38bdf8; +} + .message-bubble { max-width: min(620px, 82%); padding: 13px 15px; @@ -355,6 +413,17 @@ body { } @media (max-width: 860px) { + html, + body { + overflow: auto; + } + + .app-shell { + height: auto; + min-height: 100vh; + overflow: visible; + } + .hero-card, .chat-header { align-items: flex-start; @@ -363,11 +432,12 @@ body { .chat-layout { grid-template-columns: 1fr; + overflow: visible; } .users-panel, .chat-panel { - min-height: auto; + min-height: 0; } .chat-panel { @@ -377,4 +447,4 @@ body { .message-form { grid-template-columns: 1fr; } -} \ No newline at end of file +} diff --git a/examples/easy-chat/public/index.html b/examples/easy-chat/public/index.html index 8849fdb..868ecec 100644 --- a/examples/easy-chat/public/index.html +++ b/examples/easy-chat/public/index.html @@ -30,7 +30,7 @@

Enter the chat