diff --git a/README.md b/README.md index 1ee719a..fc2e902 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Then open: http://127.0.0.1:8002 ``` -PrivateChat demonstrates global chat plus direct 1:1 private conversations. A direct message is delivered only to the sender and the selected recipient. +PrivateChat demonstrates global chat, direct 1:1 conversations, private group rooms, unread badges, typing indicators and simple message receipts. ## Requirements diff --git a/examples/private-chat/README.md b/examples/private-chat/README.md index 996b85a..c807f09 100644 --- a/examples/private-chat/README.md +++ b/examples/private-chat/README.md @@ -8,8 +8,11 @@ It demonstrates: - Online users list. - Global room. - Private direct 1:1 conversations. +- Private group rooms with selected online users. - Direct messages delivered only to the sender and the selected recipient. -- Typing indicators for global and direct conversations. +- Group messages delivered only to selected members. +- Unread badges for global, direct and private group conversations. +- Typing indicators for global, direct and private group conversations. - Simple message receipts for sent, received and read states. - Safe rendering with `textContent`. - Plain HTML, CSS and JavaScript. @@ -75,8 +78,40 @@ Expected behavior: - Duplicate names are rejected. - User messages are rendered safely without `innerHTML`. -## Important notes +## Private group rooms + +PrivateChat also supports private group rooms. + +A user can click `+ New private room`, select online users, optionally name the room, and create a private conversation. + +Only selected members receive the room and its messages. + +## Manual group test -This phase implements direct 1:1 private messaging. +Open four browser tabs: + +```txt +Tab 1: William +Tab 2: Ana +Tab 3: Bruno +Tab 4: Carla +``` + +Expected behavior: + +- William creates a room with Ana and Bruno. +- William, Ana and Bruno see the new room. +- Carla does not see the room. +- Messages in that room are delivered only to William, Ana and Bruno. +- Carla receives no group messages. +- Unread badges appear when a message arrives in a room that is not currently open. + +## Unread badges + +PrivateChat displays unread badges for Global Room, direct conversations and private group rooms. + +Badges increase while a conversation is not open and reset when the conversation is opened. + +## Important notes -Private group rooms are implemented in the next phase. +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 42359f3..7def79a 100644 --- a/examples/private-chat/public/assets/app.js +++ b/examples/private-chat/public/assets/app.js @@ -11,6 +11,7 @@ const state = { pendingMessageConversations: new Map(), messageElements: new Map(), messageReadBy: new Map(), + unreadCounts: new Map(), isTyping: false, typingStopTimer: null, lastTypingStartSentAt: 0, @@ -30,19 +31,27 @@ state.conversations.set('global', { const elements = { alertBox: document.getElementById('alertBox'), chatPanel: document.getElementById('chatPanel'), + closePrivateRoomModalButton: document.getElementById('closePrivateRoomModalButton'), connectionStatus: document.getElementById('connectionStatus'), conversationEyebrow: document.getElementById('conversationEyebrow'), conversationTitle: document.getElementById('conversationTitle'), + createPrivateRoomButton: document.getElementById('createPrivateRoomButton'), currentDisplayName: document.getElementById('currentDisplayName'), displayNameInput: document.getElementById('displayNameInput'), globalRoomButton: document.getElementById('globalRoomButton'), + groupRoomsList: document.getElementById('groupRoomsList'), joinButton: document.getElementById('joinButton'), joinForm: document.getElementById('joinForm'), loginPanel: document.getElementById('loginPanel'), messageForm: document.getElementById('messageForm'), messageInput: document.getElementById('messageInput'), messagesList: document.getElementById('messagesList'), + newPrivateRoomButton: document.getElementById('newPrivateRoomButton'), onlineCount: document.getElementById('onlineCount'), + privateRoomForm: document.getElementById('privateRoomForm'), + privateRoomModal: document.getElementById('privateRoomModal'), + privateRoomNameInput: document.getElementById('privateRoomNameInput'), + privateRoomUsersList: document.getElementById('privateRoomUsersList'), serverUrlInput: document.getElementById('serverUrlInput'), typingIndicator: document.getElementById('typingIndicator'), usersList: document.getElementById('usersList'), @@ -66,6 +75,25 @@ elements.globalRoomButton.addEventListener('click', () => { setActiveConversation('global'); }); +elements.newPrivateRoomButton.addEventListener('click', () => { + openPrivateRoomModal(); +}); + +elements.closePrivateRoomModalButton.addEventListener('click', () => { + closePrivateRoomModal(); +}); + +elements.privateRoomModal.addEventListener('click', (event) => { + if (event.target && event.target.getAttribute('data-close-room-modal') === 'true') { + closePrivateRoomModal(); + } +}); + +elements.privateRoomForm.addEventListener('submit', (event) => { + event.preventDefault(); + createPrivateRoomFromForm(); +}); + elements.messageForm.addEventListener('submit', (event) => { event.preventDefault(); @@ -89,12 +117,18 @@ elements.messageForm.addEventListener('submit', (event) => { if (conversation.type === 'global') { sendEnvelope('message.global', { text, clientMessageId }); - } else { + } else if (conversation.type === 'direct') { sendEnvelope('message.direct', { toUserId: conversation.targetUserId, text, clientMessageId, }); + } else { + sendEnvelope('room.message', { + roomId: conversation.roomId, + text, + clientMessageId, + }); } elements.messageInput.value = ''; @@ -120,6 +154,7 @@ window.addEventListener('beforeunload', () => { renderEmptyMessages(); renderTypingIndicator(); renderConversationHeader(); +renderGroupRooms(); setStatus('Disconnected', 'offline'); function connect(serverUrl, displayName) { @@ -220,6 +255,10 @@ function handleServerMessage(rawMessage) { handleMessageRead(envelope.payload); break; + case 'room.created': + handleRoomCreated(envelope.payload); + break; + case 'typing.started': handleTypingStarted(envelope.payload); break; @@ -253,6 +292,7 @@ function handleSessionAccepted(payload) { clearAlert(); setActiveConversation('global'); renderUsers(); + renderGroupRooms(); elements.messageInput.focus(); } @@ -278,6 +318,7 @@ function handlePresenceSnapshot(payload) { } renderUsers(); + renderGroupRooms(); } function handleUserJoined(payload) { @@ -287,6 +328,7 @@ function handleUserJoined(payload) { state.users.set(user.userId, user); ensureDirectConversationFromUserId(user.userId); renderUsers(); + renderGroupRooms(); } } @@ -295,6 +337,7 @@ function handleUserLeft(payload) { state.users.delete(payload.userId); clearTypingUserInAllConversations(payload.userId); renderUsers(); + renderGroupRooms(); } } @@ -324,6 +367,10 @@ function handleMessageReceived(payload) { addMessage(message, conversationId); + if (!isOwn && conversationId !== state.activeConversationId) { + incrementUnread(conversationId); + } + if (!isOwn) { sendEnvelope('message.read', { messageId: message.id, @@ -332,6 +379,33 @@ function handleMessageReceived(payload) { } } +function handleRoomCreated(payload) { + if (!payload.room || !payload.room.id) { + return; + } + + const room = payload.room; + const conversationId = groupConversationId(room.id); + const title = room.name || groupTitleFromMembers(room.memberUserIds || []); + + state.conversations.set(conversationId, { + id: conversationId, + type: 'private_group', + title, + subtitle: 'Private group', + targetUserId: null, + roomId: room.id, + memberUserIds: Array.isArray(room.memberUserIds) ? room.memberUserIds : [], + createdBy: room.createdBy || null, + }); + + renderGroupRooms(); + + if (state.currentUser && room.createdBy === state.currentUser.userId) { + setActiveConversation(conversationId); + } +} + function handleMessageRead(payload) { if (!payload.messageId || !payload.userId) { return; @@ -405,10 +479,158 @@ function sendEnvelope(type, payload) { state.socket.send(JSON.stringify({ type, payload })); } +function openPrivateRoomModal() { + renderPrivateRoomUsersList(); + elements.privateRoomModal.classList.remove('d-none'); + elements.privateRoomModal.setAttribute('aria-hidden', 'false'); + elements.privateRoomNameInput.focus(); +} + +function closePrivateRoomModal() { + elements.privateRoomModal.classList.add('d-none'); + elements.privateRoomModal.setAttribute('aria-hidden', 'true'); + elements.privateRoomNameInput.value = ''; + elements.privateRoomUsersList.replaceChildren(); + elements.createPrivateRoomButton.disabled = true; +} + +function selectedPrivateRoomUserIds() { + return [...elements.privateRoomUsersList.querySelectorAll('input[type="checkbox"]:checked')] + .map((input) => input.value) + .filter(Boolean); +} + +function renderPrivateRoomUsersList() { + elements.privateRoomUsersList.replaceChildren(); + + const users = [...state.users.values()] + .filter((user) => !state.currentUser || user.userId !== state.currentUser.userId) + .sort((first, second) => first.displayName.localeCompare(second.displayName)); + + if (users.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No other users online.'; + elements.privateRoomUsersList.appendChild(empty); + elements.createPrivateRoomButton.disabled = true; + return; + } + + for (const user of users) { + const label = document.createElement('label'); + label.className = 'room-user-option'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = user.userId; + + checkbox.addEventListener('change', () => { + elements.createPrivateRoomButton.disabled = selectedPrivateRoomUserIds().length === 0; + }); + + const name = document.createElement('span'); + name.className = 'room-user-option-name'; + name.textContent = user.displayName; + + label.appendChild(checkbox); + label.appendChild(name); + + elements.privateRoomUsersList.appendChild(label); + } + + elements.createPrivateRoomButton.disabled = true; +} + +function createPrivateRoomFromForm() { + const participantUserIds = selectedPrivateRoomUserIds(); + + if (participantUserIds.length === 0) { + showAlert('Select at least one participant.', 'warning'); + return; + } + + const name = elements.privateRoomNameInput.value.trim(); + + sendEnvelope('room.create', { + type: 'private_group', + name: name || null, + participantUserIds, + }); + + closePrivateRoomModal(); +} + function directConversationId(userId) { return `direct:${userId}`; } +function groupConversationId(roomId) { + return `room:${roomId}`; +} + +function unreadCountForConversation(conversationId) { + return state.unreadCounts.get(conversationId) || 0; +} + +function formatUnreadCount(count) { + if (count <= 0) { + return ''; + } + + return count > 99 ? '99+' : String(count); +} + +function incrementUnread(conversationId) { + if (conversationId === state.activeConversationId) { + return; + } + + const current = unreadCountForConversation(conversationId); + state.unreadCounts.set(conversationId, current + 1); + renderConversationBadges(); +} + +function clearUnread(conversationId) { + state.unreadCounts.delete(conversationId); + renderConversationBadges(); +} + +function renderConversationBadges() { + const globalBadge = elements.globalRoomButton.querySelector('.unread-badge'); + updateBadgeElement(globalBadge, unreadCountForConversation('global')); + + for (const item of elements.usersList.querySelectorAll('[data-conversation-id]')) { + const conversationId = item.getAttribute('data-conversation-id'); + const badge = item.querySelector('.unread-badge'); + + if (conversationId && badge) { + updateBadgeElement(badge, unreadCountForConversation(conversationId)); + } + } + + if (elements.groupRoomsList) { + for (const item of elements.groupRoomsList.querySelectorAll('[data-conversation-id]')) { + const conversationId = item.getAttribute('data-conversation-id'); + const badge = item.querySelector('.unread-badge'); + + if (conversationId && badge) { + updateBadgeElement(badge, unreadCountForConversation(conversationId)); + } + } + } +} + +function updateBadgeElement(element, count) { + if (!element) { + return; + } + + const label = formatUnreadCount(count); + + element.textContent = label; + element.classList.toggle('d-none', label === ''); +} + function openDirectConversation(userId) { const user = state.users.get(userId); @@ -445,6 +667,86 @@ function ensureDirectConversationFromUserId(userId) { } } +function groupTitleFromMembers(memberUserIds) { + const names = memberUserIds + .filter((userId) => !state.currentUser || userId !== state.currentUser.userId) + .map((userId) => { + const user = state.users.get(userId); + return user ? user.displayName : 'Unknown user'; + }) + .filter(Boolean); + + if (names.length === 0) { + return 'Private room'; + } + + if (names.length <= 3) { + return names.join(', '); + } + + return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; +} + +function renderGroupRooms() { + if (!elements.groupRoomsList) { + return; + } + + elements.groupRoomsList.replaceChildren(); + + const rooms = [...state.conversations.values()] + .filter((conversation) => conversation.type === 'private_group') + .sort((first, second) => first.title.localeCompare(second.title)); + + if (rooms.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty-state compact-empty'; + empty.textContent = 'No private rooms yet.'; + elements.groupRoomsList.appendChild(empty); + return; + } + + for (const conversation of rooms) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = conversation.id === state.activeConversationId + ? 'conversation-item conversation-item-active' + : 'conversation-item'; + item.dataset.conversationId = conversation.id; + + const avatar = document.createElement('span'); + avatar.className = 'conversation-avatar'; + avatar.textContent = 'G'; + + const info = document.createElement('span'); + info.className = 'conversation-info'; + + const title = document.createElement('strong'); + title.textContent = conversation.title; + + const subtitle = document.createElement('small'); + subtitle.textContent = `${conversation.memberUserIds ? conversation.memberUserIds.length : 0} members`; + + const badge = document.createElement('span'); + badge.className = 'unread-badge d-none'; + + info.appendChild(title); + info.appendChild(subtitle); + + item.appendChild(avatar); + item.appendChild(info); + item.appendChild(badge); + + item.addEventListener('click', () => { + setActiveConversation(conversation.id); + }); + + elements.groupRoomsList.appendChild(item); + } + + renderConversationBadges(); +} + function setActiveConversation(conversationId) { if (!state.conversations.has(conversationId)) { return; @@ -453,8 +755,10 @@ function setActiveConversation(conversationId) { stopTyping(); state.activeConversationId = conversationId; + clearUnread(conversationId); renderConversationHeader(); renderUsers(); + renderGroupRooms(); renderMessages(); renderTypingIndicator(); @@ -469,7 +773,11 @@ function renderConversationHeader() { } elements.conversationTitle.textContent = conversation.title; - elements.conversationEyebrow.textContent = conversation.type === 'global' ? 'Global room' : 'Private direct'; + elements.conversationEyebrow.textContent = conversation.type === 'global' + ? 'Global room' + : conversation.type === 'direct' + ? 'Private direct' + : 'Private group'; elements.globalRoomButton.classList.toggle('conversation-item-active', conversation.id === 'global'); } @@ -478,6 +786,12 @@ function conversationIdForMessage(message) { return 'global'; } + const roomConversationId = groupConversationId(message.roomId); + + if (state.conversations.has(roomConversationId)) { + return roomConversationId; + } + if (state.currentUser && message.fromUserId !== state.currentUser.userId) { ensureDirectConversationFromUserId(message.fromUserId); return directConversationId(message.fromUserId); @@ -499,6 +813,10 @@ function conversationIdForTyping(payload) { return directConversationId(payload.userId); } + if (payload.scope === 'room' && payload.roomId) { + return groupConversationId(payload.roomId); + } + return 'global'; } @@ -509,10 +827,17 @@ function typingPayloadForActiveConversation() { return { roomId: 'global' }; } + if (conversation.type === 'direct') { + return { + scope: 'direct', + toUserId: conversation.targetUserId, + roomId: conversation.roomId || null, + }; + } + return { - scope: 'direct', - toUserId: conversation.targetUserId, - roomId: conversation.roomId || null, + scope: 'room', + roomId: conversation.roomId, }; } @@ -537,9 +862,10 @@ function createClientMessageId() { } function addPendingOwnMessage(text, clientMessageId, conversationId) { + const conversation = state.conversations.get(conversationId); const message = { id: clientMessageId, - roomId: conversationId === 'global' ? 'global' : null, + roomId: conversation && conversation.roomId ? conversation.roomId : 'global', fromUserId: state.currentUser ? state.currentUser.userId : null, kind: 'text', body: text, @@ -725,6 +1051,7 @@ function resetToLogin(keepDisplayName) { state.pendingMessageConversations.clear(); state.messageElements.clear(); state.messageReadBy.clear(); + state.unreadCounts.clear(); clearTypingState(); elements.chatPanel.classList.add('d-none'); @@ -738,6 +1065,7 @@ function resetToLogin(keepDisplayName) { setJoinFormEnabled(true); renderConversationHeader(); renderUsers(); + renderGroupRooms(); renderMessages(); } @@ -784,6 +1112,7 @@ function renderUsers() { const item = document.createElement('button'); item.className = 'user-item'; item.type = 'button'; + item.dataset.conversationId = directConversationId(user.userId); item.classList.toggle('user-item-active', state.activeConversationId === directConversationId(user.userId)); item.addEventListener('click', () => { openDirectConversation(user.userId); @@ -797,11 +1126,17 @@ function renderUsers() { name.className = 'user-name'; name.textContent = user.displayName; + const badge = document.createElement('span'); + badge.className = 'unread-badge d-none'; + item.appendChild(avatar); item.appendChild(name); + item.appendChild(badge); elements.usersList.appendChild(item); } + + renderConversationBadges(); } function renderMessages() { diff --git a/examples/private-chat/public/assets/style.css b/examples/private-chat/public/assets/style.css index b7ccfd7..943be9e 100644 --- a/examples/private-chat/public/assets/style.css +++ b/examples/private-chat/public/assets/style.css @@ -212,6 +212,7 @@ body { .conversation-item, .user-item { + position: relative; width: 100%; display: flex; align-items: center; @@ -281,6 +282,19 @@ body { text-transform: uppercase; } +.sidebar-section { + margin-bottom: 14px; +} + +.group-rooms-list { + display: grid; + gap: 10px; + margin-bottom: 14px; + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + .users-list { display: grid; gap: 10px; @@ -294,13 +308,34 @@ body { } .user-you { - margin-left: auto; + margin-left: 0; color: #6ee7b7; font-size: 0.72rem; font-weight: 900; text-transform: uppercase; } +.unread-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + margin-left: auto; + padding: 0 7px; + border-radius: 999px; + background: #22c55e; + color: #052e16; + font-size: 0.76rem; + font-weight: 900; + line-height: 1; + box-shadow: 0 8px 18px rgba(34, 197, 94, 0.26); +} + +.unread-badge.d-none { + display: none; +} + .chat-panel { display: flex; flex-direction: column; @@ -456,6 +491,82 @@ body { color: #f8fafc; } +.compact-empty { + min-height: 44px; + height: auto; + font-size: 0.86rem; +} + +.room-modal { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 20px; +} + +.room-modal.d-none { + display: none; +} + +.room-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 6, 23, 0.72); + backdrop-filter: blur(10px); +} + +.room-modal-card { + position: relative; + z-index: 1; + width: min(560px, calc(100vw - 32px)); + max-height: min(760px, calc(100vh - 48px)); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.room-users-header { + color: #a7b3c6; + font-size: 0.8rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 10px; +} + +.room-users-list { + display: grid; + gap: 10px; + min-height: 0; + max-height: 320px; + overflow-y: auto; + padding-right: 4px; +} + +.room-user-option { + display: flex; + align-items: center; + gap: 12px; + padding: 13px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 16px; + background: rgba(15, 23, 42, 0.72); + cursor: pointer; +} + +.room-user-option input { + width: 18px; + height: 18px; + accent-color: #22c55e; +} + +.room-user-option-name { + color: #e2e8f0; + font-weight: 800; +} + @keyframes typingDots { 0% { content: ""; diff --git a/examples/private-chat/public/index.html b/examples/private-chat/public/index.html index 0305bf3..74fbe9b 100644 --- a/examples/private-chat/public/index.html +++ b/examples/private-chat/public/index.html @@ -55,8 +55,16 @@