diff --git a/.gitignore b/.gitignore index 9900982..3873cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -32,9 +32,11 @@ desktop.ini /storage/*.sqlite /storage/*.sqlite3 /storage/*.db +/storage/attachments/ /examples/**/storage/*.sqlite /examples/**/storage/*.sqlite3 /examples/**/storage/*.db +/examples/**/storage/attachments/ node_modules/ npm-debug.log* diff --git a/README.md b/README.md index 8645ac0..74204e3 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,43 @@ $pdo = PdoConnectionFactory::sqlite(__DIR__ . '/storage/phpsockets.sqlite'); The CLI migration command will be added in a future phase. +## Emoji and small attachment support + +The chat examples support a composer action button next to the message input. + +Users can: + +- Insert emojis from a small built-in emoji picker. +- Send small files up to the configured limit. +- Send image previews, PDFs and text files. +- Keep message rendering safe with `textContent`. + +The initial attachment transport uses JSON text-frame envelopes with base64 payloads over WebSocket. The chat core does not accept binary WebSocket frames for chat messages in this version. Larger uploads and chunked binary frames are planned for future versions. + +## Attachment composer behavior + +Selecting a file does not send it immediately. + +The selected file appears as a pending attachment in the composer. The user can add a text caption and click `Send`. + +Supported files: + +```txt +image/png +image/jpeg +image/gif +application/pdf +text/plain +``` + +Default max file size: + +```txt +2 MB +``` + +Each delivered file message includes a download button. + ## Requirements The modern version targets: diff --git a/examples/easy-chat/README.md b/examples/easy-chat/README.md index 8d36c04..b8414c0 100644 --- a/examples/easy-chat/README.md +++ b/examples/easy-chat/README.md @@ -91,3 +91,56 @@ 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. + +## Composer actions + +The message input includes a left-side action button. + +It opens: + +- Emoji picker. +- File picker. + +Allowed files: + +```txt +image/png +image/jpeg +image/gif +application/pdf +text/plain +``` + +Default max size: + +```txt +2 MB +``` + +All user-provided text continues to be rendered safely. + +## Attachment composer behavior + +Selecting a file does not send it immediately. + +The selected file appears as a pending attachment in the composer. The user can add a text caption and click `Send`. + +Supported files: + +```txt +image/png +image/jpeg +image/gif +application/pdf +text/plain +``` + +Default max file size: + +```txt +2 MB +``` + +Each delivered file message includes a download button. + +Attachments are transported as JSON text-frame envelopes with base64 content. The chat core does not accept binary WebSocket frames for chat messages in this version. diff --git a/examples/easy-chat/public/assets/app.js b/examples/easy-chat/public/assets/app.js index 4762054..9965c5e 100644 --- a/examples/easy-chat/public/assets/app.js +++ b/examples/easy-chat/public/assets/app.js @@ -7,6 +7,7 @@ const state = { pendingMessages: new Map(), messageElements: new Map(), messageReadBy: new Map(), + selectedAttachment: null, isTyping: false, typingStopTimer: null, lastTypingStartSentAt: 0, @@ -14,12 +15,31 @@ const state = { typingIdleStopMs: 1400, }; +const EMOJIS = [ + '\u{1F600}', '\u{1F603}', '\u{1F604}', '\u{1F601}', '\u{1F606}', '\u{1F605}', '\u{1F602}', '\u{1F642}', + '\u{1F60D}', '\u{1F618}', '\u{1F60E}', '\u{1F914}', '\u{1F44D}', '\u{1F44F}', '\u{1F64C}', '\u{1F525}', + '\u2764\uFE0F', '\u{1F680}', '\u{1F389}', '\u2728', '\u{1F4A1}', '\u2705', '\u{1F4CC}', '\u{1F4E6}', +]; + +const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024; +const ALLOWED_ATTACHMENT_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'application/pdf', + 'text/plain', +]; + const elements = { alertBox: document.getElementById('alertBox'), chatPanel: document.getElementById('chatPanel'), + composerActionsButton: document.getElementById('composerActionsButton'), + composerActionsMenu: document.getElementById('composerActionsMenu'), connectionStatus: document.getElementById('connectionStatus'), currentDisplayName: document.getElementById('currentDisplayName'), displayNameInput: document.getElementById('displayNameInput'), + emojiPicker: document.getElementById('emojiPicker'), + fileInput: document.getElementById('fileInput'), joinButton: document.getElementById('joinButton'), joinForm: document.getElementById('joinForm'), loginPanel: document.getElementById('loginPanel'), @@ -27,11 +47,16 @@ const elements = { messageInput: document.getElementById('messageInput'), messagesList: document.getElementById('messagesList'), onlineCount: document.getElementById('onlineCount'), + openEmojiButton: document.getElementById('openEmojiButton'), + openFileButton: document.getElementById('openFileButton'), serverUrlInput: document.getElementById('serverUrlInput'), + selectedAttachmentPreview: document.getElementById('selectedAttachmentPreview'), typingIndicator: document.getElementById('typingIndicator'), usersList: document.getElementById('usersList'), }; +let alertDismissTimer = null; + elements.joinForm.addEventListener('submit', (event) => { event.preventDefault(); @@ -46,16 +71,22 @@ elements.joinForm.addEventListener('submit', (event) => { connect(serverUrl, displayName); }); -elements.messageForm.addEventListener('submit', (event) => { +elements.messageForm.addEventListener('submit', async (event) => { event.preventDefault(); const text = elements.messageInput.value.trim(); + const selectedAttachment = state.selectedAttachment; - if (!text) { + if (!text && !selectedAttachment) { stopTyping(); return; } + if (selectedAttachment) { + await sendSelectedAttachment(text); + return; + } + const clientMessageId = createClientMessageId(); clearLocalTypingStateBeforeSend(); @@ -73,6 +104,37 @@ elements.messageInput.addEventListener('blur', () => { stopTyping(); }); +elements.composerActionsButton.addEventListener('click', () => { + toggleComposerActionsMenu(); +}); + +elements.openEmojiButton.addEventListener('click', () => { + closeComposerActionsMenu(); + toggleEmojiPicker(); +}); + +elements.openFileButton.addEventListener('click', () => { + closeComposerActionsMenu(); + elements.fileInput.click(); +}); + +elements.fileInput.addEventListener('change', () => { + handleSelectedFile(); +}); + +document.addEventListener('click', (event) => { + const target = event.target; + + if (!(target instanceof Element)) { + return; + } + + if (!target.closest('.composer-actions')) { + closeComposerActionsMenu(); + closeEmojiPicker(); + } +}); + window.addEventListener('beforeunload', () => { stopTyping(); @@ -81,6 +143,7 @@ window.addEventListener('beforeunload', () => { } }); +renderEmojiPicker(); renderEmptyMessages(); renderTypingIndicator(); setStatus('Disconnected', 'offline'); @@ -183,6 +246,14 @@ function handleServerMessage(rawMessage) { handleMessageRead(envelope.payload); break; + case 'attachment.accepted': + handleAttachmentAccepted(envelope.payload); + break; + + case 'attachment.rejected': + handleAttachmentRejected(envelope.payload); + break; + case 'typing.started': handleTypingStarted(envelope.payload); break; @@ -347,6 +418,18 @@ function handleServerError(payload) { showAlert(message, 'danger'); } +function handleAttachmentAccepted(payload) { + void payload; +} + +function handleAttachmentRejected(payload) { + const message = payload && typeof payload.message === 'string' + ? payload.message + : 'Attachment was rejected.'; + + showAlert(message, 'warning'); +} + function sendEnvelope(type, payload) { if (!state.socket || state.socket.readyState !== WebSocket.OPEN) { showAlert('WebSocket connection is not open.', 'danger'); @@ -376,6 +459,31 @@ function addPendingOwnMessage(text, clientMessageId) { addMessage(message); } +function addPendingOwnFileMessage(file, clientMessageId, caption = '') { + const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null; + const downloadUrl = URL.createObjectURL(file); + const message = { + id: clientMessageId, + roomId: 'global', + fromUserId: state.currentUser ? state.currentUser.userId : null, + kind: 'file', + body: { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + previewDataUrl: previewUrl, + downloadDataUrl: downloadUrl, + caption, + }, + 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); @@ -501,6 +609,9 @@ function resetToLogin(keepDisplayName) { state.messageElements.clear(); state.messageReadBy.clear(); clearTypingState(); + closeComposerActionsMenu(); + closeEmojiPicker(); + clearSelectedAttachment(); elements.chatPanel.classList.add('d-none'); elements.loginPanel.classList.remove('d-none'); @@ -515,6 +626,259 @@ function resetToLogin(keepDisplayName) { renderEmptyMessages(); } +function toggleComposerActionsMenu() { + const isHidden = elements.composerActionsMenu.classList.contains('d-none'); + + elements.composerActionsMenu.classList.toggle('d-none', !isHidden); + elements.composerActionsMenu.setAttribute('aria-hidden', isHidden ? 'false' : 'true'); + + closeEmojiPicker(); +} + +function closeComposerActionsMenu() { + elements.composerActionsMenu.classList.add('d-none'); + elements.composerActionsMenu.setAttribute('aria-hidden', 'true'); +} + +function toggleEmojiPicker() { + const isHidden = elements.emojiPicker.classList.contains('d-none'); + + elements.emojiPicker.classList.toggle('d-none', !isHidden); + elements.emojiPicker.setAttribute('aria-hidden', isHidden ? 'false' : 'true'); +} + +function closeEmojiPicker() { + elements.emojiPicker.classList.add('d-none'); + elements.emojiPicker.setAttribute('aria-hidden', 'true'); +} + +function renderEmojiPicker() { + elements.emojiPicker.replaceChildren(); + + for (const emoji of EMOJIS) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'emoji-button'; + button.textContent = emoji; + button.title = emoji; + + button.addEventListener('click', () => { + insertAtCursor(elements.messageInput, emoji); + closeEmojiPicker(); + }); + + elements.emojiPicker.appendChild(button); + } +} + +function insertAtCursor(input, value) { + const start = input.selectionStart || 0; + const end = input.selectionEnd || 0; + const before = input.value.slice(0, start); + const after = input.value.slice(end); + + input.value = `${before}${value}${after}`; + input.selectionStart = start + value.length; + input.selectionEnd = start + value.length; + input.focus(); + + handleTypingInput(); +} + +function handleSelectedFile() { + const file = elements.fileInput.files && elements.fileInput.files[0] + ? elements.fileInput.files[0] + : null; + + elements.fileInput.value = ''; + + if (!file) { + return; + } + + if (file.size > MAX_ATTACHMENT_BYTES) { + showAlert(`File is too large. Maximum size is ${formatFileSize(MAX_ATTACHMENT_BYTES)}.`, 'warning'); + return; + } + + if (!isAllowedAttachment(file)) { + showAlert('This file type is not allowed.', 'warning'); + return; + } + + setSelectedAttachment(file); +} + +function setSelectedAttachment(file) { + clearSelectedAttachment(); + + const previewUrl = file.type.startsWith('image/') + ? URL.createObjectURL(file) + : null; + + state.selectedAttachment = { + file, + previewUrl, + }; + + renderSelectedAttachmentPreview(); +} + +function clearSelectedAttachment() { + if (state.selectedAttachment && state.selectedAttachment.previewUrl) { + URL.revokeObjectURL(state.selectedAttachment.previewUrl); + } + + state.selectedAttachment = null; + renderSelectedAttachmentPreview(); +} + +function renderSelectedAttachmentPreview() { + elements.selectedAttachmentPreview.replaceChildren(); + + if (!state.selectedAttachment) { + elements.selectedAttachmentPreview.classList.add('d-none'); + return; + } + + const { file, previewUrl } = state.selectedAttachment; + + elements.selectedAttachmentPreview.classList.remove('d-none'); + + const info = document.createElement('div'); + info.className = 'selected-attachment-info'; + + const thumb = document.createElement('span'); + thumb.className = 'selected-attachment-thumb'; + + if (previewUrl) { + const image = document.createElement('img'); + image.src = previewUrl; + image.alt = file.name; + thumb.appendChild(image); + } else { + thumb.textContent = file.type === 'application/pdf' ? 'PDF' : 'TXT'; + } + + const text = document.createElement('div'); + text.className = 'selected-attachment-text'; + + const name = document.createElement('strong'); + name.textContent = file.name; + + const meta = document.createElement('small'); + meta.textContent = `${file.type || 'unknown'} - ${formatFileSize(file.size)}`; + + text.appendChild(name); + text.appendChild(meta); + info.appendChild(thumb); + info.appendChild(text); + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'remove-attachment-button'; + removeButton.textContent = 'Remove'; + + removeButton.addEventListener('click', () => { + clearSelectedAttachment(); + elements.messageInput.focus(); + }); + + elements.selectedAttachmentPreview.appendChild(info); + elements.selectedAttachmentPreview.appendChild(removeButton); +} + +async function sendSelectedAttachment(caption) { + if (!state.selectedAttachment) { + return; + } + + const { file } = state.selectedAttachment; + + if (file.size > MAX_ATTACHMENT_BYTES) { + showAlert(`File is too large. Maximum size is ${formatFileSize(MAX_ATTACHMENT_BYTES)}.`, 'warning'); + return; + } + + if (!isAllowedAttachment(file)) { + showAlert('This file type is not allowed.', 'warning'); + return; + } + + try { + clearLocalTypingStateBeforeSend(); + + const contentBase64 = await readFileAsBase64(file); + const clientMessageId = createClientMessageId(); + + addPendingOwnFileMessage(file, clientMessageId, caption); + + sendEnvelope('message.file', { + scope: 'global', + clientMessageId, + caption, + attachment: { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + contentBase64, + }, + }); + + elements.messageInput.value = ''; + clearSelectedAttachment(); + elements.messageInput.focus(); + } catch (error) { + showAlert(error instanceof Error ? error.message : 'Failed to send file.', 'danger'); + } +} + +function isAllowedAttachment(file) { + return ALLOWED_ATTACHMENT_MIME_TYPES.includes(file.type); +} + +function formatFileSize(sizeBytes) { + if (sizeBytes < 1024) { + return `${sizeBytes} B`; + } + + if (sizeBytes < 1024 * 1024) { + return `${(sizeBytes / 1024).toFixed(1)} KB`; + } + + return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`; +} + +function readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener('load', () => { + const result = reader.result; + + if (typeof result !== 'string') { + reject(new Error('Failed to read file.')); + return; + } + + const commaIndex = result.indexOf(','); + + if (commaIndex === -1) { + reject(new Error('Failed to parse file content.')); + return; + } + + resolve(result.slice(commaIndex + 1)); + }); + + reader.addEventListener('error', () => { + reject(new Error('Failed to read file.')); + }); + + reader.readAsDataURL(file); + }); +} + function setJoinFormEnabled(enabled) { elements.displayNameInput.disabled = !enabled; elements.serverUrlInput.disabled = !enabled; @@ -528,14 +892,35 @@ function setStatus(label, mode) { elements.connectionStatus.classList.add(`status-${mode}`); } -function showAlert(message, type) { - elements.alertBox.textContent = message; +function showAlert(message, type = 'danger', autoDismissMs = 5000) { + if (alertDismissTimer) { + window.clearTimeout(alertDismissTimer); + alertDismissTimer = null; + } + elements.alertBox.className = `alert app-alert alert-${type}`; + elements.alertBox.textContent = message; + elements.alertBox.classList.remove('d-none'); + + if (autoDismissMs > 0) { + alertDismissTimer = window.setTimeout(() => { + hideAlert(); + }, autoDismissMs); + } } function clearAlert() { + hideAlert(); +} + +function hideAlert() { + if (alertDismissTimer) { + window.clearTimeout(alertDismissTimer); + alertDismissTimer = null; + } + + elements.alertBox.classList.add('d-none'); elements.alertBox.textContent = ''; - elements.alertBox.className = 'alert app-alert d-none'; } function renderUsers() { @@ -677,9 +1062,7 @@ function addMessage(message) { status.className = 'message-status'; updateStatusElement(status, isOwn ? message.status || 'received' : 'received'); - const bubble = document.createElement('div'); - bubble.className = 'message-bubble'; - bubble.textContent = message.body || ''; + const bubble = createMessageBodyElement(message); footer.appendChild(meta); footer.appendChild(status); @@ -691,6 +1074,79 @@ function addMessage(message) { elements.messagesList.scrollTop = elements.messagesList.scrollHeight; } +function createMessageBodyElement(message) { + if (message.kind === 'file') { + return createFileMessageElement(message.body || {}); + } + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + bubble.textContent = message.body || ''; + + return bubble; +} + +function createFileMessageElement(body) { + const card = document.createElement('div'); + card.className = 'message-bubble file-message'; + + const fileName = typeof body.fileName === 'string' ? body.fileName : 'Attachment'; + const mimeType = typeof body.mimeType === 'string' ? body.mimeType : 'application/octet-stream'; + const sizeBytes = typeof body.sizeBytes === 'number' ? body.sizeBytes : 0; + const previewDataUrl = typeof body.previewDataUrl === 'string' ? body.previewDataUrl : null; + const downloadUrl = typeof body.downloadDataUrl === 'string' ? body.downloadDataUrl : previewDataUrl; + const caption = typeof body.caption === 'string' ? body.caption.trim() : ''; + + if (previewDataUrl && mimeType.startsWith('image/')) { + const image = document.createElement('img'); + image.className = 'file-message-preview'; + image.src = previewDataUrl; + image.alt = fileName; + card.appendChild(image); + } + + const info = document.createElement('div'); + info.className = 'file-message-info'; + + const icon = document.createElement('span'); + icon.className = 'file-message-icon'; + icon.textContent = mimeType.startsWith('image/') ? 'IMG' : mimeType === 'application/pdf' ? 'PDF' : 'TXT'; + + const text = document.createElement('div'); + + const name = document.createElement('strong'); + name.textContent = fileName; + + const meta = document.createElement('small'); + meta.textContent = `${mimeType} - ${formatFileSize(sizeBytes)}`; + + text.appendChild(name); + text.appendChild(meta); + + info.appendChild(icon); + info.appendChild(text); + card.appendChild(info); + + if (caption) { + const captionElement = document.createElement('p'); + captionElement.className = 'file-message-caption'; + captionElement.textContent = caption; + card.appendChild(captionElement); + } + + if (downloadUrl) { + const downloadLink = document.createElement('a'); + downloadLink.className = 'file-download-button'; + downloadLink.href = downloadUrl; + downloadLink.download = fileName; + downloadLink.textContent = 'Download'; + downloadLink.rel = 'noopener'; + card.appendChild(downloadLink); + } + + return card; +} + function findDisplayName(userId) { const user = state.users.get(userId); diff --git a/examples/easy-chat/public/assets/style.css b/examples/easy-chat/public/assets/style.css index 2896798..534dd9b 100644 --- a/examples/easy-chat/public/assets/style.css +++ b/examples/easy-chat/public/assets/style.css @@ -385,12 +385,298 @@ body { } .message-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; + display: block; gap: 12px; padding: 18px; border-top: 1px solid rgba(148, 163, 184, 0.14); background: rgba(2, 6, 23, 0.36); + position: relative; + overflow: visible; + z-index: 30; +} + +.composer-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; +} + +.composer-actions { + position: relative; + display: flex; + align-items: center; + z-index: 31; +} + +.composer-actions-button { + width: 46px; + height: 46px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 16px; + background: rgba(15, 23, 42, 0.86); + color: #e2e8f0; + font-size: 1.35rem; + font-weight: 900; + line-height: 1; + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; +} + +.composer-actions-button:hover { + transform: translateY(-1px); + border-color: rgba(56, 189, 248, 0.5); + background: rgba(30, 41, 59, 0.95); +} + +.composer-actions-menu, +.emoji-picker { + position: absolute; + left: 0; + bottom: calc(100% + 12px); + z-index: 20; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(18px); +} + +.composer-actions-menu { + width: 190px; + padding: 8px; + display: grid; + gap: 6px; +} + +.composer-actions-menu.d-none, +.emoji-picker.d-none { + display: none; +} + +.composer-action-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: 0; + border-radius: 14px; + padding: 11px 12px; + background: transparent; + color: #e2e8f0; + text-align: left; +} + +.composer-action-item:hover { + background: rgba(56, 189, 248, 0.12); +} + +.composer-action-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: 0 0 auto; + color: #e2e8f0; +} + +.composer-action-icon svg { + width: 22px; + height: 22px; + fill: currentColor; +} + +.composer-action-label { + color: #e2e8f0; + font-weight: 800; +} + +.emoji-picker { + width: 292px; + max-height: 220px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + padding: 12px; + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; +} + +.emoji-button { + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; + border: 0; + border-radius: 12px; + background: transparent; + font-size: 1.25rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.emoji-button:hover { + background: rgba(56, 189, 248, 0.16); +} + +.file-message { + display: grid; + gap: 10px; + min-width: min(320px, 100%); +} + +.file-message-preview { + width: 100%; + max-width: 340px; + max-height: 220px; + border-radius: 14px; + object-fit: cover; + border: 1px solid rgba(148, 163, 184, 0.16); +} + +.file-message-info { + display: flex; + align-items: center; + gap: 12px; +} + +.file-message-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.36); + font-size: 1.25rem; +} + +.file-message-info strong, +.file-message-info small { + display: block; +} + +.file-message-info strong { + color: #f8fafc; + font-size: 0.92rem; + word-break: break-word; +} + +.file-message-info small { + color: #94a3b8; + margin-top: 2px; + font-size: 0.76rem; +} + +.file-download-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + margin-top: 8px; + padding: 8px 12px; + border-radius: 12px; + background: rgba(56, 189, 248, 0.14); + color: #bae6fd; + font-size: 0.82rem; + font-weight: 900; + text-decoration: none; +} + +.file-download-button:hover { + background: rgba(56, 189, 248, 0.22); + color: #e0f2fe; +} + +.file-message-caption { + margin: 2px 0 0; + color: #e2e8f0; + font-size: 0.92rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.selected-attachment-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + width: 100%; + margin-bottom: 10px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + background: rgba(15, 23, 42, 0.82); +} + +.selected-attachment-preview.d-none { + display: none; +} + +.selected-attachment-info { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.selected-attachment-thumb { + display: inline-flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + border-radius: 14px; + background: rgba(30, 41, 59, 0.9); + overflow: hidden; + flex: 0 0 auto; + color: #e2e8f0; + font-weight: 900; +} + +.selected-attachment-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.selected-attachment-text { + min-width: 0; +} + +.selected-attachment-text strong, +.selected-attachment-text small { + display: block; +} + +.selected-attachment-text strong { + color: #f8fafc; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selected-attachment-text small { + color: #94a3b8; + margin-top: 2px; + font-size: 0.76rem; +} + +.remove-attachment-button { + border: 0; + border-radius: 12px; + padding: 8px 10px; + background: rgba(248, 113, 113, 0.14); + color: #fecaca; + font-weight: 900; +} + +.remove-attachment-button:hover { + background: rgba(248, 113, 113, 0.24); } @keyframes typingDots { @@ -444,7 +730,7 @@ body { min-height: 620px; } - .message-form { + .composer-row { grid-template-columns: 1fr; } } diff --git a/examples/easy-chat/public/index.html b/examples/easy-chat/public/index.html index 868ecec..5bf1f26 100644 --- a/examples/easy-chat/public/index.html +++ b/examples/easy-chat/public/index.html @@ -69,8 +69,40 @@

