reactjs – Strange Sockets Behavior with React – [RESOLVED]

I am new to using Web Sockets, and am trying to use the Sockets.io library to finish a chat application I am building. When a message is sent, the recipient receives the message twice, which is obviously not how the app is supposed to work.

I built a Socket context that joins the user into a room that is represented by their unique MongoDB identifier so they can be reached regardless of whichever chat they may be currently viewing. I tried to build it so that each chat the user views enters them into a new room while simultaneously causing them to leave the room for the chat they were viewing previously.

This is my code for the socket-related portion of my server:

io.on('connection', socket => {
  socket.on('setup', userData => {
    socket.join(userData._id);
  });

  socket.on('join chat', chatId => {
    socket.join(chatId);
  }); 

  socket.on('new message', message => {
    let chat = message.chat;
    chat.users.forEach(user => {
      if (user._id === message.sender._id) return;
      socket.to(user._id).emit('message received', message);
    });
  });

  socket.on('leave chat', chatId => {
    socket.leave(chatId);
  });

Here is the relevant code for my socket context (if a new user signs in then it should close the old socket and create a new room representing the new user):

  useEffect(() => {
    if (!currentUser) return;
    const newSocket = io(ENDPOINT);
    newSocket.emit('setup', currentUser);
    setSocket(newSocket);
    return () => newSocket.close();
  }, [currentUser]);

And, finally, here is the code within the chat instance component:

 useEffect(() => {
    if (!socket) return;
    socket.emit('join chat', activeChat[0]._id);
    return () => socket.emit('leave chat', activeChat[0]._id);
  }, [activeChat, socket]);

  useEffect(() => {
    if (!socket) return;
    socket.on('message received', message => {
      if (!activeChat[0]._id || message.chat._id !== activeChat[0]._id) {
        if (!notifications) return;
        setNotifications(prevState => [message, ...prevState]);
        return;
      } else {
        setMessages(prevState => {
          return [...prevState, message];
        });
      }
    });
  }, [activeChat, fetchChats, notifications, socket, setNotifications]);

Just as a side note, I had the application working previously when I kept the socket instance inside of the chat instance (and did not try importing it from the socket hook), but it inhibited my ability for the user to be contacted while viewing another chat since removed the socket instance when the chat instance unmounted by calling return () => socket.close(). Here is that code:

let socket; // Global scope

 useEffect(() => {
    socket = io(ENDPOINT);
    socket.emit('setup', currentUser);
    socket.emit('stop typing', activeChat[0]._id, currentUser);
    return () => socket.close();
  }, [currentUser, activeChat]);

If there is anything I can clarify, please let me know! Thanks so much for the help 🙂

EDIT: So I fixed my problem and it had to do with how I was handling the event listeners on the client side, which I was never unmounting. For anyone in the future who faces the same problem, please see the code below that I used to handle user messaging, typing, and handling changes to which users are online. Namaste.

Server.js (relevant portion):

global.onlineUsers = new Map();
io.on('connection', socket => {
  socket.on('setup', userId => {
    socket.join(userId);
    global.onlineUsers.set(userId, socket.id);
    for (const [
      onlineUserId,
      _onlineSocketId,
    ] of global.onlineUsers.entries()) {
      if (onlineUserId === userId) {
        socket.emit('logged in user change', [...global.onlineUsers]);
        return;
      } else {
        socket
          .to(onlineUserId)
          .emit('logged in user change', [...global.onlineUsers]);
      }
    }
  });

  socket.on('join room', chatId => {
    socket.join(chatId);
  });

  socket.on('leave room', chatId => {
    socket.leave(chatId);
  });

  socket.on('send-msg', message => {
    message.chat.users.forEach(user => {
      if (user._id === message.sender._id) return;
      socket.to(user._id).emit('msg-received', message);
    });
  });

  socket.on('typing', (room, user) => {
    socket.to(room).emit('typing', user.userName);
  });

  socket.on('stop typing', (room, user) =>
    socket.to(room).emit('stop typing', user.userName)
  );

  socket.on('log out', userId => {
    socket.leave(userId);
    global.onlineUsers.delete(userId);
    for (const [
      onlineUserId,
      _onlineSocketId,
    ] of global.onlineUsers.entries()) {
      socket
        .to(onlineUserId)
        .emit('logged in user change', [...global.onlineUsers]);
    }
  });
});

Socket Context (relevant portion):

 useEffect(() => {
    if (!currentUser) return;
    const newSocket = io(ENDPOINT);
    newSocket.emit('setup', currentUser._id);
    newSocket.on('logged in user change', users => {
      const userIdArr = users.map(([userId, socketId]) => userId);
      setOnlineUsers(userIdArr);
    });
    setSocket(newSocket);
    return () => {
      newSocket.off('logged in user change');
      newSocket.emit('log out', currentUser._id);
    };
  }, [currentUser]);

Chat Instance (entire component):

import { useCallback, useEffect, useState } from 'react';
import { io } from 'socket.io-client';
import Lottie from 'lottie-react';
import { useChatView } from '../../contexts/chat-view-context';
import Spinner from '../spinner/spinner.component';
import './message-view.styles.scss';
import { useAuthentication } from '../../contexts/authentication-context';
import animationData from '../../animations/typing.json';
import {
  defaultToast,
  sameSenderAndNotCurrentUser,
  TOAST_TYPE,
  userSent,
  getTyperString,
} from '../../utils/utils';
import { useSocket } from '../../contexts/socket-context';

// Could definitely add timestamp data to the message as well, that would be pretty clean actually

let typingTimer;

const MessageView = () => {
  // Somehow we are going to have to get all of the message in a conversation potentially and then mark whether or not they are your messages or someone else's to style accordingly;
  const { currentUser } = useAuthentication();
  const { activeChat, notifications, setNotifications, fetchChats } =
    useChatView();
  const { socket } = useSocket();

  // const [socketConnected, setSocketConnected] = useState(false);
  const [messages, setMessages] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [isTyping, setIsTyping] = useState(false);
  const [typing, setTyping] = useState(false);
  const [typers, setTypers] = useState([]);

  // console.log('typers from outside', typers);

  // So I am thinking that I can definitely scroll into view whatever message is actually clicked within whatever chat, I don't see why that would not be possible?
  // Pretty cool, when the component actually mounts, the ref for the element gets passed into the callback function, could actually do some pretyy coll things with this, like making an animation or shake the screen or bounce the message or anything when the message actually enters the screen...

  const handleKeyDown = async e => {
    if (!socket) return;
    const newMessage = e.target.innerHTML;
    if (e.key === 'Enter' && newMessage) {
      e.preventDefault();
      e.target.innerHTML = '';
      try {
        const response = await fetch(`http://localhost:4000/api/message`, {
          method: 'post',
          headers: {
            Authorization: `Bearer ${currentUser.token}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            chatId: activeChat[0]._id,
            text: newMessage,
          }),
        });
        const message = await response.json();
        socket.emit('send-msg', message);
        setMessages(prevState => [...prevState, message]);
        setTyping(false);
        return;
      } catch (error) {
        defaultToast(TOAST_TYPE.error, 'Error sending');
      }
    } else {
      if (!typing) {
        setTyping(true);
        socket.emit('typing', activeChat[0]._id, currentUser);
      }
      const lastTypingTime = new Date().getTime();
      const timerLength = 3000;
      if (typingTimer) clearTimeout(typingTimer);
      typingTimer = setTimeout(() => {
        const timeNow = new Date().getTime();
        const timeDiff = timeNow - lastTypingTime;
        if (timeDiff >= timerLength) {
          socket.emit('stop typing', activeChat[0]._id, currentUser);
          setTyping(false);
        }
      }, timerLength);
    }
  };

  const fetchMessages = useCallback(async () => {
    if (!socket) return;
    if (!activeChat) return;
    setIsLoading(true);
    const response = await fetch(
      `http://localhost:4000/api/message/${activeChat[0]._id}`,
      {
        method: 'get',
        headers: { Authorization: `Bearer ${currentUser.token}` },
      }
    );
    const messages = await response.json();
    setMessages(messages);
    setIsLoading(false);
  }, [activeChat, currentUser.token, socket]);

  useEffect(() => {
    fetchMessages();
  }, [fetchMessages, activeChat]);

  useEffect(() => {
    if (!socket) return;
    socket.emit('join room', activeChat[0]._id);
    socket.emit('stop typing', activeChat[0]._id, currentUser);
    return () => socket.emit('leave room', activeChat[0]._id);
  }, [activeChat, socket, currentUser]);

  useEffect(() => {
    if (!socket) return;
    socket.on('msg-received', message => {
      if (!activeChat[0]._id || message.chat._id !== activeChat[0]._id) {
        setNotifications(prevState => [message, ...prevState]);
        return;
      } else {
        setIsTyping(false);
        setMessages(prevState => [...prevState, message]);
      }
    });
    return () => socket.off('msg-received');
  }, [socket, activeChat, setNotifications]);

  useEffect(() => {
    if (!socket) return;
    socket.on('typing', typer => {
      setIsTyping(true);
      setTypers(prevState => [...new Set([typer, ...prevState])]);
    });
    socket.on('stop typing', userName => {
      const usersStillTyping = typers.filter(typer => typer !== userName);
      if (usersStillTyping.length > 0 && typers.length !== 0) {
        setIsTyping(true);
        setTypers(usersStillTyping);
        return;
      }
      setIsTyping(false);
      setTypers([]);
    });
    return () => {
      socket.off('typing');
      socket.off('stop typing');
    };
  }, [socket, typers]);
  const setRef = useCallback(
    node => {
      if (node && isTyping && isScrolledIntoView(node)) {
        node.scrollIntoView({ smooth: true });
      } else if (node && !isTyping) {
        node.scrollIntoView({ smooth: true });
      }
    },
    [isTyping]
  );

  function isScrolledIntoView(el) {
    var rect = el.getBoundingClientRect();
    var elemTop = rect.top;
    var elemBottom = rect.bottom;

    // Only completely visible elements return true:
    var isVisible = elemTop >= 0 && elemBottom <= window.innerHeight;
    // Partially visible elements return true:
    //isVisible = elemTop < window.innerHeight && elemBottom >= 0;
    return isVisible;
  }
  // What is the best way to make it so that the text bubble can expland if it needs to??
  return (
    <div className="message-view-container">
      {isLoading ? (
        <Spinner type="search" />
      ) : (
        <>
          <div className="message-view-active-chat-container">
            {messages.length > 0 &&
              messages.map((message, i) => {
                const lastMessageBool = messages.length - 1 === i + 1;
                const userSentBool = userSent(currentUser, message);
                const sameSenderAndNotCurrentUserBool =
                  sameSenderAndNotCurrentUser(i, messages, currentUser);
                return (
                  <div
                    key={i}
                    ref={lastMessageBool ? setRef : null}
                    className={`message-view-message-container ${
                      userSentBool ? 'user-sent' : ''
                    }`}
                  >
                    <div
                      className="message-view-message-image-container"
                      style={
                        sameSenderAndNotCurrentUserBool || userSentBool
                          ? { visibility: 'hidden' }
                          : { marginTop: '2px' }
                      }
                    >
                      <img
                        height="100%"
                        src={message.sender.picture}
                        alt="profile"
                      />
                    </div>
                    <div className="message-view-text-container">
                      <div className="message-view-text">{message.text}</div>
                      <div
                        style={
                          sameSenderAndNotCurrentUserBool || userSentBool
                            ? { display: 'none' }
                            : {}
                        }
                        className="message-view-text-info"
                      >
                        <p>
                          @{!userSentBool ? message.sender.userName : 'You'}
                        </p>
                      </div>
                    </div>
                  </div>
                );
              })}
          </div>

          {isTyping && (
            <div className="lottie-container">
              {typers.length ? getTyperString(typers) : ''}
              <Lottie
                animationData={animationData}
                loop={true}
                autoplay={true}
                style={{ height: '16px', display: 'block' }}
              />
            </div>
          )}

          <div
            className="send-message-editable"
            data-text={`Message `}
            contentEditable
            onKeyDown={handleKeyDown}
          />
        </>
      )}
    </div>
  );
};

export default MessageView;

Leave a Comment