From 7b4ae705e5c852bf00e10fb215a4a81556590b35 Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 19:16:05 -0300 Subject: [PATCH 1/4] phase 11: add emoji picker and small attachments --- .gitignore | 2 + README.md | 13 + examples/easy-chat/README.md | 27 ++ examples/easy-chat/public/assets/app.js | 301 +++++++++++++++- examples/easy-chat/public/assets/style.css | 141 +++++++- examples/easy-chat/public/index.html | 20 ++ examples/medium-chat/README.md | 27 ++ examples/medium-chat/public/assets/app.js | 303 +++++++++++++++- examples/medium-chat/public/assets/style.css | 141 +++++++- examples/medium-chat/public/index.html | 20 ++ examples/private-chat/README.md | 33 ++ examples/private-chat/public/assets/app.js | 323 +++++++++++++++++- examples/private-chat/public/assets/style.css | 141 +++++++- examples/private-chat/public/index.html | 20 ++ src/Chat/Attachment.php | 65 ++++ src/Chat/AttachmentValidator.php | 98 ++++++ src/Chat/ChatKernel.php | 190 +++++++++++ src/Chat/ChatMessage.php | 20 +- src/Chat/PayloadValidator.php | 34 ++ src/Config/ChatConfig.php | 33 ++ src/Contracts/AttachmentStoreInterface.php | 14 + src/Storage/File/FileAttachmentStore.php | 126 +++++++ src/Storage/File/FileMessageStore.php | 19 +- src/Storage/Pdo/PdoMessageStore.php | 43 ++- tests/Integration/Chat/FileMessageTest.php | 291 ++++++++++++++++ tests/Unit/Chat/AttachmentValidatorTest.php | 82 +++++ .../Unit/Storage/FileAttachmentStoreTest.php | 54 +++ 27 files changed, 2565 insertions(+), 16 deletions(-) create mode 100644 src/Chat/Attachment.php create mode 100644 src/Chat/AttachmentValidator.php create mode 100644 src/Contracts/AttachmentStoreInterface.php create mode 100644 src/Storage/File/FileAttachmentStore.php create mode 100644 tests/Integration/Chat/FileMessageTest.php create mode 100644 tests/Unit/Chat/AttachmentValidatorTest.php create mode 100644 tests/Unit/Storage/FileAttachmentStoreTest.php 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..dc861f7 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,19 @@ $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 base64 payloads over WebSocket with strict limits. Larger uploads and chunked binary frames are planned for future versions. + ## Requirements The modern version targets: diff --git a/examples/easy-chat/README.md b/examples/easy-chat/README.md index 8d36c04..30d1fe8 100644 --- a/examples/easy-chat/README.md +++ b/examples/easy-chat/README.md @@ -91,3 +91,30 @@ 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 +512 KB +``` + +All user-provided text continues to be rendered safely. diff --git a/examples/easy-chat/public/assets/app.js b/examples/easy-chat/public/assets/app.js index 4762054..557d520 100644 --- a/examples/easy-chat/public/assets/app.js +++ b/examples/easy-chat/public/assets/app.js @@ -14,12 +14,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 = 524288; +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,6 +46,8 @@ 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'), typingIndicator: document.getElementById('typingIndicator'), usersList: document.getElementById('usersList'), @@ -73,6 +94,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 +133,7 @@ window.addEventListener('beforeunload', () => { } }); +renderEmojiPicker(); renderEmptyMessages(); renderTypingIndicator(); setStatus('Disconnected', 'offline'); @@ -183,6 +236,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 +408,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 +449,27 @@ function addPendingOwnMessage(text, clientMessageId) { addMessage(message); } +function addPendingOwnFileMessage(file, clientMessageId) { + 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: file.type.startsWith('image/') ? URL.createObjectURL(file) : null, + }, + 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 +595,8 @@ function resetToLogin(keepDisplayName) { state.messageElements.clear(); state.messageReadBy.clear(); clearTypingState(); + closeComposerActionsMenu(); + closeEmojiPicker(); elements.chatPanel.classList.add('d-none'); elements.loginPanel.classList.remove('d-none'); @@ -515,6 +611,153 @@ 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(); +} + +async 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; + } + + try { + sendEnvelope('attachment.prepare', { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + }); + + const contentBase64 = await readFileAsBase64(file); + const clientMessageId = createClientMessageId(); + + addPendingOwnFileMessage(file, clientMessageId); + + sendEnvelope('message.file', { + scope: 'global', + clientMessageId, + attachment: { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + contentBase64, + }, + }); + } 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 parts = result.split(','); + resolve(parts.length > 1 ? parts[1] : result); + }); + + reader.addEventListener('error', () => { + reject(new Error('Failed to read file.')); + }); + + reader.readAsDataURL(file); + }); +} + function setJoinFormEnabled(enabled) { elements.displayNameInput.disabled = !enabled; elements.serverUrlInput.disabled = !enabled; @@ -677,9 +920,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 +932,60 @@ 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; + + 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); + + 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..ae5423b 100644 --- a/examples/easy-chat/public/assets/style.css +++ b/examples/easy-chat/public/assets/style.css @@ -386,11 +386,150 @@ body { .message-form { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; padding: 18px; border-top: 1px solid rgba(148, 163, 184, 0.14); background: rgba(2, 6, 23, 0.36); + position: relative; +} + +.composer-actions { + position: relative; + display: flex; + align-items: center; +} + +.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); +} + +.emoji-picker { + width: 278px; + padding: 12px; + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 6px; +} + +.emoji-button { + width: 28px; + height: 28px; + border: 0; + border-radius: 10px; + background: transparent; + font-size: 1.1rem; + line-height: 1; +} + +.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; } @keyframes typingDots { diff --git a/examples/easy-chat/public/index.html b/examples/easy-chat/public/index.html index 868ecec..7790e5c 100644 --- a/examples/easy-chat/public/index.html +++ b/examples/easy-chat/public/index.html @@ -69,6 +69,26 @@