Public conversation

- - +
+ +
+
+ + + + + + + +
+ + + +
diff --git a/examples/easy-chat/server.php b/examples/easy-chat/server.php index 5388932..d568e35 100644 --- a/examples/easy-chat/server.php +++ b/examples/easy-chat/server.php @@ -16,8 +16,8 @@ echo "Press Ctrl+C to stop the WebSocket server.\n\n"; $server = ChatServer::create( - ServerConfig::new(host: $host, port: $port), + ServerConfig::new(host: $host, port: $port, maxPayloadBytes: 4 * 1024 * 1024), ChatConfig::new(), ); -$server->run(); \ No newline at end of file +$server->run(); diff --git a/examples/medium-chat/README.md b/examples/medium-chat/README.md index 816ae7f..9605e4c 100644 --- a/examples/medium-chat/README.md +++ b/examples/medium-chat/README.md @@ -122,3 +122,56 @@ This example still uses the global room only. Message receipts are browser-only example receipts. They are not persisted and do not represent a full per-user room read history. Direct private messages and private group rooms are demonstrated in later phases. + +## Composer actions + +The message input includes a left-side action button. + +It opens: + +- Emoji picker. +- File picker. + +Allowed files: + +```txt +image/png +image/jpeg +image/gif +application/pdf +text/plain +``` + +Default max size: + +```txt +2 MB +``` + +All user-provided text continues to be rendered safely. + +## Attachment composer behavior + +Selecting a file does not send it immediately. + +The selected file appears as a pending attachment in the composer. The user can add a text caption and click `Send`. + +Supported files: + +```txt +image/png +image/jpeg +image/gif +application/pdf +text/plain +``` + +Default max file size: + +```txt +2 MB +``` + +Each delivered file message includes a download button. + +Attachments are transported as JSON text-frame envelopes with base64 content. The chat core does not accept binary WebSocket frames for chat messages in this version. diff --git a/examples/medium-chat/public/assets/app.js b/examples/medium-chat/public/assets/app.js index 74b80b6..56dad97 100644 --- a/examples/medium-chat/public/assets/app.js +++ b/examples/medium-chat/public/assets/app.js @@ -7,6 +7,7 @@ const state = { pendingMessages: new Map(), messageElements: new Map(), messageReadBy: new Map(), + selectedAttachment: null, isTyping: false, typingStopTimer: null, lastTypingStartSentAt: 0, @@ -14,14 +15,33 @@ const state = { typingIdleStopMs: 1400, }; +const EMOJIS = [ + '\u{1F600}', '\u{1F603}', '\u{1F604}', '\u{1F601}', '\u{1F606}', '\u{1F605}', '\u{1F602}', '\u{1F642}', + '\u{1F60D}', '\u{1F618}', '\u{1F60E}', '\u{1F914}', '\u{1F44D}', '\u{1F44F}', '\u{1F64C}', '\u{1F525}', + '\u2764\uFE0F', '\u{1F680}', '\u{1F389}', '\u2728', '\u{1F4A1}', '\u2705', '\u{1F4CC}', '\u{1F4E6}', +]; + +const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024; +const ALLOWED_ATTACHMENT_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'application/pdf', + 'text/plain', +]; + const elements = { alertBox: document.getElementById('alertBox'), chatPanel: document.getElementById('chatPanel'), clearEventsButton: document.getElementById('clearEventsButton'), + composerActionsButton: document.getElementById('composerActionsButton'), + composerActionsMenu: document.getElementById('composerActionsMenu'), connectionStatus: document.getElementById('connectionStatus'), currentDisplayName: document.getElementById('currentDisplayName'), displayNameInput: document.getElementById('displayNameInput'), + emojiPicker: document.getElementById('emojiPicker'), eventLog: document.getElementById('eventLog'), + fileInput: document.getElementById('fileInput'), joinButton: document.getElementById('joinButton'), joinForm: document.getElementById('joinForm'), loginPanel: document.getElementById('loginPanel'), @@ -29,11 +49,16 @@ const elements = { messageInput: document.getElementById('messageInput'), messagesList: document.getElementById('messagesList'), onlineCount: document.getElementById('onlineCount'), + openEmojiButton: document.getElementById('openEmojiButton'), + openFileButton: document.getElementById('openFileButton'), serverUrlInput: document.getElementById('serverUrlInput'), + selectedAttachmentPreview: document.getElementById('selectedAttachmentPreview'), typingIndicator: document.getElementById('typingIndicator'), usersList: document.getElementById('usersList'), }; +let alertDismissTimer = null; + elements.joinForm.addEventListener('submit', (event) => { event.preventDefault(); @@ -48,16 +73,22 @@ elements.joinForm.addEventListener('submit', (event) => { connect(serverUrl, displayName); }); -elements.messageForm.addEventListener('submit', (event) => { +elements.messageForm.addEventListener('submit', async (event) => { event.preventDefault(); const text = elements.messageInput.value.trim(); + const selectedAttachment = state.selectedAttachment; - if (!text) { + if (!text && !selectedAttachment) { stopTyping(); return; } + if (selectedAttachment) { + await sendSelectedAttachment(text); + return; + } + const clientMessageId = createClientMessageId(); clearLocalTypingStateBeforeSend(); @@ -79,6 +110,37 @@ elements.clearEventsButton.addEventListener('click', () => { elements.eventLog.replaceChildren(); }); +elements.composerActionsButton.addEventListener('click', () => { + toggleComposerActionsMenu(); +}); + +elements.openEmojiButton.addEventListener('click', () => { + closeComposerActionsMenu(); + toggleEmojiPicker(); +}); + +elements.openFileButton.addEventListener('click', () => { + closeComposerActionsMenu(); + elements.fileInput.click(); +}); + +elements.fileInput.addEventListener('change', () => { + handleSelectedFile(); +}); + +document.addEventListener('click', (event) => { + const target = event.target; + + if (!(target instanceof Element)) { + return; + } + + if (!target.closest('.composer-actions')) { + closeComposerActionsMenu(); + closeEmojiPicker(); + } +}); + window.addEventListener('beforeunload', () => { stopTyping(); @@ -87,6 +149,7 @@ window.addEventListener('beforeunload', () => { } }); +renderEmojiPicker(); renderEmptyMessages(); renderTypingIndicator(); setStatus('Disconnected', 'offline'); @@ -197,6 +260,14 @@ function handleServerMessage(rawMessage) { handleMessageRead(envelope.payload); break; + case 'attachment.accepted': + handleAttachmentAccepted(envelope.payload); + break; + + case 'attachment.rejected': + handleAttachmentRejected(envelope.payload); + break; + case 'typing.started': handleTypingStarted(envelope.payload); break; @@ -361,6 +432,18 @@ function handleServerError(payload) { showAlert(message, 'danger'); } +function handleAttachmentAccepted(payload) { + void payload; +} + +function handleAttachmentRejected(payload) { + const message = payload && typeof payload.message === 'string' + ? payload.message + : 'Attachment was rejected.'; + + showAlert(message, 'warning'); +} + function sendEnvelope(type, payload) { if (!state.socket || state.socket.readyState !== WebSocket.OPEN) { showAlert('WebSocket connection is not open.', 'danger'); @@ -374,7 +457,9 @@ function sendEnvelope(type, payload) { function clientEventName(type) { const clientEvents = { 'auth.join': 'client.auth.join', + 'attachment.prepare': 'client.attachment.prepare', 'message.global': 'client.message.global', + 'message.file': 'client.message.file', 'message.read': 'client.message.read', 'typing.start': 'client.typing.start', 'typing.stop': 'client.typing.stop', @@ -424,6 +509,31 @@ function addPendingOwnMessage(text, clientMessageId) { addMessage(message); } +function addPendingOwnFileMessage(file, clientMessageId, caption = '') { + const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null; + const downloadUrl = URL.createObjectURL(file); + const message = { + id: clientMessageId, + roomId: 'global', + fromUserId: state.currentUser ? state.currentUser.userId : null, + kind: 'file', + body: { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + previewDataUrl: previewUrl, + downloadDataUrl: downloadUrl, + caption, + }, + 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); @@ -549,6 +659,9 @@ function resetToLogin(keepDisplayName) { state.messageElements.clear(); state.messageReadBy.clear(); clearTypingState(); + closeComposerActionsMenu(); + closeEmojiPicker(); + clearSelectedAttachment(); elements.chatPanel.classList.add('d-none'); elements.loginPanel.classList.remove('d-none'); @@ -563,6 +676,256 @@ function resetToLogin(keepDisplayName) { renderEmptyMessages(); } +function toggleComposerActionsMenu() { + const isHidden = elements.composerActionsMenu.classList.contains('d-none'); + + elements.composerActionsMenu.classList.toggle('d-none', !isHidden); + elements.composerActionsMenu.setAttribute('aria-hidden', isHidden ? 'false' : 'true'); + + closeEmojiPicker(); +} + +function closeComposerActionsMenu() { + elements.composerActionsMenu.classList.add('d-none'); + elements.composerActionsMenu.setAttribute('aria-hidden', 'true'); +} + +function toggleEmojiPicker() { + const isHidden = elements.emojiPicker.classList.contains('d-none'); + + elements.emojiPicker.classList.toggle('d-none', !isHidden); + elements.emojiPicker.setAttribute('aria-hidden', isHidden ? 'false' : 'true'); +} + +function closeEmojiPicker() { + elements.emojiPicker.classList.add('d-none'); + elements.emojiPicker.setAttribute('aria-hidden', 'true'); +} + +function renderEmojiPicker() { + elements.emojiPicker.replaceChildren(); + + for (const emoji of EMOJIS) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'emoji-button'; + button.textContent = emoji; + button.title = emoji; + + button.addEventListener('click', () => { + insertAtCursor(elements.messageInput, emoji); + closeEmojiPicker(); + }); + + elements.emojiPicker.appendChild(button); + } +} + +function insertAtCursor(input, value) { + const start = input.selectionStart || 0; + const end = input.selectionEnd || 0; + const before = input.value.slice(0, start); + const after = input.value.slice(end); + + input.value = `${before}${value}${after}`; + input.selectionStart = start + value.length; + input.selectionEnd = start + value.length; + input.focus(); + + handleTypingInput(); +} + +function handleSelectedFile() { + const file = elements.fileInput.files && elements.fileInput.files[0] + ? elements.fileInput.files[0] + : null; + + elements.fileInput.value = ''; + + if (!file) { + return; + } + + if (file.size > MAX_ATTACHMENT_BYTES) { + showAlert(`File is too large. Maximum size is ${formatFileSize(MAX_ATTACHMENT_BYTES)}.`, 'warning'); + return; + } + + if (!isAllowedAttachment(file)) { + showAlert('This file type is not allowed.', 'warning'); + return; + } + + setSelectedAttachment(file); +} + +function setSelectedAttachment(file) { + clearSelectedAttachment(); + + const previewUrl = file.type.startsWith('image/') + ? URL.createObjectURL(file) + : null; + + state.selectedAttachment = { file, previewUrl }; + + renderSelectedAttachmentPreview(); +} + +function clearSelectedAttachment() { + if (state.selectedAttachment && state.selectedAttachment.previewUrl) { + URL.revokeObjectURL(state.selectedAttachment.previewUrl); + } + + state.selectedAttachment = null; + renderSelectedAttachmentPreview(); +} + +function renderSelectedAttachmentPreview() { + elements.selectedAttachmentPreview.replaceChildren(); + + if (!state.selectedAttachment) { + elements.selectedAttachmentPreview.classList.add('d-none'); + return; + } + + const { file, previewUrl } = state.selectedAttachment; + + elements.selectedAttachmentPreview.classList.remove('d-none'); + + const info = document.createElement('div'); + info.className = 'selected-attachment-info'; + + const thumb = document.createElement('span'); + thumb.className = 'selected-attachment-thumb'; + + if (previewUrl) { + const image = document.createElement('img'); + image.src = previewUrl; + image.alt = file.name; + thumb.appendChild(image); + } else { + thumb.textContent = file.type === 'application/pdf' ? 'PDF' : 'TXT'; + } + + const text = document.createElement('div'); + text.className = 'selected-attachment-text'; + + const name = document.createElement('strong'); + name.textContent = file.name; + + const meta = document.createElement('small'); + meta.textContent = `${file.type || 'unknown'} - ${formatFileSize(file.size)}`; + + text.appendChild(name); + text.appendChild(meta); + info.appendChild(thumb); + info.appendChild(text); + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'remove-attachment-button'; + removeButton.textContent = 'Remove'; + + removeButton.addEventListener('click', () => { + clearSelectedAttachment(); + elements.messageInput.focus(); + }); + + elements.selectedAttachmentPreview.appendChild(info); + elements.selectedAttachmentPreview.appendChild(removeButton); +} + +async function sendSelectedAttachment(caption) { + if (!state.selectedAttachment) { + return; + } + + const { file } = state.selectedAttachment; + + if (file.size > MAX_ATTACHMENT_BYTES) { + showAlert(`File is too large. Maximum size is ${formatFileSize(MAX_ATTACHMENT_BYTES)}.`, 'warning'); + return; + } + + if (!isAllowedAttachment(file)) { + showAlert('This file type is not allowed.', 'warning'); + return; + } + + try { + clearLocalTypingStateBeforeSend(); + + const contentBase64 = await readFileAsBase64(file); + const clientMessageId = createClientMessageId(); + + addPendingOwnFileMessage(file, clientMessageId, caption); + + sendEnvelope('message.file', { + scope: 'global', + clientMessageId, + caption, + attachment: { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + contentBase64, + }, + }); + + elements.messageInput.value = ''; + clearSelectedAttachment(); + elements.messageInput.focus(); + } catch (error) { + showAlert(error instanceof Error ? error.message : 'Failed to send file.', 'danger'); + } +} + +function isAllowedAttachment(file) { + return ALLOWED_ATTACHMENT_MIME_TYPES.includes(file.type); +} + +function formatFileSize(sizeBytes) { + if (sizeBytes < 1024) { + return `${sizeBytes} B`; + } + + if (sizeBytes < 1024 * 1024) { + return `${(sizeBytes / 1024).toFixed(1)} KB`; + } + + return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`; +} + +function readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener('load', () => { + const result = reader.result; + + if (typeof result !== 'string') { + reject(new Error('Failed to read file.')); + return; + } + + const commaIndex = result.indexOf(','); + + if (commaIndex === -1) { + reject(new Error('Failed to parse file content.')); + return; + } + + resolve(result.slice(commaIndex + 1)); + }); + + reader.addEventListener('error', () => { + reject(new Error('Failed to read file.')); + }); + + reader.readAsDataURL(file); + }); +} + function setJoinFormEnabled(enabled) { elements.displayNameInput.disabled = !enabled; elements.serverUrlInput.disabled = !enabled; @@ -576,14 +939,35 @@ function setStatus(label, mode) { elements.connectionStatus.classList.add(`status-${mode}`); } -function showAlert(message, type) { - elements.alertBox.textContent = message; +function showAlert(message, type = 'danger', autoDismissMs = 5000) { + if (alertDismissTimer) { + window.clearTimeout(alertDismissTimer); + alertDismissTimer = null; + } + elements.alertBox.className = `alert app-alert alert-${type}`; + elements.alertBox.textContent = message; + elements.alertBox.classList.remove('d-none'); + + if (autoDismissMs > 0) { + alertDismissTimer = window.setTimeout(() => { + hideAlert(); + }, autoDismissMs); + } } function clearAlert() { + hideAlert(); +} + +function hideAlert() { + if (alertDismissTimer) { + window.clearTimeout(alertDismissTimer); + alertDismissTimer = null; + } + + elements.alertBox.classList.add('d-none'); elements.alertBox.textContent = ''; - elements.alertBox.className = 'alert app-alert d-none'; } function renderUsers() { @@ -725,9 +1109,7 @@ function addMessage(message) { status.className = 'message-status'; updateStatusElement(status, isOwn ? message.status || 'received' : 'received'); - const bubble = document.createElement('div'); - bubble.className = 'message-bubble'; - bubble.textContent = message.body || ''; + const bubble = createMessageBodyElement(message); footer.appendChild(meta); footer.appendChild(status); @@ -739,6 +1121,79 @@ function addMessage(message) { elements.messagesList.scrollTop = elements.messagesList.scrollHeight; } +function createMessageBodyElement(message) { + if (message.kind === 'file') { + return createFileMessageElement(message.body || {}); + } + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + bubble.textContent = message.body || ''; + + return bubble; +} + +function createFileMessageElement(body) { + const card = document.createElement('div'); + card.className = 'message-bubble file-message'; + + const fileName = typeof body.fileName === 'string' ? body.fileName : 'Attachment'; + const mimeType = typeof body.mimeType === 'string' ? body.mimeType : 'application/octet-stream'; + const sizeBytes = typeof body.sizeBytes === 'number' ? body.sizeBytes : 0; + const previewDataUrl = typeof body.previewDataUrl === 'string' ? body.previewDataUrl : null; + const downloadUrl = typeof body.downloadDataUrl === 'string' ? body.downloadDataUrl : previewDataUrl; + const caption = typeof body.caption === 'string' ? body.caption.trim() : ''; + + if (previewDataUrl && mimeType.startsWith('image/')) { + const image = document.createElement('img'); + image.className = 'file-message-preview'; + image.src = previewDataUrl; + image.alt = fileName; + card.appendChild(image); + } + + const info = document.createElement('div'); + info.className = 'file-message-info'; + + const icon = document.createElement('span'); + icon.className = 'file-message-icon'; + icon.textContent = mimeType.startsWith('image/') ? 'IMG' : mimeType === 'application/pdf' ? 'PDF' : 'TXT'; + + const text = document.createElement('div'); + + const name = document.createElement('strong'); + name.textContent = fileName; + + const meta = document.createElement('small'); + meta.textContent = `${mimeType} - ${formatFileSize(sizeBytes)}`; + + text.appendChild(name); + text.appendChild(meta); + + info.appendChild(icon); + info.appendChild(text); + card.appendChild(info); + + if (caption) { + const captionElement = document.createElement('p'); + captionElement.className = 'file-message-caption'; + captionElement.textContent = caption; + card.appendChild(captionElement); + } + + if (downloadUrl) { + const downloadLink = document.createElement('a'); + downloadLink.className = 'file-download-button'; + downloadLink.href = downloadUrl; + downloadLink.download = fileName; + downloadLink.textContent = 'Download'; + downloadLink.rel = 'noopener'; + card.appendChild(downloadLink); + } + + return card; +} + function findDisplayName(userId) { const user = state.users.get(userId); diff --git a/examples/medium-chat/public/assets/style.css b/examples/medium-chat/public/assets/style.css index 636dab8..dab3a5c 100644 --- a/examples/medium-chat/public/assets/style.css +++ b/examples/medium-chat/public/assets/style.css @@ -403,12 +403,298 @@ body { } .message-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; + display: block; gap: 12px; padding: 18px; border-top: 1px solid rgba(165, 180, 252, 0.14); background: rgba(3, 7, 18, 0.36); + position: relative; + overflow: visible; + z-index: 30; +} + +.composer-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; +} + +.composer-actions { + position: relative; + display: flex; + align-items: center; + z-index: 31; +} + +.composer-actions-button { + width: 46px; + height: 46px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 16px; + background: rgba(15, 23, 42, 0.86); + color: #e2e8f0; + font-size: 1.35rem; + font-weight: 900; + line-height: 1; + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; +} + +.composer-actions-button:hover { + transform: translateY(-1px); + border-color: rgba(103, 232, 249, 0.5); + background: rgba(30, 41, 59, 0.95); +} + +.composer-actions-menu, +.emoji-picker { + position: absolute; + left: 0; + bottom: calc(100% + 12px); + z-index: 20; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(18px); +} + +.composer-actions-menu { + width: 190px; + padding: 8px; + display: grid; + gap: 6px; +} + +.composer-actions-menu.d-none, +.emoji-picker.d-none { + display: none; +} + +.composer-action-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: 0; + border-radius: 14px; + padding: 11px 12px; + background: transparent; + color: #e2e8f0; + text-align: left; +} + +.composer-action-item:hover { + background: rgba(103, 232, 249, 0.12); +} + +.composer-action-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: 0 0 auto; + color: #e2e8f0; +} + +.composer-action-icon svg { + width: 22px; + height: 22px; + fill: currentColor; +} + +.composer-action-label { + color: #e2e8f0; + font-weight: 800; +} + +.emoji-picker { + width: 292px; + max-height: 220px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + padding: 12px; + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; +} + +.emoji-button { + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; + border: 0; + border-radius: 12px; + background: transparent; + font-size: 1.25rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.emoji-button:hover { + background: rgba(103, 232, 249, 0.16); +} + +.file-message { + display: grid; + gap: 10px; + min-width: min(320px, 100%); +} + +.file-message-preview { + width: 100%; + max-width: 340px; + max-height: 220px; + border-radius: 14px; + object-fit: cover; + border: 1px solid rgba(148, 163, 184, 0.16); +} + +.file-message-info { + display: flex; + align-items: center; + gap: 12px; +} + +.file-message-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.36); + font-size: 1.25rem; +} + +.file-message-info strong, +.file-message-info small { + display: block; +} + +.file-message-info strong { + color: #f8fafc; + font-size: 0.92rem; + word-break: break-word; +} + +.file-message-info small { + color: #94a3b8; + margin-top: 2px; + font-size: 0.76rem; +} + +.file-download-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + margin-top: 8px; + padding: 8px 12px; + border-radius: 12px; + background: rgba(103, 232, 249, 0.14); + color: #cffafe; + font-size: 0.82rem; + font-weight: 900; + text-decoration: none; +} + +.file-download-button:hover { + background: rgba(103, 232, 249, 0.22); + color: #ecfeff; +} + +.file-message-caption { + margin: 2px 0 0; + color: #e2e8f0; + font-size: 0.92rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.selected-attachment-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + width: 100%; + margin-bottom: 10px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + background: rgba(15, 23, 42, 0.82); +} + +.selected-attachment-preview.d-none { + display: none; +} + +.selected-attachment-info { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.selected-attachment-thumb { + display: inline-flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + border-radius: 14px; + background: rgba(30, 41, 59, 0.9); + overflow: hidden; + flex: 0 0 auto; + color: #e2e8f0; + font-weight: 900; +} + +.selected-attachment-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.selected-attachment-text { + min-width: 0; +} + +.selected-attachment-text strong, +.selected-attachment-text small { + display: block; +} + +.selected-attachment-text strong { + color: #f8fafc; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selected-attachment-text small { + color: #94a3b8; + margin-top: 2px; + font-size: 0.76rem; +} + +.remove-attachment-button { + border: 0; + border-radius: 12px; + padding: 8px 10px; + background: rgba(248, 113, 113, 0.14); + color: #fecaca; + font-weight: 900; +} + +.remove-attachment-button:hover { + background: rgba(248, 113, 113, 0.24); } .event-log { @@ -505,7 +791,7 @@ body { min-height: 620px; } - .message-form { + .composer-row { grid-template-columns: 1fr; } } diff --git a/examples/medium-chat/public/index.html b/examples/medium-chat/public/index.html index 1cb10bc..b53fe1c 100644 --- a/examples/medium-chat/public/index.html +++ b/examples/medium-chat/public/index.html @@ -73,8 +73,40 @@

