EasyChat
+A modern native PHP WebSocket chat example built with Composer, PHP sockets and plain JavaScript.
+Enter the chat
+Choose a unique display name. Duplicate names are blocked by the server.
+diff --git a/README.zip b/README.zip deleted file mode 100644 index 3ac9742..0000000 Binary files a/README.zip and /dev/null differ diff --git a/examples/easy-chat/README.md b/examples/easy-chat/README.md new file mode 100644 index 0000000..90aa4b8 --- /dev/null +++ b/examples/easy-chat/README.md @@ -0,0 +1,88 @@ +# EasyChat Example + +EasyChat is the first modern browser example for PHPSockets With WebSockets. + +It demonstrates a simple global chat using: + +- Native PHP WebSocket server. +- Composer autoload. +- PHP sockets. +- Chat core with unique display names. +- Plain HTML, CSS and JavaScript. +- Bootstrap through CDN. +- Safe message rendering with `textContent`. + +## 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/easy-chat/server.php +``` + +By default, the WebSocket server runs at: + +```txt +ws://127.0.0.1:8080 +``` + +You can customize host and port with environment variables: + +```bash +PHPSOCKETS_HOST=127.0.0.1 PHPSOCKETS_PORT=8080 php examples/easy-chat/server.php +``` + +On Windows PowerShell: + +```powershell +$env:PHPSOCKETS_HOST="127.0.0.1" +$env:PHPSOCKETS_PORT="8080" +php examples/easy-chat/server.php +``` + +## Running the browser UI + +Open a second terminal and run: + +```bash +php -S 127.0.0.1:8000 -t examples/easy-chat/public +``` + +Then open: + +```txt +http://127.0.0.1:8000 +``` + +## 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. +- Duplicate display names should be rejected. +- User messages must be rendered safely without `innerHTML`. + +## Important notes + +This example is intentionally simple. + +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 new file mode 100644 index 0000000..d584e4c --- /dev/null +++ b/examples/easy-chat/public/assets/app.js @@ -0,0 +1,581 @@ +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'), + connectionStatus: document.getElementById('connectionStatus'), + currentDisplayName: document.getElementById('currentDisplayName'), + displayNameInput: document.getElementById('displayNameInput'), + 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(); +}); + +window.addEventListener('beforeunload', () => { + stopTyping(); + + if (state.socket && state.socket.readyState === WebSocket.OPEN) { + state.socket.close(); + } +}); + +renderEmptyMessages(); +renderTypingIndicator(); +setStatus('Disconnected', 'offline'); + +function connect(serverUrl, displayName) { + disconnect(); + clearAlert(); + setStatus('Connecting', 'connecting'); + setJoinFormEnabled(false); + + try { + state.socket = new WebSocket(serverUrl); + } catch (error) { + setJoinFormEnabled(true); + setStatus('Disconnected', 'offline'); + showAlert('Invalid WebSocket server URL.', 'danger'); + return; + } + + state.socket.addEventListener('open', () => { + sendEnvelope('auth.join', { displayName }); + }); + + state.socket.addEventListener('message', (event) => { + handleServerMessage(event.data); + }); + + state.socket.addEventListener('close', () => { + const hadCurrentUser = Boolean(state.currentUser); + + setStatus('Disconnected', 'offline'); + + if (hadCurrentUser) { + showAlert('Connection closed. Start the server again and re-enter the chat.', 'warning'); + resetToLogin(false); + return; + } + + resetToLogin(true); + }); + + state.socket.addEventListener('error', () => { + setStatus('Connection error', 'offline'); + + if (!state.currentUser) { + setJoinFormEnabled(true); + } + + showAlert('Could not connect to the WebSocket server.', 'danger'); + }); +} + +function disconnect() { + if ( + state.socket && + (state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING) + ) { + state.socket.close(); + } + + state.socket = null; +} + +function handleServerMessage(rawMessage) { + let envelope; + + try { + envelope = JSON.parse(rawMessage); + } catch (error) { + showAlert('The server sent an invalid JSON message.', 'danger'); + return; + } + + switch (envelope.type) { + case 'session.accepted': + handleSessionAccepted(envelope.payload); + break; + + case 'session.rejected': + handleSessionRejected(envelope.payload); + break; + + case 'presence.snapshot': + handlePresenceSnapshot(envelope.payload); + break; + + case 'presence.user_joined': + handleUserJoined(envelope.payload); + break; + + case 'presence.user_left': + handleUserLeft(envelope.payload); + break; + + case 'message.received': + handleMessageReceived(envelope.payload); + break; + + case '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; + } + + state.socket.send(JSON.stringify({ type, payload })); +} + +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/easy-chat/public/assets/style.css b/examples/easy-chat/public/assets/style.css new file mode 100644 index 0000000..b2d907c --- /dev/null +++ b/examples/easy-chat/public/assets/style.css @@ -0,0 +1,380 @@ +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at top left, rgba(72, 101, 255, 0.28), transparent 32rem), + radial-gradient(circle at bottom right, rgba(0, 214, 201, 0.18), transparent 28rem), + #080b13; + color: #f8fafc; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.app-shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 40px 0; +} + +.hero-card, +.panel { + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.82); + box-shadow: 0 24px 90px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(18px); +} + +.hero-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 28px; + border-radius: 28px; + margin-bottom: 18px; +} + +.hero-content h1 { + margin: 8px 0; + font-size: clamp(2.4rem, 5vw, 4.8rem); + line-height: 0.92; + letter-spacing: -0.08em; +} + +.hero-content p { + max-width: 720px; + margin: 0; + color: #a7b4cc; + font-size: 1.05rem; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + color: #7dd3fc; + font-size: 0.78rem; + font-weight: 700; + 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.32); + background: rgba(248, 113, 113, 0.12); + color: #fca5a5; +} + +.status-connecting { + border: 1px solid rgba(251, 191, 36, 0.4); + background: rgba(251, 191, 36, 0.12); + color: #fde68a; +} + +.app-alert { + border-radius: 18px; + border: 0; +} + +.panel { + border-radius: 26px; + padding: 24px; +} + +.login-panel { + max-width: 520px; + margin: 0 auto; +} + +.panel-header { + margin-bottom: 20px; +} + +.panel-header.compact { + display: flex; + align-items: center; + justify-content: space-between; +} + +.panel-header h2, +.chat-header h2 { + margin: 0; + font-size: 1.35rem; + letter-spacing: -0.03em; +} + +.panel-header p { + margin: 8px 0 0; + color: #93a4bc; +} + +.form-label { + color: #cbd5e1; + font-weight: 700; +} + +.form-control { + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(2, 6, 23, 0.72); + color: #f8fafc; + border-radius: 16px; +} + +.form-control:focus { + border-color: rgba(96, 165, 250, 0.76); + background: rgba(2, 6, 23, 0.88); + color: #f8fafc; + box-shadow: 0 0 0 0.25rem rgba(59, 130, 246, 0.16); +} + +.form-control::placeholder { + color: #64748b; +} + +.btn-primary { + border: 0; + border-radius: 16px; + background: linear-gradient(135deg, #2563eb, #06b6d4); + font-weight: 800; + box-shadow: 0 16px 36px rgba(37, 99, 235, 0.28); +} + +.btn-primary:hover { + filter: brightness(1.08); +} + +.chat-layout { + display: grid; + grid-template-columns: 310px minmax(0, 1fr); + gap: 18px; + min-height: 680px; +} + +.users-panel, +.chat-panel { + min-height: 680px; +} + +.count-pill { + min-width: 38px; + height: 32px; + background: rgba(14, 165, 233, 0.16); + color: #7dd3fc; +} + +.users-list { + display: grid; + gap: 10px; +} + +.user-item { + display: flex; + align-items: center; + gap: 12px; + min-height: 50px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 18px; + background: rgba(15, 23, 42, 0.72); +} + +.user-avatar { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(59, 130, 246, 0.2); + color: #bfdbfe; + 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: #7dd3fc; + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; +} + +.chat-panel { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 24px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); +} + +.current-user { + color: #93a4bc; + font-size: 0.92rem; +} + +.current-user strong { + color: #f8fafc; +} + +.messages-list { + flex: 1; + display: flex; + flex-direction: column; + gap: 14px; + padding: 24px; + overflow-y: auto; +} + +.empty-state { + display: grid; + place-items: center; + height: 100%; + color: #64748b; + 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: #64748b; + font-size: 0.78rem; + font-weight: 700; +} + +.message-bubble { + max-width: min(620px, 82%); + padding: 13px 15px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 18px 18px 18px 6px; + background: rgba(30, 41, 59, 0.86); + color: #f8fafc; + word-break: break-word; +} + +.message-row.is-own .message-bubble { + border-radius: 18px 18px 6px 18px; + background: linear-gradient(135deg, rgba(37, 99, 235, 0.95), rgba(8, 145, 178, 0.95)); +} + +.typing-indicator { + min-height: 42px; + padding: 0 24px 16px; + color: #93c5fd; + font-size: 0.92rem; + font-weight: 700; +} + +.typing-indicator::after { + content: ""; + display: inline-block; + width: 1.2em; + text-align: left; + animation: typingDots 1.2s steps(4, end) infinite; +} + +.message-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 18px; + border-top: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(2, 6, 23, 0.36); +} + +@keyframes typingDots { + 0% { + content: ""; + } + + 25% { + content: "."; + } + + 50% { + content: ".."; + } + + 75%, + 100% { + content: "..."; + } +} + +@media (max-width: 860px) { + .hero-card, + .chat-header { + align-items: flex-start; + flex-direction: column; + } + + .chat-layout { + grid-template-columns: 1fr; + } + + .users-panel, + .chat-panel { + min-height: auto; + } + + .chat-panel { + min-height: 620px; + } + + .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 new file mode 100644 index 0000000..8849fdb --- /dev/null +++ b/examples/easy-chat/public/index.html @@ -0,0 +1,81 @@ + + +
+ + +A modern native PHP WebSocket chat example built with Composer, PHP sockets and plain JavaScript.
+Choose a unique display name. Duplicate names are blocked by the server.
+