Public conversation

+
+ + + + + + + +
+
diff --git a/examples/medium-chat/README.md b/examples/medium-chat/README.md index 816ae7f..20c2c5c 100644 --- a/examples/medium-chat/README.md +++ b/examples/medium-chat/README.md @@ -122,3 +122,30 @@ 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 +512 KB +``` + +All user-provided text continues to be rendered safely. diff --git a/examples/medium-chat/public/assets/app.js b/examples/medium-chat/public/assets/app.js index 74b80b6..d191ea1 100644 --- a/examples/medium-chat/public/assets/app.js +++ b/examples/medium-chat/public/assets/app.js @@ -14,14 +14,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 = 524288; +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,6 +48,8 @@ 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'), typingIndicator: document.getElementById('typingIndicator'), usersList: document.getElementById('usersList'), @@ -79,6 +100,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 +139,7 @@ window.addEventListener('beforeunload', () => { } }); +renderEmojiPicker(); renderEmptyMessages(); renderTypingIndicator(); setStatus('Disconnected', 'offline'); @@ -197,6 +250,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 +422,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 +447,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 +499,27 @@ function addPendingOwnMessage(text, clientMessageId) { addMessage(message); } +function addPendingOwnFileMessage(file, clientMessageId) { + 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: file.type.startsWith('image/') ? URL.createObjectURL(file) : null, + }, + 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 +645,8 @@ function resetToLogin(keepDisplayName) { state.messageElements.clear(); state.messageReadBy.clear(); clearTypingState(); + closeComposerActionsMenu(); + closeEmojiPicker(); elements.chatPanel.classList.add('d-none'); elements.loginPanel.classList.remove('d-none'); @@ -563,6 +661,153 @@ 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(); +} + +async 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; + } + + try { + sendEnvelope('attachment.prepare', { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + }); + + const contentBase64 = await readFileAsBase64(file); + const clientMessageId = createClientMessageId(); + + addPendingOwnFileMessage(file, clientMessageId); + + sendEnvelope('message.file', { + scope: 'global', + clientMessageId, + attachment: { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + contentBase64, + }, + }); + } 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 parts = result.split(','); + resolve(parts.length > 1 ? parts[1] : result); + }); + + reader.addEventListener('error', () => { + reject(new Error('Failed to read file.')); + }); + + reader.readAsDataURL(file); + }); +} + function setJoinFormEnabled(enabled) { elements.displayNameInput.disabled = !enabled; elements.serverUrlInput.disabled = !enabled; @@ -725,9 +970,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 +982,60 @@ 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; + + 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); + + 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..fd317f2 100644 --- a/examples/medium-chat/public/assets/style.css +++ b/examples/medium-chat/public/assets/style.css @@ -404,11 +404,150 @@ body { .message-form { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; padding: 18px; border-top: 1px solid rgba(165, 180, 252, 0.14); background: rgba(3, 7, 18, 0.36); + position: relative; +} + +.composer-actions { + position: relative; + display: flex; + align-items: center; +} + +.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); +} + +.emoji-picker { + width: 278px; + padding: 12px; + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 6px; +} + +.emoji-button { + width: 28px; + height: 28px; + border: 0; + border-radius: 10px; + background: transparent; + font-size: 1.1rem; + line-height: 1; +} + +.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; } .event-log { diff --git a/examples/medium-chat/public/index.html b/examples/medium-chat/public/index.html index 1cb10bc..db12934 100644 --- a/examples/medium-chat/public/index.html +++ b/examples/medium-chat/public/index.html @@ -73,6 +73,26 @@

Customizable realtime flow

+
+ + + + + + + +
+
diff --git a/examples/private-chat/README.md b/examples/private-chat/README.md index 0208f19..a94adb6 100644 --- a/examples/private-chat/README.md +++ b/examples/private-chat/README.md @@ -118,6 +118,39 @@ 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 +512 KB +``` + +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. + ## 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..74e4f45 100644 --- a/examples/private-chat/public/assets/app.js +++ b/examples/private-chat/public/assets/app.js @@ -28,16 +28,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 = 524288; +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,6 +67,8 @@ 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'), @@ -143,6 +164,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 +203,7 @@ window.addEventListener('beforeunload', () => { } }); +renderEmojiPicker(); renderEmptyMessages(); renderTypingIndicator(); renderConversationHeader(); @@ -255,6 +308,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 +531,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 +952,29 @@ function addPendingOwnMessage(text, clientMessageId, conversationId) { addMessage(message, conversationId); } +function addPendingOwnFileMessage(file, clientMessageId, conversationId) { + const conversation = state.conversations.get(conversationId); + 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: file.type.startsWith('image/') ? URL.createObjectURL(file) : null, + }, + 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 +1149,8 @@ function resetToLogin(keepDisplayName) { state.messageReadBy.clear(); state.unreadCounts.clear(); clearTypingState(); + closeComposerActionsMenu(); + closeEmojiPicker(); elements.chatPanel.classList.add('d-none'); elements.loginPanel.classList.remove('d-none'); @@ -1069,6 +1167,173 @@ 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(); +} + +async 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; + } + + try { + sendEnvelope('attachment.prepare', { + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + }); + + const contentBase64 = await readFileAsBase64(file); + const clientMessageId = createClientMessageId(); + + addPendingOwnFileMessage(file, clientMessageId, state.activeConversationId); + sendEnvelope('message.file', fileMessagePayloadForActiveConversation(file, clientMessageId, contentBase64)); + } catch (error) { + showAlert(error instanceof Error ? error.message : 'Failed to send file.', 'danger'); + } +} + +function fileMessagePayloadForActiveConversation(file, clientMessageId, contentBase64) { + 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, attachment }; + } + + if (conversation.type === 'direct') { + return { + scope: 'direct', + toUserId: conversation.targetUserId, + clientMessageId, + attachment, + }; + } + + return { + scope: 'room', + roomId: conversation.roomId, + clientMessageId, + 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 parts = result.split(','); + resolve(parts.length > 1 ? parts[1] : result); + }); + + reader.addEventListener('error', () => { + reject(new Error('Failed to read file.')); + }); + + reader.readAsDataURL(file); + }); +} + function setJoinFormEnabled(enabled) { elements.displayNameInput.disabled = !enabled; elements.serverUrlInput.disabled = !enabled; @@ -1193,9 +1458,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 +1469,60 @@ 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; + + 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); + + 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..6b1a738 100644 --- a/examples/private-chat/public/assets/style.css +++ b/examples/private-chat/public/assets/style.css @@ -465,11 +465,150 @@ body { .message-form { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; padding: 18px; border-top: 1px solid rgba(148, 163, 184, 0.14); background: rgba(3, 7, 18, 0.36); + position: relative; +} + +.composer-actions { + position: relative; + display: flex; + align-items: center; +} + +.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); +} + +.emoji-picker { + width: 278px; + padding: 12px; + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 6px; +} + +.emoji-button { + width: 28px; + height: 28px; + border: 0; + border-radius: 10px; + background: transparent; + font-size: 1.1rem; + line-height: 1; +} + +.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; } .info-panel { diff --git a/examples/private-chat/public/index.html b/examples/private-chat/public/index.html index 74fbe9b..d88efbc 100644 --- a/examples/private-chat/public/index.html +++ b/examples/private-chat/public/index.html @@ -85,6 +85,26 @@

