@@ -4,6 +4,9 @@ const state = {
44 users : new Map ( ) ,
55 typingUsers : new Map ( ) ,
66 typingTimers : new Map ( ) ,
7+ pendingMessages : new Map ( ) ,
8+ messageElements : new Map ( ) ,
9+ messageReadBy : new Map ( ) ,
710 isTyping : false ,
811 typingStopTimer : null ,
912 lastTypingStartSentAt : 0 ,
@@ -53,8 +56,11 @@ elements.messageForm.addEventListener('submit', (event) => {
5356 return ;
5457 }
5558
59+ const clientMessageId = createClientMessageId ( ) ;
60+
5661 clearLocalTypingStateBeforeSend ( ) ;
57- sendEnvelope ( 'message.global' , { text } ) ;
62+ addPendingOwnMessage ( text , clientMessageId ) ;
63+ sendEnvelope ( 'message.global' , { text, clientMessageId } ) ;
5864 elements . messageInput . value = '' ;
5965 elements . messageInput . focus ( ) ;
6066} ) ;
@@ -173,6 +179,10 @@ function handleServerMessage(rawMessage) {
173179 handleMessageReceived ( envelope . payload ) ;
174180 break ;
175181
182+ case 'message.read' :
183+ handleMessageRead ( envelope . payload ) ;
184+ break ;
185+
176186 case 'typing.started' :
177187 handleTypingStarted ( envelope . payload ) ;
178188 break ;
@@ -255,8 +265,42 @@ function handleMessageReceived(payload) {
255265 return ;
256266 }
257267
258- clearTypingUser ( payload . message . fromUserId ) ;
259- addMessage ( payload . message ) ;
268+ const message = payload . message ;
269+ const isOwn = state . currentUser && message . fromUserId === state . currentUser . userId ;
270+ const clientMessageId = message . metadata && message . metadata . clientMessageId ;
271+
272+ clearTypingUser ( message . fromUserId ) ;
273+
274+ if ( isOwn && clientMessageId && state . pendingMessages . has ( clientMessageId ) ) {
275+ state . pendingMessages . delete ( clientMessageId ) ;
276+ updatePendingMessageAsReceived ( clientMessageId , message ) ;
277+ return ;
278+ }
279+
280+ addMessage ( message ) ;
281+
282+ if ( ! isOwn ) {
283+ sendEnvelope ( 'message.read' , {
284+ messageId : message . id ,
285+ roomId : message . roomId || 'global' ,
286+ } ) ;
287+ }
288+ }
289+
290+ function handleMessageRead ( payload ) {
291+ if ( ! payload . messageId || ! payload . userId ) {
292+ return ;
293+ }
294+
295+ if ( state . currentUser && payload . userId === state . currentUser . userId ) {
296+ return ;
297+ }
298+
299+ const readBy = state . messageReadBy . get ( payload . messageId ) || new Map ( ) ;
300+ readBy . set ( payload . userId , payload . displayName || 'Someone' ) ;
301+ state . messageReadBy . set ( payload . messageId , readBy ) ;
302+
303+ updateMessageStatus ( payload . messageId , 'read' ) ;
260304}
261305
262306function handleTypingStarted ( payload ) {
@@ -312,6 +356,83 @@ function sendEnvelope(type, payload) {
312356 state . socket . send ( JSON . stringify ( { type, payload } ) ) ;
313357}
314358
359+ function createClientMessageId ( ) {
360+ return `client_${ Date . now ( ) } _${ Math . random ( ) . toString ( 16 ) . slice ( 2 ) } ` ;
361+ }
362+
363+ function addPendingOwnMessage ( text , clientMessageId ) {
364+ const message = {
365+ id : clientMessageId ,
366+ roomId : 'global' ,
367+ fromUserId : state . currentUser ? state . currentUser . userId : null ,
368+ kind : 'text' ,
369+ body : text ,
370+ metadata : { clientMessageId } ,
371+ createdAt : new Date ( ) . toISOString ( ) ,
372+ status : 'sent' ,
373+ } ;
374+
375+ state . pendingMessages . set ( clientMessageId , message ) ;
376+ addMessage ( message ) ;
377+ }
378+
379+ function updatePendingMessageAsReceived ( clientMessageId , message ) {
380+ const row = state . messageElements . get ( clientMessageId ) ;
381+
382+ if ( ! row ) {
383+ addMessage ( message ) ;
384+ updateMessageStatus ( message . id , 'received' ) ;
385+ return ;
386+ }
387+
388+ state . messageElements . delete ( clientMessageId ) ;
389+ state . messageElements . set ( message . id , row ) ;
390+ row . dataset . messageId = message . id ;
391+
392+ const status = row . querySelector ( '.message-status' ) ;
393+ updateStatusElement ( status , 'received' ) ;
394+ }
395+
396+ function updateMessageStatus ( messageId , statusName ) {
397+ const row = state . messageElements . get ( messageId ) ;
398+
399+ if ( ! row ) {
400+ return ;
401+ }
402+
403+ const status = row . querySelector ( '.message-status' ) ;
404+
405+ if ( ! status ) {
406+ return ;
407+ }
408+
409+ updateStatusElement ( status , statusName ) ;
410+ }
411+
412+ function updateStatusElement ( element , statusName ) {
413+ if ( ! element ) {
414+ return ;
415+ }
416+
417+ element . classList . remove ( 'message-status-sent' , 'message-status-received' , 'message-status-read' ) ;
418+ element . classList . add ( `message-status-${ statusName } ` ) ;
419+
420+ if ( statusName === 'sent' ) {
421+ element . textContent = '✓' ;
422+ element . title = 'Message sent' ;
423+ return ;
424+ }
425+
426+ if ( statusName === 'received' ) {
427+ element . textContent = '✓✓' ;
428+ element . title = 'Message received' ;
429+ return ;
430+ }
431+
432+ element . textContent = '✓✓' ;
433+ element . title = 'Message read' ;
434+ }
435+
315436function handleTypingInput ( ) {
316437 if ( ! state . currentUser ) {
317438 return ;
@@ -376,6 +497,9 @@ function clearLocalTypingStateBeforeSend() {
376497function resetToLogin ( keepDisplayName ) {
377498 state . currentUser = null ;
378499 state . users . clear ( ) ;
500+ state . pendingMessages . clear ( ) ;
501+ state . messageElements . clear ( ) ;
502+ state . messageReadBy . clear ( ) ;
379503 clearTypingState ( ) ;
380504
381505 elements . chatPanel . classList . add ( 'd-none' ) ;
@@ -515,6 +639,9 @@ function clearTypingState() {
515639}
516640
517641function renderEmptyMessages ( ) {
642+ state . pendingMessages . clear ( ) ;
643+ state . messageElements . clear ( ) ;
644+ state . messageReadBy . clear ( ) ;
518645 elements . messagesList . replaceChildren ( ) ;
519646
520647 const empty = document . createElement ( 'div' ) ;
@@ -537,18 +664,29 @@ function addMessage(message) {
537664
538665 const row = document . createElement ( 'div' ) ;
539666 row . className = isOwn ? 'message-row is-own' : 'message-row' ;
667+ row . dataset . messageId = message . id ;
668+
669+ const footer = document . createElement ( 'div' ) ;
670+ footer . className = 'message-footer' ;
540671
541672 const meta = document . createElement ( 'div' ) ;
542673 meta . className = 'message-meta' ;
543- meta . textContent = `${ sender } • ${ createdAt } ` ;
674+ meta . textContent = `${ sender } - ${ createdAt } ` ;
675+
676+ const status = document . createElement ( 'span' ) ;
677+ status . className = 'message-status' ;
678+ updateStatusElement ( status , isOwn ? message . status || 'received' : 'received' ) ;
544679
545680 const bubble = document . createElement ( 'div' ) ;
546681 bubble . className = 'message-bubble' ;
547682 bubble . textContent = message . body || '' ;
548683
549- row . appendChild ( meta ) ;
684+ footer . appendChild ( meta ) ;
685+ footer . appendChild ( status ) ;
686+ row . appendChild ( footer ) ;
550687 row . appendChild ( bubble ) ;
551688
689+ state . messageElements . set ( message . id , row ) ;
552690 elements . messagesList . appendChild ( row ) ;
553691 elements . messagesList . scrollTop = elements . messagesList . scrollHeight ;
554692}
0 commit comments