Skip to content

Commit 20fde9e

Browse files
authored
Merge pull request #6 from micilini/phase-06-medium-chat-example
Phase 06 medium chat example
2 parents c8a743a + 34e1ab9 commit 20fde9e

17 files changed

Lines changed: 2370 additions & 18 deletions

File tree

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,50 @@ Fix code style automatically:
5858
composer cs:fix
5959
```
6060

61+
## Running the EasyChat example
62+
63+
Start the WebSocket server:
64+
65+
```bash
66+
php examples/easy-chat/server.php
67+
```
68+
69+
Open a second terminal and start the browser UI:
70+
71+
```bash
72+
php -S 127.0.0.1:8000 -t examples/easy-chat/public
73+
```
74+
75+
Then open:
76+
77+
```txt
78+
http://127.0.0.1:8000
79+
```
80+
81+
## Running the MediumChat example
82+
83+
Start the WebSocket server:
84+
85+
```bash
86+
php examples/medium-chat/server.php
87+
```
88+
89+
Open a second terminal and start the browser UI:
90+
91+
```bash
92+
php -S 127.0.0.1:8001 -t examples/medium-chat/public
93+
```
94+
95+
Then open:
96+
97+
```txt
98+
http://127.0.0.1:8001
99+
```
100+
101+
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`.
102+
103+
EasyChat and MediumChat also include typing indicators and simple message status receipts for sent, received, and read states.
104+
61105
## Requirements
62106

63107
The modern version targets:

examples/easy-chat/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,16 @@ Expected behavior:
7878
- Both users should enter the chat.
7979
- Both users should appear in the online users list.
8080
- Messages sent by one tab should appear in the other tab.
81+
- Own messages should show a message status icon.
82+
- When the server echoes the message, the status should move from sent to received.
83+
- When another browser receives the message, the sender should see the message as read.
8184
- Duplicate display names should be rejected.
8285
- User messages must be rendered safely without `innerHTML`.
8386

8487
## Important notes
8588

8689
This example is intentionally simple.
8790

91+
Message receipts are browser-only example receipts. They are not persisted and do not represent a full per-user room read history.
92+
8893
It only demonstrates the global chat flow. Private direct messages and private group rooms will be demonstrated in later examples.

examples/easy-chat/public/assets/app.js

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ const state = {
44
users: new Map(),
55
typingUsers: new Map(),
66
typingTimers: new Map(),
7+
pendingMessages: new Map(),
8+
messageElements: new Map(),
9+
messageReadBy: new Map(),
710
isTyping: false,
811
typingStopTimer: null,
912
lastTypingStartSentAt: 0,
@@ -53,8 +56,11 @@ elements.messageForm.addEventListener('submit', (event) => {
5356
return;
5457
}
5558

59+
const clientMessageId = createClientMessageId();
60+
5661
clearLocalTypingStateBeforeSend();
57-
sendEnvelope('message.global', { text });
62+
addPendingOwnMessage(text, clientMessageId);
63+
sendEnvelope('message.global', { text, clientMessageId });
5864
elements.messageInput.value = '';
5965
elements.messageInput.focus();
6066
});
@@ -173,6 +179,10 @@ function handleServerMessage(rawMessage) {
173179
handleMessageReceived(envelope.payload);
174180
break;
175181

182+
case 'message.read':
183+
handleMessageRead(envelope.payload);
184+
break;
185+
176186
case 'typing.started':
177187
handleTypingStarted(envelope.payload);
178188
break;
@@ -255,8 +265,42 @@ function handleMessageReceived(payload) {
255265
return;
256266
}
257267

258-
clearTypingUser(payload.message.fromUserId);
259-
addMessage(payload.message);
268+
const message = payload.message;
269+
const isOwn = state.currentUser && message.fromUserId === state.currentUser.userId;
270+
const clientMessageId = message.metadata && message.metadata.clientMessageId;
271+
272+
clearTypingUser(message.fromUserId);
273+
274+
if (isOwn && clientMessageId && state.pendingMessages.has(clientMessageId)) {
275+
state.pendingMessages.delete(clientMessageId);
276+
updatePendingMessageAsReceived(clientMessageId, message);
277+
return;
278+
}
279+
280+
addMessage(message);
281+
282+
if (!isOwn) {
283+
sendEnvelope('message.read', {
284+
messageId: message.id,
285+
roomId: message.roomId || 'global',
286+
});
287+
}
288+
}
289+
290+
function handleMessageRead(payload) {
291+
if (!payload.messageId || !payload.userId) {
292+
return;
293+
}
294+
295+
if (state.currentUser && payload.userId === state.currentUser.userId) {
296+
return;
297+
}
298+
299+
const readBy = state.messageReadBy.get(payload.messageId) || new Map();
300+
readBy.set(payload.userId, payload.displayName || 'Someone');
301+
state.messageReadBy.set(payload.messageId, readBy);
302+
303+
updateMessageStatus(payload.messageId, 'read');
260304
}
261305