Global Room

+
+ + + + + + + +
+
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..692ad8e 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,95 @@ 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 { + $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); + $target = $this->resolveFileMessageTarget($fromUserId, $envelope); + $clientMessageId = $this->validator->clientMessageId($envelope); + $metadata = []; + + if ($clientMessageId !== null) { + $metadata['clientMessageId'] = $clientMessageId; + } + + $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), + 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 +557,98 @@ 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): array + { + return [ + 'attachmentId' => $attachment->id, + 'fileName' => $attachment->fileName, + 'mimeType' => $attachment->mimeType, + 'sizeBytes' => $attachment->sizeBytes, + 'previewDataUrl' => $this->previewDataUrl($attachment->mimeType, $content), + ]; + } + + 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..87de7c3 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'); @@ -106,6 +125,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..4f01a65 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 = 524288, + 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 @@ +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..aad3569 --- /dev/null +++ b/tests/Integration/Chat/FileMessageTest.php @@ -0,0 +1,291 @@ + + */ + 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', + '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::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 testOversizedFileMessageIsRejected(): 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, 'error'); + + self::assertSame('Attachment exceeds the maximum allowed size.', $envelope['payload']['message'] ?? null); + } + + 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..ffbae97 --- /dev/null +++ b/tests/Unit/Chat/AttachmentValidatorTest.php @@ -0,0 +1,82 @@ +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 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/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'); + } +} From 1e76312d541be665280b713ede3110d15dbcdb60 Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 19:34:52 -0300 Subject: [PATCH 2/4] phase 11: polish attachment composer and downloads --- README.md | 24 +++ examples/easy-chat/README.md | 24 +++ examples/easy-chat/public/assets/app.js | 173 +++++++++++++++-- examples/easy-chat/public/assets/style.css | 167 +++++++++++++++- examples/easy-chat/public/index.html | 50 +++-- examples/easy-chat/server.php | 4 +- examples/medium-chat/README.md | 24 +++ examples/medium-chat/public/assets/app.js | 170 +++++++++++++++-- examples/medium-chat/public/assets/style.css | 167 +++++++++++++++- examples/medium-chat/public/index.html | 50 +++-- examples/medium-chat/server.php | 6 +- examples/private-chat/README.md | 24 +++ examples/private-chat/public/assets/app.js | 178 ++++++++++++++++-- examples/private-chat/public/assets/style.css | 167 +++++++++++++++- examples/private-chat/public/index.html | 50 +++-- examples/private-chat/server.php | 6 +- src/Chat/ChatKernel.php | 38 ++-- src/Chat/PayloadValidator.php | 21 +++ tests/Integration/Chat/FileMessageTest.php | 39 +++- tests/Unit/Chat/AttachmentValidatorTest.php | 7 + 20 files changed, 1236 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index dc861f7..4ca0179 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,30 @@ Users can: The initial attachment transport uses base64 payloads over WebSocket with strict limits. 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 +512 KB +``` + +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 30d1fe8..8913c3e 100644 --- a/examples/easy-chat/README.md +++ b/examples/easy-chat/README.md @@ -118,3 +118,27 @@ Default max size: ``` 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 +512 KB +``` + +Each delivered file message includes a download button. diff --git a/examples/easy-chat/public/assets/app.js b/examples/easy-chat/public/assets/app.js index 557d520..173db68 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, @@ -49,10 +50,13 @@ const elements = { 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(); @@ -67,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(); @@ -449,7 +459,9 @@ function addPendingOwnMessage(text, clientMessageId) { addMessage(message); } -function addPendingOwnFileMessage(file, clientMessageId) { +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', @@ -459,7 +471,9 @@ function addPendingOwnFileMessage(file, clientMessageId) { fileName: file.name, mimeType: file.type, sizeBytes: file.size, - previewDataUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null, + previewDataUrl: previewUrl, + downloadDataUrl: downloadUrl, + caption, }, metadata: { clientMessageId }, createdAt: new Date().toISOString(), @@ -597,6 +611,7 @@ function resetToLogin(keepDisplayName) { clearTypingState(); closeComposerActionsMenu(); closeEmojiPicker(); + clearSelectedAttachment(); elements.chatPanel.classList.add('d-none'); elements.loginPanel.classList.remove('d-none'); @@ -670,7 +685,7 @@ function insertAtCursor(input, value) { handleTypingInput(); } -async function handleSelectedFile() { +function handleSelectedFile() { const file = elements.fileInput.files && elements.fileInput.files[0] ? elements.fileInput.files[0] : null; @@ -691,21 +706,107 @@ async function handleSelectedFile() { 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; + try { - sendEnvelope('attachment.prepare', { - fileName: file.name, - mimeType: file.type, - sizeBytes: file.size, - }); + clearLocalTypingStateBeforeSend(); const contentBase64 = await readFileAsBase64(file); const clientMessageId = createClientMessageId(); - addPendingOwnFileMessage(file, clientMessageId); + addPendingOwnFileMessage(file, clientMessageId, caption); sendEnvelope('message.file', { scope: 'global', clientMessageId, + caption, attachment: { fileName: file.name, mimeType: file.type, @@ -713,6 +814,10 @@ async function handleSelectedFile() { contentBase64, }, }); + + elements.messageInput.value = ''; + clearSelectedAttachment(); + elements.messageInput.focus(); } catch (error) { showAlert(error instanceof Error ? error.message : 'Failed to send file.', 'danger'); } @@ -771,14 +876,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() { @@ -952,6 +1078,8 @@ function createFileMessageElement(body) { 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'); @@ -983,6 +1111,23 @@ function createFileMessageElement(body) { 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; } diff --git a/examples/easy-chat/public/assets/style.css b/examples/easy-chat/public/assets/style.css index ae5423b..534dd9b 100644 --- a/examples/easy-chat/public/assets/style.css +++ b/examples/easy-chat/public/assets/style.css @@ -385,19 +385,27 @@ body { } .message-form { - display: grid; - grid-template-columns: auto 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 { @@ -461,22 +469,52 @@ body { 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: 278px; + width: 292px; + max-height: 220px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; padding: 12px; display: grid; - grid-template-columns: repeat(8, 1fr); - gap: 6px; + grid-template-columns: repeat(6, 1fr); + gap: 8px; } .emoji-button { - width: 28px; - height: 28px; + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; border: 0; - border-radius: 10px; + border-radius: 12px; background: transparent; - font-size: 1.1rem; + font-size: 1.25rem; line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; } .emoji-button:hover { @@ -532,6 +570,115 @@ body { 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 { 0% { content: ""; @@ -583,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 7790e5c..5bf1f26 100644 --- a/examples/easy-chat/public/index.html +++ b/examples/easy-chat/public/index.html @@ -69,28 +69,40 @@

Public conversation

-
- - -