diff --git a/README.md b/README.md index f660ceb..2534dda 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,50 @@ 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`. + +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 @@