Customizable realtime flow

- - +
+ +
+
+ + + + + + + +
+ + + +
diff --git a/examples/medium-chat/server.php b/examples/medium-chat/server.php index f736f44..ed82d98 100644 --- a/examples/medium-chat/server.php +++ b/examples/medium-chat/server.php @@ -20,7 +20,7 @@ echo "Press Ctrl+C to stop the WebSocket server.\n\n"; $server = ChatServer::create( - ServerConfig::new(host: $host, port: $port), + ServerConfig::new(host: $host, port: $port, maxPayloadBytes: 4 * 1024 * 1024), ChatConfig::new(), ); @@ -76,7 +76,9 @@ return; } - echo "[chat.message.received] scope={$scope} room={$message->roomId} from={$message->fromUserId}: {$message->body}\n"; + $body = is_string($message->body) ? $message->body : '[file attachment]'; + + echo "[chat.message.received] scope={$scope} room={$message->roomId} from={$message->fromUserId}: {$body}\n"; }); $server->on('room.created', function (array $event): void { diff --git a/examples/private-chat/README.md b/examples/private-chat/README.md index 0208f19..beeebda 100644 --- a/examples/private-chat/README.md +++ b/examples/private-chat/README.md @@ -118,6 +118,65 @@ This example still uses in-memory storage by default. The package now includes optional storage adapters and migrations, but the official CLI/config workflow is added in a later phase. +## Composer actions + +The message input includes a left-side action button. + +It opens: + +- Emoji picker. +- File picker. + +Allowed files: + +```txt +image/png +image/jpeg +image/gif +application/pdf +text/plain +``` + +Default max size: + +```txt +2 MB +``` + +Files follow the active conversation context: + +- Global Room sends files to everyone. +- Direct conversations send files only to the selected user. +- Private group rooms send files only to room members. + +All user-provided text continues to be rendered safely. + +## Attachment composer behavior + +Selecting a file does not send it immediately. + +The selected file appears as a pending attachment in the composer. The user can add a text caption and click `Send`. + +Supported files: + +```txt +image/png +image/jpeg +image/gif +application/pdf +text/plain +``` + +Default max file size: + +```txt +2 MB +``` + +Each delivered file message includes a download button. + +Attachments are transported as JSON text-frame envelopes with base64 content. The chat core does not accept binary WebSocket frames for chat messages in this version. + ## Important notes This phase implements direct 1:1 private messaging and private group rooms. diff --git a/examples/private-chat/public/assets/app.js b/examples/private-chat/public/assets/app.js index 7def79a..01fdbc2 100644 --- a/examples/private-chat/public/assets/app.js +++ b/examples/private-chat/public/assets/app.js @@ -12,6 +12,7 @@ const state = { messageElements: new Map(), messageReadBy: new Map(), unreadCounts: new Map(), + selectedAttachment: null, isTyping: false, typingStopTimer: null, lastTypingStartSentAt: 0, @@ -28,16 +29,35 @@ state.conversations.set('global', { roomId: 'global', }); +const EMOJIS = [ + '\u{1F600}', '\u{1F603}', '\u{1F604}', '\u{1F601}', '\u{1F606}', '\u{1F605}', '\u{1F602}', '\u{1F642}', + '\u{1F60D}', '\u{1F618}', '\u{1F60E}', '\u{1F914}', '\u{1F44D}', '\u{1F44F}', '\u{1F64C}', '\u{1F525}', + '\u2764\uFE0F', '\u{1F680}', '\u{1F389}', '\u2728', '\u{1F4A1}', '\u2705', '\u{1F4CC}', '\u{1F4E6}', +]; + +const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024; +const ALLOWED_ATTACHMENT_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'application/pdf', + 'text/plain', +]; + const elements = { alertBox: document.getElementById('alertBox'), chatPanel: document.getElementById('chatPanel'), closePrivateRoomModalButton: document.getElementById('closePrivateRoomModalButton'), + composerActionsButton: document.getElementById('composerActionsButton'), + composerActionsMenu: document.getElementById('composerActionsMenu'), connectionStatus: document.getElementById('connectionStatus'), conversationEyebrow: document.getElementById('conversationEyebrow'), conversationTitle: document.getElementById('conversationTitle'), createPrivateRoomButton: document.getElementById('createPrivateRoomButton'), currentDisplayName: document.getElementById('currentDisplayName'), displayNameInput: document.getElementById('displayNameInput'), + emojiPicker: document.getElementById('emojiPicker'), + fileInput: document.getElementById('fileInput'), globalRoomButton: document.getElementById('globalRoomButton'), groupRoomsList: document.getElementById('groupRoomsList'), joinButton: document.getElementById('joinButton'), @@ -48,15 +68,20 @@ const elements = { messagesList: document.getElementById('messagesList'), newPrivateRoomButton: document.getElementById('newPrivateRoomButton'), onlineCount: document.getElementById('onlineCount'), + openEmojiButton: document.getElementById('openEmojiButton'), + openFileButton: document.getElementById('openFileButton'), privateRoomForm: document.getElementById('privateRoomForm'), privateRoomModal: document.getElementById('privateRoomModal'), privateRoomNameInput: document.getElementById('privateRoomNameInput'), privateRoomUsersList: document.getElementById('privateRoomUsersList'), serverUrlInput: document.getElementById('serverUrlInput'), + selectedAttachmentPreview: document.getElementById('selectedAttachmentPreview'), typingIndicator: document.getElementById('typingIndicator'), usersList: document.getElementById('usersList'), }; +let alertDismissTimer = null; + elements.joinForm.addEventListener('submit', (event) => { event.preventDefault(); @@ -94,13 +119,14 @@ elements.privateRoomForm.addEventListener('submit', (event) => { createPrivateRoomFromForm(); }); -elements.messageForm.addEventListener('submit', (event) => { +elements.messageForm.addEventListener('submit', async (event) => { event.preventDefault(); const text = elements.messageInput.value.trim(); + const selectedAttachment = state.selectedAttachment; const conversation = state.conversations.get(state.activeConversationId); - if (!text) { + if (!text && !selectedAttachment) { stopTyping(); return; } @@ -110,6 +136,11 @@ elements.messageForm.addEventListener('submit', (event) => { return; } + if (selectedAttachment) { + await sendSelectedAttachment(text); + return; + } + const clientMessageId = createClientMessageId(); clearLocalTypingStateBeforeSend(); @@ -143,6 +174,37 @@ elements.messageInput.addEventListener('blur', () => { stopTyping(); }); +elements.composerActionsButton.addEventListener('click', () => { + toggleComposerActionsMenu(); +}); + +elements.openEmojiButton.addEventListener('click', () => { + closeComposerActionsMenu(); + toggleEmojiPicker(); +}); + +elements.openFileButton.addEventListener('click', () => { + closeComposerActionsMenu(); + elements.fileInput.click(); +}); + +elements.fileInput.addEventListener('change', () => { + handleSelectedFile(); +}); + +document.addEventListener('click', (event) => { + const target = event.target; + + if (!(target instanceof Element)) { + return; + } + + if (!target.closest('.composer-actions')) { + closeComposerActionsMenu(); + closeEmojiPicker(); + } +}); + window.addEventListener('beforeunload', () => { stopTyping(); @@ -151,6 +213,7 @@ window.addEventListener('beforeunload', () => { } }); +renderEmojiPicker(); renderEmptyMessages(); renderTypingIndicator(); renderConversationHeader(); @@ -255,6 +318,14 @@ function handleServerMessage(rawMessage) { handleMessageRead(envelope.payload); break; + case 'attachment.accepted': + handleAttachmentAccepted(envelope.payload); + break; + + case 'attachment.rejected': + handleAttachmentRejected(envelope.payload); + break; + case 'room.created': handleRoomCreated(envelope.payload); break; @@ -470,6 +541,18 @@ function handleServerError(payload) { showAlert(message, 'danger'); } +function handleAttachmentAccepted(payload) { + void payload; +} + +function handleAttachmentRejected(payload) { + const message = payload && typeof payload.message === 'string' + ? payload.message + : 'Attachment was rejected.'; + + showAlert(message, 'warning'); +} + function sendEnvelope(type, payload) { if (!state.socket || state.socket.readyState !== WebSocket.OPEN) { showAlert('WebSocket connection is not open.', 'danger'); @@ -879,6 +962,33 @@ function addPendingOwnMessage(text, clientMessageId, conversationId) { addMessage(message, conversationId); } +function addPendingOwnFileMessage(file, clientMessageId, conversationId, caption = '') { + const conversation = state.conversations.get(conversationId); + const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null; + const downloadUrl = URL.createObjectURL(file); + const message = { + id: clientMessageId, + roomId: conversation && conversation.roomId ? conversation.roomId : 'global', + fromUserId: state.currentUser ? state.currentUser.userId : null, + kind: 'file', + body: { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + previewDataUrl: previewUrl, + downloadDataUrl: downloadUrl, + caption, + }, + metadata: { clientMessageId }, + createdAt: new Date().toISOString(), + status: 'sent', + }; + + state.pendingMessages.set(clientMessageId, message); + state.pendingMessageConversations.set(clientMessageId, conversationId); + addMessage(message, conversationId); +} + function addMessage(message, conversationId) { messagesForConversation(conversationId).push(message); @@ -1053,6 +1163,9 @@ function resetToLogin(keepDisplayName) { state.messageReadBy.clear(); state.unreadCounts.clear(); clearTypingState(); + closeComposerActionsMenu(); + closeEmojiPicker(); + clearSelectedAttachment(); elements.chatPanel.classList.add('d-none'); elements.loginPanel.classList.remove('d-none'); @@ -1069,6 +1182,278 @@ function resetToLogin(keepDisplayName) { renderMessages(); } +function toggleComposerActionsMenu() { + const isHidden = elements.composerActionsMenu.classList.contains('d-none'); + + elements.composerActionsMenu.classList.toggle('d-none', !isHidden); + elements.composerActionsMenu.setAttribute('aria-hidden', isHidden ? 'false' : 'true'); + + closeEmojiPicker(); +} + +function closeComposerActionsMenu() { + elements.composerActionsMenu.classList.add('d-none'); + elements.composerActionsMenu.setAttribute('aria-hidden', 'true'); +} + +function toggleEmojiPicker() { + const isHidden = elements.emojiPicker.classList.contains('d-none'); + + elements.emojiPicker.classList.toggle('d-none', !isHidden); + elements.emojiPicker.setAttribute('aria-hidden', isHidden ? 'false' : 'true'); +} + +function closeEmojiPicker() { + elements.emojiPicker.classList.add('d-none'); + elements.emojiPicker.setAttribute('aria-hidden', 'true'); +} + +function renderEmojiPicker() { + elements.emojiPicker.replaceChildren(); + + for (const emoji of EMOJIS) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'emoji-button'; + button.textContent = emoji; + button.title = emoji; + + button.addEventListener('click', () => { + insertAtCursor(elements.messageInput, emoji); + closeEmojiPicker(); + }); + + elements.emojiPicker.appendChild(button); + } +} + +function insertAtCursor(input, value) { + const start = input.selectionStart || 0; + const end = input.selectionEnd || 0; + const before = input.value.slice(0, start); + const after = input.value.slice(end); + + input.value = `${before}${value}${after}`; + input.selectionStart = start + value.length; + input.selectionEnd = start + value.length; + input.focus(); + + handleTypingInput(); +} + +function handleSelectedFile() { + const file = elements.fileInput.files && elements.fileInput.files[0] + ? elements.fileInput.files[0] + : null; + + elements.fileInput.value = ''; + + if (!file) { + return; + } + + if (file.size > MAX_ATTACHMENT_BYTES) { + showAlert(`File is too large. Maximum size is ${formatFileSize(MAX_ATTACHMENT_BYTES)}.`, 'warning'); + return; + } + + if (!isAllowedAttachment(file)) { + showAlert('This file type is not allowed.', 'warning'); + return; + } + + setSelectedAttachment(file); +} + +function setSelectedAttachment(file) { + clearSelectedAttachment(); + + const previewUrl = file.type.startsWith('image/') + ? URL.createObjectURL(file) + : null; + + state.selectedAttachment = { file, previewUrl }; + + renderSelectedAttachmentPreview(); +} + +function clearSelectedAttachment() { + if (state.selectedAttachment && state.selectedAttachment.previewUrl) { + URL.revokeObjectURL(state.selectedAttachment.previewUrl); + } + + state.selectedAttachment = null; + renderSelectedAttachmentPreview(); +} + +function renderSelectedAttachmentPreview() { + elements.selectedAttachmentPreview.replaceChildren(); + + if (!state.selectedAttachment) { + elements.selectedAttachmentPreview.classList.add('d-none'); + return; + } + + const { file, previewUrl } = state.selectedAttachment; + + elements.selectedAttachmentPreview.classList.remove('d-none'); + + const info = document.createElement('div'); + info.className = 'selected-attachment-info'; + + const thumb = document.createElement('span'); + thumb.className = 'selected-attachment-thumb'; + + if (previewUrl) { + const image = document.createElement('img'); + image.src = previewUrl; + image.alt = file.name; + thumb.appendChild(image); + } else { + thumb.textContent = file.type === 'application/pdf' ? 'PDF' : 'TXT'; + } + + const text = document.createElement('div'); + text.className = 'selected-attachment-text'; + + const name = document.createElement('strong'); + name.textContent = file.name; + + const meta = document.createElement('small'); + meta.textContent = `${file.type || 'unknown'} - ${formatFileSize(file.size)}`; + + text.appendChild(name); + text.appendChild(meta); + info.appendChild(thumb); + info.appendChild(text); + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'remove-attachment-button'; + removeButton.textContent = 'Remove'; + + removeButton.addEventListener('click', () => { + clearSelectedAttachment(); + elements.messageInput.focus(); + }); + + elements.selectedAttachmentPreview.appendChild(info); + elements.selectedAttachmentPreview.appendChild(removeButton); +} + +async function sendSelectedAttachment(caption) { + if (!state.selectedAttachment) { + return; + } + + const { file } = state.selectedAttachment; + const conversationId = state.activeConversationId; + + if (file.size > MAX_ATTACHMENT_BYTES) { + showAlert(`File is too large. Maximum size is ${formatFileSize(MAX_ATTACHMENT_BYTES)}.`, 'warning'); + return; + } + + if (!isAllowedAttachment(file)) { + showAlert('This file type is not allowed.', 'warning'); + return; + } + + try { + clearLocalTypingStateBeforeSend(); + + const contentBase64 = await readFileAsBase64(file); + const clientMessageId = createClientMessageId(); + + addPendingOwnFileMessage(file, clientMessageId, conversationId, caption); + sendEnvelope('message.file', fileMessagePayloadForActiveConversation(file, clientMessageId, contentBase64, caption)); + + elements.messageInput.value = ''; + clearSelectedAttachment(); + elements.messageInput.focus(); + } catch (error) { + showAlert(error instanceof Error ? error.message : 'Failed to send file.', 'danger'); + } +} + +function fileMessagePayloadForActiveConversation(file, clientMessageId, contentBase64, caption) { + const conversation = state.conversations.get(state.activeConversationId); + const attachment = { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + contentBase64, + }; + + if (!conversation || conversation.type === 'global') { + return { scope: 'global', clientMessageId, caption, attachment }; + } + + if (conversation.type === 'direct') { + return { + scope: 'direct', + toUserId: conversation.targetUserId, + clientMessageId, + caption, + attachment, + }; + } + + return { + scope: 'room', + roomId: conversation.roomId, + clientMessageId, + caption, + attachment, + }; +} + +function isAllowedAttachment(file) { + return ALLOWED_ATTACHMENT_MIME_TYPES.includes(file.type); +} + +function formatFileSize(sizeBytes) { + if (sizeBytes < 1024) { + return `${sizeBytes} B`; + } + + if (sizeBytes < 1024 * 1024) { + return `${(sizeBytes / 1024).toFixed(1)} KB`; + } + + return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`; +} + +function readFileAsBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener('load', () => { + const result = reader.result; + + if (typeof result !== 'string') { + reject(new Error('Failed to read file.')); + return; + } + + const commaIndex = result.indexOf(','); + + if (commaIndex === -1) { + reject(new Error('Failed to parse file content.')); + return; + } + + resolve(result.slice(commaIndex + 1)); + }); + + reader.addEventListener('error', () => { + reject(new Error('Failed to read file.')); + }); + + reader.readAsDataURL(file); + }); +} + function setJoinFormEnabled(enabled) { elements.displayNameInput.disabled = !enabled; elements.serverUrlInput.disabled = !enabled; @@ -1082,14 +1467,35 @@ function setStatus(label, mode) { elements.connectionStatus.classList.add(`status-${mode}`); } -function showAlert(message, type) { - elements.alertBox.textContent = message; +function showAlert(message, type = 'danger', autoDismissMs = 5000) { + if (alertDismissTimer) { + window.clearTimeout(alertDismissTimer); + alertDismissTimer = null; + } + elements.alertBox.className = `alert app-alert alert-${type}`; + elements.alertBox.textContent = message; + elements.alertBox.classList.remove('d-none'); + + if (autoDismissMs > 0) { + alertDismissTimer = window.setTimeout(() => { + hideAlert(); + }, autoDismissMs); + } } function clearAlert() { + hideAlert(); +} + +function hideAlert() { + if (alertDismissTimer) { + window.clearTimeout(alertDismissTimer); + alertDismissTimer = null; + } + + elements.alertBox.classList.add('d-none'); elements.alertBox.textContent = ''; - elements.alertBox.className = 'alert app-alert d-none'; } function renderUsers() { @@ -1193,9 +1599,7 @@ function appendMessageElement(message) { status.className = 'message-status'; updateStatusElement(status, isOwn ? message.status || 'received' : 'received'); - const bubble = document.createElement('div'); - bubble.className = 'message-bubble'; - bubble.textContent = message.body || ''; + const bubble = createMessageBodyElement(message); footer.appendChild(meta); footer.appendChild(status); @@ -1206,6 +1610,79 @@ function appendMessageElement(message) { elements.messagesList.appendChild(row); } +function createMessageBodyElement(message) { + if (message.kind === 'file') { + return createFileMessageElement(message.body || {}); + } + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + bubble.textContent = message.body || ''; + + return bubble; +} + +function createFileMessageElement(body) { + const card = document.createElement('div'); + card.className = 'message-bubble file-message'; + + const fileName = typeof body.fileName === 'string' ? body.fileName : 'Attachment'; + const mimeType = typeof body.mimeType === 'string' ? body.mimeType : 'application/octet-stream'; + const sizeBytes = typeof body.sizeBytes === 'number' ? body.sizeBytes : 0; + const previewDataUrl = typeof body.previewDataUrl === 'string' ? body.previewDataUrl : null; + const downloadUrl = typeof body.downloadDataUrl === 'string' ? body.downloadDataUrl : previewDataUrl; + const caption = typeof body.caption === 'string' ? body.caption.trim() : ''; + + if (previewDataUrl && mimeType.startsWith('image/')) { + const image = document.createElement('img'); + image.className = 'file-message-preview'; + image.src = previewDataUrl; + image.alt = fileName; + card.appendChild(image); + } + + const info = document.createElement('div'); + info.className = 'file-message-info'; + + const icon = document.createElement('span'); + icon.className = 'file-message-icon'; + icon.textContent = mimeType.startsWith('image/') ? 'IMG' : mimeType === 'application/pdf' ? 'PDF' : 'TXT'; + + const text = document.createElement('div'); + + const name = document.createElement('strong'); + name.textContent = fileName; + + const meta = document.createElement('small'); + meta.textContent = `${mimeType} - ${formatFileSize(sizeBytes)}`; + + text.appendChild(name); + text.appendChild(meta); + + info.appendChild(icon); + info.appendChild(text); + card.appendChild(info); + + if (caption) { + const captionElement = document.createElement('p'); + captionElement.className = 'file-message-caption'; + captionElement.textContent = caption; + card.appendChild(captionElement); + } + + if (downloadUrl) { + const downloadLink = document.createElement('a'); + downloadLink.className = 'file-download-button'; + downloadLink.href = downloadUrl; + downloadLink.download = fileName; + downloadLink.textContent = 'Download'; + downloadLink.rel = 'noopener'; + card.appendChild(downloadLink); + } + + return card; +} + function renderTypingIndicator() { const names = [...typingUsersForConversation(state.activeConversationId).values()]; diff --git a/examples/private-chat/public/assets/style.css b/examples/private-chat/public/assets/style.css index 943be9e..6bd4686 100644 --- a/examples/private-chat/public/assets/style.css +++ b/examples/private-chat/public/assets/style.css @@ -464,12 +464,298 @@ body { } .message-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; + display: block; gap: 12px; padding: 18px; border-top: 1px solid rgba(148, 163, 184, 0.14); background: rgba(3, 7, 18, 0.36); + position: relative; + overflow: visible; + z-index: 30; +} + +.composer-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; +} + +.composer-actions { + position: relative; + display: flex; + align-items: center; + z-index: 31; +} + +.composer-actions-button { + width: 46px; + height: 46px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 16px; + background: rgba(15, 23, 42, 0.86); + color: #e2e8f0; + font-size: 1.35rem; + font-weight: 900; + line-height: 1; + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; +} + +.composer-actions-button:hover { + transform: translateY(-1px); + border-color: rgba(34, 197, 94, 0.5); + background: rgba(30, 41, 59, 0.95); +} + +.composer-actions-menu, +.emoji-picker { + position: absolute; + left: 0; + bottom: calc(100% + 12px); + z-index: 20; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(18px); +} + +.composer-actions-menu { + width: 190px; + padding: 8px; + display: grid; + gap: 6px; +} + +.composer-actions-menu.d-none, +.emoji-picker.d-none { + display: none; +} + +.composer-action-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + border: 0; + border-radius: 14px; + padding: 11px 12px; + background: transparent; + color: #e2e8f0; + text-align: left; +} + +.composer-action-item:hover { + background: rgba(34, 197, 94, 0.12); +} + +.composer-action-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: 0 0 auto; + color: #e2e8f0; +} + +.composer-action-icon svg { + width: 22px; + height: 22px; + fill: currentColor; +} + +.composer-action-label { + color: #e2e8f0; + font-weight: 800; +} + +.emoji-picker { + width: 292px; + max-height: 220px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + padding: 12px; + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; +} + +.emoji-button { + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; + border: 0; + border-radius: 12px; + background: transparent; + font-size: 1.25rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.emoji-button:hover { + background: rgba(34, 197, 94, 0.16); +} + +.file-message { + display: grid; + gap: 10px; + min-width: min(320px, 100%); +} + +.file-message-preview { + width: 100%; + max-width: 340px; + max-height: 220px; + border-radius: 14px; + object-fit: cover; + border: 1px solid rgba(148, 163, 184, 0.16); +} + +.file-message-info { + display: flex; + align-items: center; + gap: 12px; +} + +.file-message-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.36); + font-size: 1.25rem; +} + +.file-message-info strong, +.file-message-info small { + display: block; +} + +.file-message-info strong { + color: #f8fafc; + font-size: 0.92rem; + word-break: break-word; +} + +.file-message-info small { + color: #94a3b8; + margin-top: 2px; + font-size: 0.76rem; +} + +.file-download-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + margin-top: 8px; + padding: 8px 12px; + border-radius: 12px; + background: rgba(34, 197, 94, 0.14); + color: #bbf7d0; + font-size: 0.82rem; + font-weight: 900; + text-decoration: none; +} + +.file-download-button:hover { + background: rgba(34, 197, 94, 0.22); + color: #dcfce7; +} + +.file-message-caption { + margin: 2px 0 0; + color: #e2e8f0; + font-size: 0.92rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.selected-attachment-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + width: 100%; + margin-bottom: 10px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + background: rgba(15, 23, 42, 0.82); +} + +.selected-attachment-preview.d-none { + display: none; +} + +.selected-attachment-info { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.selected-attachment-thumb { + display: inline-flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + border-radius: 14px; + background: rgba(30, 41, 59, 0.9); + overflow: hidden; + flex: 0 0 auto; + color: #e2e8f0; + font-weight: 900; +} + +.selected-attachment-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.selected-attachment-text { + min-width: 0; +} + +.selected-attachment-text strong, +.selected-attachment-text small { + display: block; +} + +.selected-attachment-text strong { + color: #f8fafc; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selected-attachment-text small { + color: #94a3b8; + margin-top: 2px; + font-size: 0.76rem; +} + +.remove-attachment-button { + border: 0; + border-radius: 12px; + padding: 8px 10px; + background: rgba(248, 113, 113, 0.14); + color: #fecaca; + font-weight: 900; +} + +.remove-attachment-button:hover { + background: rgba(248, 113, 113, 0.24); } .info-panel { @@ -630,7 +916,7 @@ body { min-height: 620px; } - .message-form { + .composer-row { grid-template-columns: 1fr; } } diff --git a/examples/private-chat/public/index.html b/examples/private-chat/public/index.html index 74fbe9b..e1d8c3f 100644 --- a/examples/private-chat/public/index.html +++ b/examples/private-chat/public/index.html @@ -85,8 +85,40 @@

Global Room

- - +
+ +
+
+ + + + + + + +
+ + + +
diff --git a/examples/private-chat/server.php b/examples/private-chat/server.php index f5fa244..8ec7a27 100644 --- a/examples/private-chat/server.php +++ b/examples/private-chat/server.php @@ -19,7 +19,7 @@ echo "Press Ctrl+C to stop the WebSocket server.\n\n"; $server = ChatServer::create( - ServerConfig::new(host: $host, port: $port), + ServerConfig::new(host: $host, port: $port, maxPayloadBytes: 4 * 1024 * 1024), ChatConfig::new(), ); @@ -75,7 +75,9 @@ return; } - echo "[private.message.received] scope={$scope} room={$message->roomId} from={$message->fromUserId}: {$message->body}\n"; + $body = is_string($message->body) ? $message->body : '[file attachment]'; + + echo "[private.message.received] scope={$scope} room={$message->roomId} from={$message->fromUserId}: {$body}\n"; }); $server->run(); diff --git a/src/Chat/Attachment.php b/src/Chat/Attachment.php new file mode 100644 index 0000000..211ba08 --- /dev/null +++ b/src/Chat/Attachment.php @@ -0,0 +1,65 @@ + $metadata + */ + public function __construct( + public string $id, + public string $messageId, + public string $fileName, + public string $mimeType, + public int $sizeBytes, + public string $path, + public DateTimeImmutable $createdAt, + public array $metadata = [], + ) { + } + + /** + * @param array $metadata + */ + public static function new( + string $messageId, + string $fileName, + string $mimeType, + int $sizeBytes, + string $path, + array $metadata = [], + ): self { + return new self( + id: 'att_' . bin2hex(random_bytes(16)), + messageId: $messageId, + fileName: $fileName, + mimeType: $mimeType, + sizeBytes: $sizeBytes, + path: $path, + createdAt: new DateTimeImmutable(), + metadata: $metadata, + ); + } + + /** + * @return array + */ + public function publicPayload(): array + { + return [ + 'id' => $this->id, + 'messageId' => $this->messageId, + 'fileName' => $this->fileName, + 'mimeType' => $this->mimeType, + 'sizeBytes' => $this->sizeBytes, + 'path' => $this->path, + 'createdAt' => $this->createdAt->format(DATE_ATOM), + 'metadata' => $this->metadata, + ]; + } +} diff --git a/src/Chat/AttachmentValidator.php b/src/Chat/AttachmentValidator.php new file mode 100644 index 0000000..18e1d01 --- /dev/null +++ b/src/Chat/AttachmentValidator.php @@ -0,0 +1,98 @@ + $this->config->maxAttachmentFileNameLength) { + throw new InvalidPayloadException('Attachment fileName is too long.'); + } + + return $fileName; + } + + public function mimeType(mixed $value): string + { + if (!is_string($value)) { + throw new InvalidPayloadException('Attachment mimeType is required.'); + } + + $mimeType = strtolower(trim($value)); + + if (!in_array($mimeType, $this->config->allowedAttachmentMimeTypes, true)) { + throw new InvalidPayloadException('Attachment mimeType is not allowed.'); + } + + return $mimeType; + } + + public function sizeBytes(mixed $value): int + { + if (!is_int($value)) { + throw new InvalidPayloadException('Attachment sizeBytes is required.'); + } + + if ($value <= 0) { + throw new InvalidPayloadException('Attachment sizeBytes must be greater than zero.'); + } + + if ($value > $this->config->maxAttachmentBytes) { + throw new InvalidPayloadException('Attachment exceeds the maximum allowed size.'); + } + + return $value; + } + + public function decodedContent(mixed $value, int $expectedSizeBytes): string + { + if (!is_string($value) || trim($value) === '') { + throw new InvalidPayloadException('Attachment contentBase64 is required.'); + } + + $decoded = base64_decode($value, true); + + if (!is_string($decoded)) { + throw new InvalidPayloadException('Attachment contentBase64 is invalid.'); + } + + $actualSize = strlen($decoded); + + if ($actualSize !== $expectedSizeBytes) { + throw new InvalidPayloadException('Attachment size does not match decoded content.'); + } + + if ($actualSize > $this->config->maxAttachmentBytes) { + throw new InvalidPayloadException('Attachment exceeds the maximum allowed size.'); + } + + return $decoded; + } +} diff --git a/src/Chat/ChatKernel.php b/src/Chat/ChatKernel.php index 5dad69c..d482496 100644 --- a/src/Chat/ChatKernel.php +++ b/src/Chat/ChatKernel.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use Micilini\PhpSockets\Config\ChatConfig; use Micilini\PhpSockets\Connection\Connection; +use Micilini\PhpSockets\Contracts\AttachmentStoreInterface; use Micilini\PhpSockets\Contracts\ConnectionRegistryInterface; use Micilini\PhpSockets\Contracts\MessageStoreInterface; use Micilini\PhpSockets\Contracts\RoomStoreInterface; @@ -16,6 +17,7 @@ use Micilini\PhpSockets\Protocol\Frame; use Micilini\PhpSockets\Protocol\Opcode; use Micilini\PhpSockets\Server\WebSocketServer; +use Micilini\PhpSockets\Storage\File\FileAttachmentStore; use Micilini\PhpSockets\Storage\InMemory\InMemoryMessageStore; use Micilini\PhpSockets\Storage\InMemory\InMemoryRoomStore; use Micilini\PhpSockets\Storage\InMemory\InMemorySessionStore; @@ -31,6 +33,8 @@ final class ChatKernel private readonly RoomManager $roomManager; private readonly DirectMessageRouter $directMessages; private readonly PrivateGroupRouter $privateGroups; + private readonly AttachmentValidator $attachmentValidator; + private readonly AttachmentStoreInterface $attachments; /** * @var array): void>> @@ -42,11 +46,14 @@ public function __construct( ?SessionStoreInterface $sessionStore = null, ?MessageStoreInterface $messageStore = null, ?RoomStoreInterface $roomStore = null, + ?AttachmentStoreInterface $attachmentStore = null, ) { $this->sessions = $sessionStore ?? new InMemorySessionStore(); $this->messages = $messageStore ?? new InMemoryMessageStore(); $this->rooms = $roomStore ?? new InMemoryRoomStore(); $this->validator = new PayloadValidator(); + $this->attachmentValidator = new AttachmentValidator($this->config); + $this->attachments = $attachmentStore ?? new FileAttachmentStore(sys_get_temp_dir() . '/phpsockets-attachments'); $this->presence = new PresenceManager( new UsernameNormalizer($this->config->maxDisplayNameLength), $this->sessions, @@ -111,8 +118,10 @@ public function handleMessage( match ($envelope->type) { 'auth.join' => $this->handleJoin($connections, $connection, $envelope), + 'attachment.prepare' => $this->handleAttachmentPrepare($connection, $envelope), 'message.global' => $this->handleGlobalMessage($connections, $connection, $envelope), 'message.direct' => $this->handleDirectMessage($connections, $connection, $envelope), + 'message.file' => $this->handleFileMessage($connections, $connection, $envelope), 'message.read' => $this->handleMessageRead($connections, $connection, $envelope), 'room.create' => $this->handleRoomCreate($connections, $connection, $envelope), 'room.message' => $this->handleRoomMessage($connections, $connection, $envelope), @@ -311,6 +320,105 @@ private function handleRoomMessage( ])); } + private function handleAttachmentPrepare(Connection $connection, MessageEnvelope $envelope): void + { + $this->requireAuthenticated($connection); + + try { + $fileName = $this->attachmentValidator->fileName($envelope->payload['fileName'] ?? null); + $mimeType = $this->attachmentValidator->mimeType($envelope->payload['mimeType'] ?? null); + $sizeBytes = $this->attachmentValidator->sizeBytes($envelope->payload['sizeBytes'] ?? null); + + $this->sendEnvelope($connection, MessageEnvelope::server('attachment.accepted', [ + 'fileName' => $fileName, + 'mimeType' => $mimeType, + 'sizeBytes' => $sizeBytes, + 'maxAttachmentBytes' => $this->config->maxAttachmentBytes, + ])); + + return; + } catch (InvalidPayloadException $exception) { + $this->sendEnvelope($connection, MessageEnvelope::server('attachment.rejected', [ + 'message' => $exception->getMessage(), + 'maxAttachmentBytes' => $this->config->maxAttachmentBytes, + ])); + } + } + + private function handleFileMessage( + ConnectionRegistryInterface $connections, + Connection $connection, + MessageEnvelope $envelope, + ): void { + try { + $fromUserId = $this->requireAuthenticated($connection); + $attachmentPayload = $this->validator->attachmentPayload($envelope); + $fileName = $this->attachmentValidator->fileName($attachmentPayload['fileName'] ?? null); + $mimeType = $this->attachmentValidator->mimeType($attachmentPayload['mimeType'] ?? null); + $sizeBytes = $this->attachmentValidator->sizeBytes($attachmentPayload['sizeBytes'] ?? null); + $content = $this->attachmentValidator->decodedContent($attachmentPayload['contentBase64'] ?? null, $sizeBytes); + $caption = $this->validator->optionalText($envelope, 'caption'); + $target = $this->resolveFileMessageTarget($fromUserId, $envelope); + $clientMessageId = $this->validator->clientMessageId($envelope); + $metadata = []; + + if ($clientMessageId !== null) { + $metadata['clientMessageId'] = $clientMessageId; + } + } catch (InvalidPayloadException $exception) { + $this->sendEnvelope($connection, MessageEnvelope::server('attachment.rejected', [ + 'message' => $exception->getMessage(), + 'maxAttachmentBytes' => $this->config->maxAttachmentBytes, + ])); + + return; + } + + $draftMessage = ChatMessage::file( + roomId: $target['room']->id, + fromUserId: $fromUserId, + body: [], + metadata: $metadata, + ); + $attachment = $this->storeAttachment( + messageId: $draftMessage->id, + fileName: $fileName, + mimeType: $mimeType, + content: $content, + ); + $message = new ChatMessage( + id: $draftMessage->id, + roomId: $draftMessage->roomId, + fromUserId: $draftMessage->fromUserId, + kind: $draftMessage->kind, + body: $this->fileMessageBody($attachment, $content, $caption), + createdAt: $draftMessage->createdAt, + metadata: $draftMessage->metadata, + ); + + $this->messages->save($message); + + $this->emit('message.received', [ + 'message' => $message, + 'room' => $target['room'], + 'connection' => $connection, + 'scope' => $target['scope'], + ]); + + $envelope = MessageEnvelope::server('message.received', [ + 'roomId' => $target['room']->id, + 'message' => $message->toArray(), + ]); + + if ($target['recipientUserIds'] === null) { + $this->broadcastAuthenticated($connections, $envelope); + + return; + } + + $this->deliverToUsers($connections, $target['recipientUserIds'], $envelope); + } + private function handleMessageRead( ConnectionRegistryInterface $connections, Connection $connection, @@ -459,6 +567,100 @@ private function handleTypingStatus( ])); } + /** + * @return array{room: Room, recipientUserIds: list|null, scope: string} + */ + private function resolveFileMessageTarget(string $fromUserId, MessageEnvelope $envelope): array + { + $scope = $envelope->payload['scope'] ?? 'global'; + + if (!is_string($scope) || trim($scope) === '') { + $scope = 'global'; + } + + $scope = trim($scope); + + if ($scope === 'direct') { + $toUserId = $this->validator->targetUserId($envelope); + + $this->assertOnlineUser($toUserId); + + $room = $this->roomManager->createDirectRoom($fromUserId, $toUserId); + + return [ + 'room' => $room, + 'recipientUserIds' => [$fromUserId, $toUserId], + 'scope' => 'direct', + ]; + } + + if ($scope === 'room') { + $roomId = $this->validator->roomId($envelope); + $room = $this->roomManager->assertMember($roomId, $fromUserId); + + return [ + 'room' => $room, + 'recipientUserIds' => $room->memberUserIds, + 'scope' => 'room', + ]; + } + + return [ + 'room' => $this->roomManager->ensureGlobalRoom(), + 'recipientUserIds' => null, + 'scope' => 'global', + ]; + } + + private function storeAttachment( + string $messageId, + string $fileName, + string $mimeType, + string $content, + ): Attachment { + if ($this->attachments instanceof FileAttachmentStore) { + return $this->attachments->saveContent( + messageId: $messageId, + fileName: $fileName, + mimeType: $mimeType, + content: $content, + ); + } + + return $this->attachments->save(Attachment::new( + messageId: $messageId, + fileName: $fileName, + mimeType: $mimeType, + sizeBytes: strlen($content), + path: 'attachment://' . $fileName, + )); + } + + /** + * @return array + */ + private function fileMessageBody(Attachment $attachment, string $content, string $caption): array + { + return [ + 'attachmentId' => $attachment->id, + 'fileName' => $attachment->fileName, + 'mimeType' => $attachment->mimeType, + 'sizeBytes' => $attachment->sizeBytes, + 'previewDataUrl' => $this->previewDataUrl($attachment->mimeType, $content), + 'downloadDataUrl' => 'data:' . $attachment->mimeType . ';base64,' . base64_encode($content), + 'caption' => $caption, + ]; + } + + private function previewDataUrl(string $mimeType, string $content): ?string + { + if (!str_starts_with($mimeType, 'image/')) { + return null; + } + + return 'data:' . $mimeType . ';base64,' . base64_encode($content); + } + private function requireAuthenticated(Connection $connection): string { $userId = $connection->userId(); diff --git a/src/Chat/ChatMessage.php b/src/Chat/ChatMessage.php index 3ca3b53..12f79a0 100644 --- a/src/Chat/ChatMessage.php +++ b/src/Chat/ChatMessage.php @@ -9,6 +9,7 @@ final readonly class ChatMessage { /** + * @param string|array|null $body * @param array $metadata */ public function __construct( @@ -16,7 +17,7 @@ public function __construct( public string $roomId, public string $fromUserId, public string $kind, - public ?string $body, + public string|array|null $body, public DateTimeImmutable $createdAt, public array $metadata = [], ) { @@ -38,6 +39,23 @@ public static function text(string $roomId, string $fromUserId, string $text, ar ); } + /** + * @param array $body + * @param array $metadata + */ + public static function file(string $roomId, string $fromUserId, array $body, array $metadata = []): self + { + return new self( + id: 'msg_' . bin2hex(random_bytes(16)), + roomId: $roomId, + fromUserId: $fromUserId, + kind: 'file', + body: $body, + createdAt: new DateTimeImmutable(), + metadata: $metadata, + ); + } + /** * @return array */ diff --git a/src/Chat/PayloadValidator.php b/src/Chat/PayloadValidator.php index ce8244a..51cc013 100644 --- a/src/Chat/PayloadValidator.php +++ b/src/Chat/PayloadValidator.php @@ -13,8 +13,10 @@ final class PayloadValidator */ private array $allowedTypes = [ 'auth.join', + 'attachment.prepare', 'message.global', 'message.direct', + 'message.file', 'message.read', 'room.create', 'room.message', @@ -61,6 +63,23 @@ public function roomId(MessageEnvelope $envelope): string return $this->requiredString($envelope, 'roomId'); } + public function optionalRoomId(MessageEnvelope $envelope): ?string + { + $value = $envelope->payload['roomId'] ?? null; + + if ($value === null) { + return null; + } + + if (!is_string($value)) { + throw new InvalidPayloadException('Payload field roomId must be a string.'); + } + + $value = trim($value); + + return $value !== '' ? $value : null; + } + public function messageId(MessageEnvelope $envelope): string { return $this->requiredString($envelope, 'messageId'); @@ -91,6 +110,27 @@ public function clientMessageId(MessageEnvelope $envelope): ?string return $value; } + public function optionalText(MessageEnvelope $envelope, string $field, int $maxLength = 2000): string + { + $value = $envelope->payload[$field] ?? null; + + if ($value === null) { + return ''; + } + + if (!is_string($value)) { + throw new InvalidPayloadException("Payload field {$field} must be a string."); + } + + $text = trim($value); + + if (strlen($text) > $maxLength) { + throw new InvalidPayloadException("Payload field {$field} is too long."); + } + + return $text; + } + public function roomName(MessageEnvelope $envelope): ?string { $name = $envelope->payload['name'] ?? null; @@ -106,6 +146,21 @@ public function roomName(MessageEnvelope $envelope): ?string return trim($name); } + /** + * @return array + */ + public function attachmentPayload(MessageEnvelope $envelope): array + { + $value = $envelope->payload['attachment'] ?? null; + + if (!is_array($value)) { + throw new InvalidPayloadException('Payload field attachment is required.'); + } + + /** @var array $value */ + return $value; + } + /** * @return list */ diff --git a/src/Config/ChatConfig.php b/src/Config/ChatConfig.php index 2b774d6..fda0863 100644 --- a/src/Config/ChatConfig.php +++ b/src/Config/ChatConfig.php @@ -14,6 +14,12 @@ public function __construct( public int $maxPrivateGroupMembers, public bool $allowGuestSessions, public int $historyLimit, + public int $maxAttachmentBytes, + public int $maxAttachmentFileNameLength, + /** + * @var list + */ + public array $allowedAttachmentMimeTypes, ) { if ($this->maxDisplayNameLength < 1) { throw new InvalidArgumentException('Maximum display name length must be greater than zero.'); @@ -30,14 +36,32 @@ public function __construct( if ($this->historyLimit < 0) { throw new InvalidArgumentException('History limit cannot be negative.'); } + + if ($this->maxAttachmentBytes < 1) { + throw new InvalidArgumentException('Maximum attachment size must be greater than zero.'); + } + + if ($this->maxAttachmentFileNameLength < 1) { + throw new InvalidArgumentException('Maximum attachment file name length must be greater than zero.'); + } + + if ($this->allowedAttachmentMimeTypes === []) { + throw new InvalidArgumentException('At least one attachment MIME type must be allowed.'); + } } + /** + * @param list|null $allowedAttachmentMimeTypes + */ public static function new( int $maxDisplayNameLength = 40, int $maxRoomNameLength = 80, int $maxPrivateGroupMembers = 20, bool $allowGuestSessions = true, int $historyLimit = 50, + int $maxAttachmentBytes = 2097152, + int $maxAttachmentFileNameLength = 180, + ?array $allowedAttachmentMimeTypes = null, ): self { return new self( maxDisplayNameLength: $maxDisplayNameLength, @@ -45,6 +69,15 @@ public static function new( maxPrivateGroupMembers: $maxPrivateGroupMembers, allowGuestSessions: $allowGuestSessions, historyLimit: $historyLimit, + maxAttachmentBytes: $maxAttachmentBytes, + maxAttachmentFileNameLength: $maxAttachmentFileNameLength, + allowedAttachmentMimeTypes: $allowedAttachmentMimeTypes ?? [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'application/pdf', + 'text/plain', + ], ); } } diff --git a/src/Contracts/AttachmentStoreInterface.php b/src/Contracts/AttachmentStoreInterface.php new file mode 100644 index 0000000..3a2858e --- /dev/null +++ b/src/Contracts/AttachmentStoreInterface.php @@ -0,0 +1,14 @@ +, 1: string} + */ + public function decodeAvailable(string $data, bool $fromClient = true): array + { + $frames = []; + $offset = 0; + $length = strlen($data); + + while ($offset < $length) { + try { + [$frame, $offset] = $this->decodeFrameAt($data, $fromClient, $offset); + $frames[] = $frame; + } catch (ProtocolException $exception) { + if (str_starts_with($exception->getMessage(), 'Incomplete WebSocket frame')) { + return [$frames, substr($data, $offset)]; + } + + throw $exception; + } + } + + return [$frames, '']; + } + /** * @return array{0: Frame, 1: int} */ diff --git a/src/Server/ServerRuntime.php b/src/Server/ServerRuntime.php index a507b79..53073e5 100644 --- a/src/Server/ServerRuntime.php +++ b/src/Server/ServerRuntime.php @@ -19,6 +19,7 @@ use Micilini\PhpSockets\Protocol\FrameCodec; use Micilini\PhpSockets\Protocol\Handshake; use Micilini\PhpSockets\Protocol\Opcode; +use Micilini\PhpSockets\Protocol\ProtocolException; use Socket; use Throwable; @@ -28,6 +29,16 @@ final class ServerRuntime private readonly Loop $loop; private readonly FrameCodec $codec; + /** + * @var array + */ + private array $receiveBuffers = []; + + /** + * @var array + */ + private array $fragmentedMessages = []; + public function __construct( private readonly ServerConfig $config, private readonly EventDispatcherInterface $dispatcher, @@ -64,6 +75,7 @@ public function stop(): void $this->loop->stop(); foreach ($this->connections->all() as $connection) { + unset($this->receiveBuffers[$connection->id()], $this->fragmentedMessages[$connection->id()]); $connection->close(); $this->connections->remove($connection->id()); } @@ -167,7 +179,15 @@ private function readConnection(Connection $connection): void } try { - $frames = $this->codec->decodeAll($data); + $connectionId = $connection->id(); + $buffer = ($this->receiveBuffers[$connectionId] ?? '') . $data; + [$frames, $remaining] = $this->codec->decodeAvailable($buffer); + + $this->receiveBuffers[$connectionId] = $remaining; + + if (strlen($remaining) > $this->config->maxPayloadBytes + 16) { + throw new ProtocolException('WebSocket receive buffer exceeds the configured maximum size.'); + } foreach ($frames as $frame) { if (!$this->handleFrame($connection, $frame)) { @@ -194,6 +214,56 @@ private function handleFrame(Connection $connection, Frame $frame): bool return false; } + if ($frame->opcode === Opcode::TEXT) { + if ($frame->fin) { + $this->dispatcher->dispatch(new MessageReceived($connection, $frame)); + + return true; + } + + $this->fragmentedMessages[$connection->id()] = [ + 'opcode' => Opcode::TEXT, + 'payload' => $frame->payload, + ]; + + return true; + } + + if ($frame->opcode === Opcode::CONTINUATION) { + $connectionId = $connection->id(); + $fragment = $this->fragmentedMessages[$connectionId] ?? null; + + if ($fragment === null) { + throw new ProtocolException('Unexpected WebSocket continuation frame.'); + } + + $payload = $fragment['payload'] . $frame->payload; + + if (strlen($payload) > $this->config->maxPayloadBytes) { + unset($this->fragmentedMessages[$connectionId]); + + throw new ProtocolException('WebSocket fragmented payload exceeds the configured maximum size.'); + } + + if (!$frame->fin) { + $this->fragmentedMessages[$connectionId] = [ + 'opcode' => $fragment['opcode'], + 'payload' => $payload, + ]; + + return true; + } + + unset($this->fragmentedMessages[$connectionId]); + + $this->dispatcher->dispatch(new MessageReceived( + $connection, + new Frame(true, $fragment['opcode'], $payload, $frame->masked), + )); + + return true; + } + $this->dispatcher->dispatch(new MessageReceived($connection, $frame)); return true; @@ -201,6 +271,8 @@ private function handleFrame(Connection $connection, Frame $frame): bool private function closeConnection(Connection $connection): void { + unset($this->receiveBuffers[$connection->id()], $this->fragmentedMessages[$connection->id()]); + $connection->close(); $this->connections->remove($connection->id()); $this->dispatcher->dispatch(new ConnectionClosed($connection)); diff --git a/src/Storage/File/FileAttachmentStore.php b/src/Storage/File/FileAttachmentStore.php new file mode 100644 index 0000000..7b78dc7 --- /dev/null +++ b/src/Storage/File/FileAttachmentStore.php @@ -0,0 +1,126 @@ +ensureDirectory($this->basePath); + } + + public function save(Attachment $attachment): Attachment + { + $metadataPath = $this->metadataPath($attachment->id); + $metadata = json_encode($attachment->publicPayload(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + if (file_put_contents($metadataPath, $metadata, LOCK_EX) === false) { + throw new StorageException('Failed to save attachment metadata.'); + } + + return $attachment; + } + + /** + * @param array $metadata + */ + public function saveContent( + string $messageId, + string $fileName, + string $mimeType, + string $content, + array $metadata = [], + ): Attachment { + $attachment = Attachment::new( + messageId: $messageId, + fileName: $fileName, + mimeType: $mimeType, + sizeBytes: strlen($content), + path: '', + metadata: $metadata, + ); + + $extension = pathinfo($fileName, PATHINFO_EXTENSION); + $physicalName = $attachment->id . ($extension !== '' ? '.' . $extension : ''); + $filePath = $this->basePath . DIRECTORY_SEPARATOR . $physicalName; + + if (file_put_contents($filePath, $content, LOCK_EX) === false) { + throw new StorageException('Failed to save attachment content.'); + } + + $attachment = new Attachment( + id: $attachment->id, + messageId: $attachment->messageId, + fileName: $attachment->fileName, + mimeType: $attachment->mimeType, + sizeBytes: $attachment->sizeBytes, + path: $filePath, + createdAt: $attachment->createdAt, + metadata: $attachment->metadata, + ); + + return $this->save($attachment); + } + + public function find(string $attachmentId): ?Attachment + { + $metadataPath = $this->metadataPath($attachmentId); + + if (!is_file($metadataPath)) { + return null; + } + + $json = file_get_contents($metadataPath); + + if (!is_string($json)) { + return null; + } + + $payload = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($payload)) { + return null; + } + + $metadata = $payload['metadata'] ?? []; + + if (!is_array($metadata)) { + $metadata = []; + } + + /** @var array $metadata */ + return new Attachment( + id: (string) $payload['id'], + messageId: (string) $payload['messageId'], + fileName: (string) $payload['fileName'], + mimeType: (string) $payload['mimeType'], + sizeBytes: (int) $payload['sizeBytes'], + path: (string) $payload['path'], + createdAt: new DateTimeImmutable((string) $payload['createdAt']), + metadata: $metadata, + ); + } + + private function metadataPath(string $attachmentId): string + { + return $this->basePath . DIRECTORY_SEPARATOR . $attachmentId . '.json'; + } + + private function ensureDirectory(string $path): void + { + if (is_dir($path)) { + return; + } + + if (!mkdir($path, 0775, true) && !is_dir($path)) { + throw new StorageException("Failed to create attachment directory: {$path}"); + } + } +} diff --git a/src/Storage/File/FileMessageStore.php b/src/Storage/File/FileMessageStore.php index c5bba9a..0267fbc 100644 --- a/src/Storage/File/FileMessageStore.php +++ b/src/Storage/File/FileMessageStore.php @@ -101,9 +101,26 @@ private function hydrate(array $row): ChatMessage roomId: (string) $row['roomId'], fromUserId: (string) $row['fromUserId'], kind: (string) $row['kind'], - body: $row['body'] === null ? null : (string) $row['body'], + body: $this->body($row['body'] ?? null), createdAt: new DateTimeImmutable((string) $row['createdAt']), metadata: $metadata, ); } + + /** + * @return string|array|null + */ + private function body(mixed $body): string|array|null + { + if ($body === null || is_string($body)) { + return $body; + } + + if (is_array($body)) { + /** @var array $body */ + return $body; + } + + return null; + } } diff --git a/src/Storage/Pdo/PdoMessageStore.php b/src/Storage/Pdo/PdoMessageStore.php index bb4a475..558b9a9 100644 --- a/src/Storage/Pdo/PdoMessageStore.php +++ b/src/Storage/Pdo/PdoMessageStore.php @@ -28,7 +28,7 @@ public function save(ChatMessage $message): void 'room_id' => $message->roomId, 'from_user_id' => $message->fromUserId, 'kind' => $message->kind, - 'body' => $message->body, + 'body' => $this->encodeBody($message->body), 'metadata_json' => json_encode($message->metadata, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created_at' => $message->createdAt->format(DATE_ATOM), ]); @@ -67,12 +67,51 @@ private function hydrate(array $row): ChatMessage roomId: (string) $row['room_id'], fromUserId: (string) $row['from_user_id'], kind: (string) $row['kind'], - body: $row['body'] === null ? null : (string) $row['body'], + body: $this->decodeBody($row['body'] === null ? null : (string) $row['body'], (string) $row['kind']), createdAt: new DateTimeImmutable((string) $row['created_at']), metadata: $this->decodeMetadata((string) $row['metadata_json']), ); } + /** + * @param string|array|null $body + */ + private function encodeBody(string|array|null $body): ?string + { + if ($body === null) { + return null; + } + + if (is_array($body)) { + return json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + + return $body; + } + + /** + * @return string|array|null + */ + private function decodeBody(?string $body, string $kind): string|array|null + { + if ($body === null) { + return null; + } + + if ($kind !== 'file') { + return $body; + } + + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($decoded)) { + return null; + } + + /** @var array $decoded */ + return $decoded; + } + /** * @return array */ diff --git a/tests/Integration/Chat/FileMessageTest.php b/tests/Integration/Chat/FileMessageTest.php new file mode 100644 index 0000000..4411d55 --- /dev/null +++ b/tests/Integration/Chat/FileMessageTest.php @@ -0,0 +1,339 @@ + + */ + private array $sockets = []; + + private ?string $attachmentDirectory = null; + + protected function tearDown(): void + { + foreach ($this->sockets as $socket) { + socket_close($socket); + } + + $this->sockets = []; + $this->removeAttachmentDirectory(); + } + + public function testGlobalFileMessageCreatesFileMessageWithoutEchoingBase64Content(): void + { + $server = $this->server(); + [$connection, $socket] = $this->authenticatedConnection($server, 'conn_william', 'William'); + + $this->drainAvailableEnvelopes($socket); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'message.file', + 'payload' => [ + 'scope' => 'global', + 'clientMessageId' => 'client_file_123', + 'caption' => 'Attached hello', + 'attachment' => [ + 'fileName' => 'hello.txt', + 'mimeType' => 'text/plain', + 'sizeBytes' => 5, + 'contentBase64' => base64_encode('hello'), + ], + ], + ]); + + $envelope = $this->receiveServerEnvelope($socket, 'message.received'); + $message = $envelope['payload']['message'] ?? null; + + self::assertIsArray($message); + self::assertSame('file', $message['kind'] ?? null); + self::assertSame('client_file_123', $message['metadata']['clientMessageId'] ?? null); + self::assertSame('hello.txt', $message['body']['fileName'] ?? null); + self::assertSame('text/plain', $message['body']['mimeType'] ?? null); + self::assertSame(5, $message['body']['sizeBytes'] ?? null); + self::assertSame('Attached hello', $message['body']['caption'] ?? null); + self::assertIsString($message['body']['downloadDataUrl'] ?? null); + self::assertArrayNotHasKey('contentBase64', $message['body']); + + $stored = $server->kernel()->messageStore()->messagesForRoom('global'); + + self::assertCount(1, $stored); + self::assertInstanceOf(ChatMessage::class, $stored[0]); + self::assertSame('file', $stored[0]->kind); + self::assertSame('client_file_123', $stored[0]->metadata['clientMessageId'] ?? null); + } + + public function testOversizedFileMessageIsRejectedWithoutGenericError(): void + { + $server = $this->server(ChatConfig::new(maxAttachmentBytes: 4)); + [$connection, $socket] = $this->authenticatedConnection($server, 'conn_william', 'William'); + + $this->drainAvailableEnvelopes($socket); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'message.file', + 'payload' => [ + 'scope' => 'global', + 'attachment' => [ + 'fileName' => 'hello.txt', + 'mimeType' => 'text/plain', + 'sizeBytes' => 5, + 'contentBase64' => base64_encode('hello'), + ], + ], + ]); + + $envelope = $this->receiveServerEnvelope($socket, 'attachment.rejected'); + + self::assertSame('Attachment exceeds the maximum allowed size.', $envelope['payload']['message'] ?? null); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'message.global', + 'payload' => [ + 'text' => 'still connected', + ], + ]); + + $messageEnvelope = $this->receiveServerEnvelope($socket, 'message.received'); + $message = $messageEnvelope['payload']['message'] ?? null; + + self::assertIsArray($message); + self::assertSame('still connected', $message['body'] ?? null); + } + + public function testPdfFileMessageIsAcceptedWithDownloadDataUrl(): void + { + $server = $this->server(); + [$connection, $socket] = $this->authenticatedConnection($server, 'conn_william', 'William'); + + $this->drainAvailableEnvelopes($socket); + + $content = "%PDF-1.4\nsmall pdf\n"; + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'message.file', + 'payload' => [ + 'scope' => 'global', + 'attachment' => [ + 'fileName' => 'sample.pdf', + 'mimeType' => 'application/pdf', + 'sizeBytes' => strlen($content), + 'contentBase64' => base64_encode($content), + ], + ], + ]); + + $envelope = $this->receiveServerEnvelope($socket, 'message.received'); + $message = $envelope['payload']['message'] ?? null; + + self::assertIsArray($message); + self::assertSame('application/pdf', $message['body']['mimeType'] ?? null); + self::assertNull($message['body']['previewDataUrl'] ?? null); + self::assertStringStartsWith('data:application/pdf;base64,', (string) ($message['body']['downloadDataUrl'] ?? '')); + self::assertArrayNotHasKey('contentBase64', $message['body']); + } + + private function server(?ChatConfig $config = null): ChatServer + { + $this->attachmentDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpsockets-file-message-test-' . bin2hex(random_bytes(6)); + + return new ChatServer( + server: new WebSocketServer(ServerConfig::new()), + kernel: new ChatKernel( + config: $config ?? ChatConfig::new(), + attachmentStore: new FileAttachmentStore($this->attachmentDirectory), + ), + ); + } + + private function removeAttachmentDirectory(): void + { + if ($this->attachmentDirectory === null || !is_dir($this->attachmentDirectory)) { + return; + } + + foreach (glob($this->attachmentDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } + } + + rmdir($this->attachmentDirectory); + $this->attachmentDirectory = null; + } + + /** + * @return array{0: Connection, 1: Socket} + */ + private function authenticatedConnection(ChatServer $server, string $id, string $displayName): array + { + [$connection, $socket] = $this->registeredConnection($server, $id); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'auth.join', + 'payload' => [ + 'displayName' => $displayName, + ], + ]); + + return [$connection, $socket]; + } + + /** + * @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)), + ); + } + + /** + * @return array{0: Connection, 1: Socket} + */ + private function registeredConnection(ChatServer $server, string $id): array + { + [$clientSocket, $peerSocket] = $this->connectedSocketPair(); + socket_set_nonblock($clientSocket); + + $connection = new Connection($id, $peerSocket, new FrameCodec()); + + $server->webSocketServer()->connections()->add($connection); + + return [$connection, $clientSocket]; + } + + /** + * @return array + */ + private function receiveServerEnvelope(Socket $socket, string $expectedType): array + { + for ($attempt = 0; $attempt < 10; $attempt++) { + foreach ($this->receiveAvailableEnvelopes($socket, 200000) as $envelope) { + if (($envelope['type'] ?? null) === $expectedType) { + return $envelope; + } + } + } + + throw new RuntimeException("Expected server envelope {$expectedType} was not received."); + } + + /** + * @return list> + */ + private function drainAvailableEnvelopes(Socket $socket): array + { + $envelopes = []; + + do { + $batch = $this->receiveAvailableEnvelopes($socket, 0); + $envelopes = [...$envelopes, ...$batch]; + } while ($batch !== []); + + return $envelopes; + } + + /** + * @return list> + */ + private function receiveAvailableEnvelopes(Socket $socket, int $timeoutMicroseconds): array + { + $readSockets = [$socket]; + $writeSockets = null; + $exceptSockets = null; + $changed = socket_select($readSockets, $writeSockets, $exceptSockets, 0, $timeoutMicroseconds); + + if ($changed === false || $changed === 0) { + return []; + } + + $data = ''; + $bytes = socket_recv($socket, $data, 8192, 0); + + if ($bytes === false || $bytes === 0) { + return []; + } + + $codec = new FrameCodec(); + $envelopes = []; + + foreach ($codec->decodeAll($data, fromClient: false) as $frame) { + $envelope = json_decode($frame->payload, true, 512, JSON_THROW_ON_ERROR); + + if (is_array($envelope)) { + $envelopes[] = $envelope; + } + } + + return $envelopes; + } + + /** + * @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]; + } +} diff --git a/tests/Unit/Chat/AttachmentValidatorTest.php b/tests/Unit/Chat/AttachmentValidatorTest.php new file mode 100644 index 0000000..840ad78 --- /dev/null +++ b/tests/Unit/Chat/AttachmentValidatorTest.php @@ -0,0 +1,114 @@ +fileName('photo.png')); + self::assertSame('image/png', $validator->mimeType('image/png')); + self::assertSame(strlen($content), $validator->sizeBytes(strlen($content))); + self::assertSame($content, $validator->decodedContent($encoded, strlen($content))); + } + + public function testRejectsDisallowedMimeType(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Attachment mimeType is not allowed.'); + + $validator->mimeType('application/x-msdownload'); + } + + public function testAcceptsPdfMimeType(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + + self::assertSame('application/pdf', $validator->mimeType('application/pdf')); + } + + public function testAcceptsTwoMegabyteAttachmentSize(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + + self::assertSame(2097152, $validator->sizeBytes(2097152)); + } + + public function testRejectsAttachmentLargerThanTwoMegabytes(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Attachment exceeds the maximum allowed size.'); + + $validator->sizeBytes(2097153); + } + + public function testAcceptsSmallPdfBase64Content(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + $content = "%PDF-1.4\nsmall pdf\n"; + + self::assertSame($content, $validator->decodedContent(base64_encode($content), strlen($content))); + } + + public function testRejectsOversizedAttachment(): void + { + $validator = new AttachmentValidator(ChatConfig::new(maxAttachmentBytes: 4)); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Attachment exceeds the maximum allowed size.'); + + $validator->sizeBytes(5); + } + + public function testRejectsEmptyFileName(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Attachment fileName cannot be empty.'); + + $validator->fileName(' '); + } + + public function testSanitizesDangerousFileName(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + + self::assertSame('evil_name.txt', $validator->fileName('../../evil:name.txt')); + } + + public function testRejectsInvalidBase64(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Attachment contentBase64 is invalid.'); + + $validator->decodedContent('not base64!@#', 10); + } + + public function testRejectsBase64SizeMismatch(): void + { + $validator = new AttachmentValidator(ChatConfig::new()); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Attachment size does not match decoded content.'); + + $validator->decodedContent(base64_encode('abc'), 4); + } +} diff --git a/tests/Unit/Protocol/FrameCodecTest.php b/tests/Unit/Protocol/FrameCodecTest.php index 47a66e0..a3fbf42 100644 --- a/tests/Unit/Protocol/FrameCodecTest.php +++ b/tests/Unit/Protocol/FrameCodecTest.php @@ -56,6 +56,44 @@ public function testMultipleClientFramesInSingleBufferAreDecoded(): void self::assertSame($secondPayload, $frames[1]->payload); } + public function testDecodeAvailableKeepsIncompleteFrameBytes(): void + { + $codec = new FrameCodec(maxPayloadBytes: 1024); + $encoded = $this->maskedFrame(Opcode::TEXT, 'Hello fragmented world'); + $firstChunk = substr($encoded, 0, 8); + $secondChunk = substr($encoded, 8); + + [$frames, $remaining] = $codec->decodeAvailable($firstChunk); + + self::assertSame([], $frames); + self::assertSame($firstChunk, $remaining); + + [$frames, $remaining] = $codec->decodeAvailable($remaining . $secondChunk); + + self::assertCount(1, $frames); + self::assertSame('', $remaining); + self::assertSame(Opcode::TEXT, $frames[0]->opcode); + self::assertSame('Hello fragmented world', $frames[0]->payload); + } + + public function testContinuationFramesAreDecodedSeparatelyForRuntimeReassembly(): void + { + $codec = new FrameCodec(maxPayloadBytes: 1024); + + $frames = $codec->decodeAll( + $this->maskedFrame(Opcode::TEXT, 'Hello ', fin: false) + . $this->maskedFrame(Opcode::CONTINUATION, 'world') + ); + + self::assertCount(2, $frames); + self::assertFalse($frames[0]->fin); + self::assertSame(Opcode::TEXT, $frames[0]->opcode); + self::assertSame('Hello ', $frames[0]->payload); + self::assertTrue($frames[1]->fin); + self::assertSame(Opcode::CONTINUATION, $frames[1]->opcode); + self::assertSame('world', $frames[1]->payload); + } + public function testServerTextFrameIsEncodedWithoutMask(): void { $codec = new FrameCodec(); @@ -100,9 +138,9 @@ public function testUnmaskedClientFrameIsRejected(): void $codec->decode("\x81\x05Hello"); } - private function maskedFrame(Opcode $opcode, string $payload): string + private function maskedFrame(Opcode $opcode, string $payload, bool $fin = true): string { - $firstByte = chr(0x80 | $opcode->value); + $firstByte = chr(($fin ? 0x80 : 0x00) | $opcode->value); $payloadLength = strlen($payload); $maskingKey = "\x37\xfa\x21\x3d"; diff --git a/tests/Unit/Server/ServerRuntimeFragmentationTest.php b/tests/Unit/Server/ServerRuntimeFragmentationTest.php new file mode 100644 index 0000000..09f516a --- /dev/null +++ b/tests/Unit/Server/ServerRuntimeFragmentationTest.php @@ -0,0 +1,62 @@ + + */ + public array $events = []; + + public function listen(string $eventName, callable $listener): void + { + } + + public function dispatch(Event $event): void + { + $this->events[] = $event; + } + }; + $runtime = new ServerRuntime(ServerConfig::new(maxPayloadBytes: 1024), $dispatcher); + $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + + if ($socket === false) { + throw new RuntimeException('Failed to create test socket.'); + } + + try { + $connection = new Connection('conn_test', $socket, new FrameCodec()); + $handleFrame = new ReflectionMethod($runtime, 'handleFrame'); + + $handleFrame->invoke($runtime, $connection, new Frame(false, Opcode::TEXT, 'Hello ')); + $handleFrame->invoke($runtime, $connection, new Frame(true, Opcode::CONTINUATION, 'world')); + + self::assertCount(1, $dispatcher->events); + self::assertInstanceOf(MessageReceived::class, $dispatcher->events[0]); + self::assertSame(Opcode::TEXT, $dispatcher->events[0]->frame->opcode); + self::assertTrue($dispatcher->events[0]->frame->fin); + self::assertSame('Hello world', $dispatcher->events[0]->frame->payload); + } finally { + socket_close($socket); + } + } +} diff --git a/tests/Unit/Storage/FileAttachmentStoreTest.php b/tests/Unit/Storage/FileAttachmentStoreTest.php new file mode 100644 index 0000000..2f89165 --- /dev/null +++ b/tests/Unit/Storage/FileAttachmentStoreTest.php @@ -0,0 +1,54 @@ +directory === null || !is_dir($this->directory)) { + return; + } + + foreach (glob($this->directory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } + } + + rmdir($this->directory); + $this->directory = null; + } + + public function testSavesContentMetadataAndFindsAttachment(): void + { + $this->directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpsockets-attachment-test-' . bin2hex(random_bytes(6)); + $store = new FileAttachmentStore($this->directory); + + $attachment = $store->saveContent( + messageId: 'msg_test', + fileName: 'hello.txt', + mimeType: 'text/plain', + content: 'Hello attachment', + metadata: ['clientMessageId' => 'client_file'], + ); + $loaded = $store->find($attachment->id); + + self::assertInstanceOf(Attachment::class, $loaded); + self::assertSame('msg_test', $loaded->messageId); + self::assertSame('hello.txt', $loaded->fileName); + self::assertSame('text/plain', $loaded->mimeType); + self::assertSame(strlen('Hello attachment'), $loaded->sizeBytes); + self::assertSame('client_file', $loaded->metadata['clientMessageId'] ?? null); + self::assertFileExists($loaded->path); + self::assertFileExists($this->directory . DIRECTORY_SEPARATOR . $attachment->id . '.json'); + } +}