262306
function handleTypingStarted(payload) {
@@ -312,6 +356,83 @@ function sendEnvelope(type, payload) {
312356
state.socket.send(JSON.stringify({ type, payload }));
313357
}
314358

359+
function createClientMessageId() {
360+
return `client_${Date.now()}_${Math.random().toString(16).slice(2)}`;
361+
}
362+
363+
function addPendingOwnMessage(text, clientMessageId) {
364+
const message = {
365+
id: clientMessageId,
366+
roomId: 'global',
367+
fromUserId: state.currentUser ? state.currentUser.userId : null,
368+
kind: 'text',
369+
body: text,
370+
metadata: { clientMessageId },
371+
createdAt: new Date().toISOString(),
372+
status: 'sent',
373+
};
374+
375+
state.pendingMessages.set(clientMessageId, message);
376+
addMessage(message);
377+
}
378+
379+
function updatePendingMessageAsReceived(clientMessageId, message) {
380+
const row = state.messageElements.get(clientMessageId);
381+
382+
if (!row) {
383+
addMessage(message);
384+
updateMessageStatus(message.id, 'received');
385+
return;
386+
}
387+
388+
state.messageElements.delete(clientMessageId);
389+
state.messageElements.set(message.id, row);
390+
row.dataset.messageId = message.id;
391+
392+
const status = row.querySelector('.message-status');
393+
updateStatusElement(status, 'received');
394+
}
395+
396+
function updateMessageStatus(messageId, statusName) {
397+
const row = state.messageElements.get(messageId);
398+
399+
if (!row) {
400+
return;
401+
}
402+
403+
const status = row.querySelector('.message-status');
404+
405+
if (!status) {
406+
return;
407+
}
408+
409+
updateStatusElement(status, statusName);
410+
}
411+
412+
function updateStatusElement(element, statusName) {
413+
if (!element) {
414+
return;
415+
}
416+
417+
element.classList.remove('message-status-sent', 'message-status-received', 'message-status-read');
418+
element.classList.add(`message-status-${statusName}`);
419+
420+
if (statusName === 'sent') {
421+
element.textContent = '✓';
422+
element.title = 'Message sent';
423+
return;
424+
}
425+
426+
if (statusName === 'received') {
427+
element.textContent = '✓✓';
428+
element.title = 'Message received';
429+
return;
430+
}
431+
432+
element.textContent = '✓✓';
433+
element.title = 'Message read';
434+
}
435+
315436
function handleTypingInput() {
316437
if (!state.currentUser) {
317438
return;
@@ -376,6 +497,9 @@ function clearLocalTypingStateBeforeSend() {
376497
function resetToLogin(keepDisplayName) {
377498
state.currentUser = null;
378499
state.users.clear();
500+
state.pendingMessages.clear();
501+
state.messageElements.clear();
502+
state.messageReadBy.clear();
379503
clearTypingState();
380504

381505
elements.chatPanel.classList.add('d-none');
@@ -515,6 +639,9 @@ function clearTypingState() {
515639
}
516640

517641
function renderEmptyMessages() {
642+
state.pendingMessages.clear();
643+
state.messageElements.clear();
644+
state.messageReadBy.clear();
518645
elements.messagesList.replaceChildren();
519646

520647
const empty = document.createElement('div');
@@ -537,18 +664,29 @@ function addMessage(message) {
537664

538665
const row = document.createElement('div');
539666
row.className = isOwn ? 'message-row is-own' : 'message-row';
667+
row.dataset.messageId = message.id;
668+
669+
const footer = document.createElement('div');
670+
footer.className = 'message-footer';
540671

541672
const meta = document.createElement('div');
542673
meta.className = 'message-meta';
543-
meta.textContent = `${sender}${createdAt}`;
674+
meta.textContent = `${sender} - ${createdAt}`;
675+
676+
const status = document.createElement('span');
677+
status.className = 'message-status';
678+
updateStatusElement(status, isOwn ? message.status || 'received' : 'received');
544679

545680
const bubble = document.createElement('div');
546681
bubble.className = 'message-bubble';
547682
bubble.textContent = message.body || '';
548683

549-
row.appendChild(meta);
684+
footer.appendChild(meta);
685+
footer.appendChild(status);
686+
row.appendChild(footer);
550687
row.appendChild(bubble);
551688

689+
state.messageElements.set(message.id, row);
552690
elements.messagesList.appendChild(row);
553691
elements.messagesList.scrollTop = elements.messagesList.scrollHeight;
554692
}

0 commit comments

Comments
 (0)