Product Manager, PubNub
IN THIS ARTICLE

    Subscribe to Our Newsletter

    Stay updated with the latest on web, mobile, and IoT, delivered weekly.
    Thanks for subscribing!

    Thanks for subscribing!

    Get ready for some great content.

    Team Chat is our sample group chat app. It includes user management, multiple group conversations, online/offline presence status, and more. Its UI is built in React, and it uses the PubNub Redux framework to manage client state in the application. Following this approach allows you to easily implement in-app chat for your own application.

    Tutorial Overview 

    This is part 1 of a multi-part tutorial that walks through how Team Chat’s key features are put together, and how you can add them in your own applications. In this part, we’ll cover logging in with a user, showing multiple conversations, and exchanging messages in a group with other users. 

    Part 2 covers joining and leaving conversations, working with occupancy and network status, and profanity filtering. Head over there once you're through part 1 to expand the functionality of your application.

    The Github repository for the project can be found here.

    Edit team-chatv0.7.0

    Our Frameworks and SDKs

    React

    Our React framework provides tools to leverage the PubNub JavaScript SDK within a React application by making use of React features like Provider and Consumer patterns and React Hooks. View reference docs.

    Redux

    Our Redux framework makes it easier for you to manage the state of data components inside an application. The framework adds structured data to the Redux store, listens to realtime events generated by the network, stores these events on the client side, and triggers updates to the view inside your application. View reference docs.

    Javascript

    Our React and Redux frameworks have a dependency on the Javascript SDK to manage connections and communicate with the PubNub network. View reference docs.

    Prerequisites

    PubNub Account - to run this app, you must create an account with PubNub and obtain your publish and subscribe keys. If you don't already have an account, you can create one for free.

    Node.js (10+) - make sure that you have a recent version of Node installed. To check what version you’re running, type node --version into a terminal window.

    A Git client - but chances are good you’ve already got one installed.

    Run the app

    1. Clone the Github repository.

    git clone https://github.com/pubnub/typescript-ref-app-team-chat.git

    2. Navigate to the root directory

    cd typescript-ref-app-team-chat

    3. Run the app. The first time you run the app, it will prompt you for your publish and subscribe keys from your account. If you don’t have an account, you can create one for free from the PubNub Dashboard.

    npm install
    npm start

    You should now have the app running in your local web browser. When you're done, type Control-C in the terminal window to exit.

    When you run the app for the first time, it runs the setup/populate.js script to populate sample data for users, spaces, and memberships into your PubNub keys. You can make changes to the sample data by editing the input_data.json file.

    Initialize the PubNub Client

    When the app first loads, it runs the main/App.tsx component to initialize the pubnub client with your publish and subscribe keys that are configured in the config/pubnub-keys.json file.

    The component also calls pubnub.addListener() to register all listeners at once (message listener, presence listener, and so on). The listener triggers events when the app receives messages, and automatically dispatches reducers to update the local store.

    import Pubnub from "pubnub";
    import { createPubNubListener } from "pubnub-redux";
    import { PubNubProvider } from "pubnub-react";
    import keyConfiguration from "config/pubnub-keys.json";
    
    const pubnubConfig = Object.assign(
      {},{
        restore: true,
        heartbeatInterval: 0
      },
      keyConfiguration
    );
    const pubnub = new Pubnub(pubnubConfig);
    
    const App = () => {
      useEffect(() => {
        pubnub.addListener(createPubNubListener(store.dispatch));
        return leaveApplication;
      }, []);
      
      return (
        <Provider store={store}>
          <PubNubProvider client={pubnub}>
            <Normalize />
            <GlobalStyles />
            <ApplicationRouter />
          </PubNubProvider>
        </Provider>
      );
    };
      
    export { App };

    Log in with a User

    While the splash screen displays, the app is selecting a user for you, and retrieving the conversations to which your user is subscribed. Keep reading to see how this works.

    Team Chat Loading Screen

    The authentication/login/Login.tsx component displays a login screen and selects a random user to log into the application. The user is selected from the list of IDs defined in the knownUserIds.json file. These are the same users that were initially populated on your key.

    const Login = () => {
      const dispatch = useDispatch();
      const loggingIn = useSelector(isLoggingIn);
      const loggedIn = useSelector(isUserLoggedIn);
    
      const loginWithRandomlyPickedUser = () => {
        if (loggingIn || loggedIn) {
          return;
        }
        const userId = KnownIds[Math.floor(Math.random() * KnownIds.length)];
        dispatch(login(userId));
      };
    
      if (!loggedIn && !loggingIn) {
        loginWithRandomlyPickedUser();
      }
    
      return (
        <Wrapper>
          <Body>
            <Button onClick={loginWithRandomlyPickedUser}>
              {loggingIn ? "Connecting" : "Connect"}
            </Button>
            <PoweredByPubNub>
              <PoweredBy>Powered By</PoweredBy>
              <img alt="PubNub" src={PubNubLogo} />
            </PoweredByPubNub>
          </Body>
        </Wrapper>
      );
    };
    
    export { Login };

    Set a user

    Once the app selects a user, the following code (in authentication/loginCommand.ts) calls pubnub.api.setUUID() to set the user’s ID on the pubnub object. This ID will be passed in all API calls to PubNub so that the network can identify the user who performs these operations. 

    We then call the fetchUserById() command to fetch user details from PubNub and store the logged in user in the local store.

    export const login = (userId: string): ThunkAction<Promise<void>> => {
      return (dispatch, getState, context) => {
        dispatch(loggingIn());
    
        // Show the login screen for a minimum amount of time
        const timer = new Promise(resolve => setTimeout(resolve, 2000));
    
        // Set the UUID of the current user
        context.pubnub.api.setUUID(userId);
    
        // Fetch user from PubNub
        const isLoginSuccessful = dispatch(fetchUserById({ userId }))
    });

    Connect to conversations

    Once the login is successful, we call the fetchMemberships() command to retrieve the user’s conversations from PubNub. The method returns a list of space memberships that were initially populated by the setup/populate.js script and stores these conversations in the local store.

    Next, we call pubnub.api.subscribe() to open a realtime connection with PubNub and subscribe the user to the conversation channels. If a client is subscribed to one or more channels, it will start receiving the messages and events published on those channels. Subscribing with presence also subscribes you to presence channels so you can receive join and leave events, which allow you to show other users as online or offline on the app. (We’ll dive into detail on presence in a future post.)

    const isLoginSuccessful = dispatch(fetchUserById({ userId }))
      .then(() => {
        return dispatch(
          // Fetch the user’s conversations from PubNub
          fetchMemberships({
            userId,
            include: {
              spaceFields: true,
              customSpaceFields: false,
              customFields: false,
              totalCount: false
            }
          })
        );
      })
      .then(() => {
        // Subscribe to messages on the user's joined conversations.
        // The store contains a list of the user's conversations; we use
        // this list to perform the subscribe() operation.
        const conversationChannels = getConversationsByUserId(getState())[
          userId
        ].map(membership => membership.id);
    
        context.pubnub.api.subscribe({
          channels: conversationChannels,
          withPresence: true
        });
      });

    Show User Details

    In the top left section of the UI, the app shows details for the current user. These include an avatar image, the user’s name and title, and the user’s network status.

    Team Chat User Details

    The currentUser/MyUserDetails.tsx component displays the details for the user like name, title and profileUrl. The component calls getUsersById() selector to retrieve these user details from the local store.

    The component also calls the NetworkStatus component to show the user’s connection status. The next section has more details on network status.

    export interface MyUserDetailsFragment {
      name: string;
      profileUrl: string;
      custom: {
        title: string;
      };
    }
    
    const MyUserDetails = () => {
      const userId = useSelector(getLoggedInUserId);
      const usersById = useSelector(getUsersById);
      const user = usersById[userId];
    
      // We must always have a user; change this to a development time error check
      if (user === undefined) {
        return <div>Loading...</div>;
      }
    
      return (
        <Wrapper>
          <Avatar>
            <NetworkStatus />
            <UserInitialsAvatar size={56} name={user.name} uuid={user.id} />
          </Avatar>
          <About>
            <UserName>{user.name}</UserName>
            <UserTitle>{user.custom.title}</UserTitle>
          </About>
        </Wrapper>
      );
    };

    Get network status

    The currentUser/NetworkStatus/NetworkStatus.tsx component displays a green or gray dot to indicate if the user is connected to the network. The component calls the state.networkStatus.isConnected selector to get this connection status from the store.

    const NetworkStatus = () => {
      let isConnected: boolean = useSelector(
        (state: AppState) => state.networkStatus.isConnected
      );
      return (
        <Wrapper>
          <PresenceIndicatorIcon fill={isConnected ? "#B8E986" : "#E9EEF4"} />
        </Wrapper>
      );
    };

    Show Joined Conversations

    Below your user info, you’ll find your list of conversations. The Introductions conversation is automatically selected when the app launches.

    You can join other conversations with the + button, and leave existing conversations with the Exit button. We’ll dive into that in a future post.

    Team Chat my Conversations

    The joinedConversations/MyConversations/MyConversations.tsx component gets the user’s list of conversations and allows the user to select a conversation. When the app loads, it selects the Introduction conversation by default.

    export interface ConversationFragment {
      id: string;
      name: string;
    }
    
    const MyConversations = () => {
      const currentUserId = useSelector(getLoggedInUserId);
      const conversationsById = useSelector(getConversationsById);
      const conversations: ConversationFragment[] = useSelector(
        getJoinedConversations
      );
      const currentConversationId: string = useSelector(getCurrentConversationId);
    
      if (conversationsById === undefined) {
        return <div>Loading...</div>;
      }
    
      return (
        <Wrapper>
          <Title>
            Conversations
          </Title>
          <ConversationList>
            {conversations.map(conversation => (
              <ConversationItem
                id={conversation.id}
                name={conversation.name}
                selected={conversation.id === currentConversationId}
                key={conversation.id}
                onClick={() => {
                  dispatch(focusOnConversation(conversation.id));
                  dispatch(setLayoutDefault());
                }}
              ></ConversationItem>
            ))}
          </ConversationList>
        </Wrapper>
      );
    };

    Get conversations for user

    The MyConversations component calls the getConversationsByUserId() selector to get the user’s conversations from the local store. This selector returns the list of conversations, along with properties like id and name, to be displayed in the UI.

    export const getJoinedConversations = createSelector(
      [getConversationsById, getLoggedInUserId, getConversationsByUserId],
      (
        conversations: ConversationsIndexedById,
        userId: string,
        userConversations: MembershipHash
      ): ConversationFragment[] => {
        return userConversations[userId]
          ? userConversations[userId].map(conversation => {
              return {
                id: conversation.id,
                name: conversations[conversation.id].name
              };
            })
          : [];
      }
    );

    Send and Receive Messages

    Along the top of the conversation pane, the app displays details about the conversation: a title, description, and membership and presence information. Below that, the app shows messages in the conversation as they arrive. At the bottom, we have a component to let us input text and emoji.

    Team Chat Conversation

    Once a conversation is selected, the currentConversation/CurrentConversation/CurrentConversation.tsx component displays details of the conversation as well as its messages. There are three sub-components:

    • Header includes the conversation name, description, and occupancy count

    • MessageList shows the messages in the conversation (we’ll talk about this in the next section)

    • MessageInput shows the text input area and emoji picker (we’ll talk about this in the next section, too)

    const CurrentConversation = () => {
      const panels = useSelector(getPanelStates);
      return (
        <AnimatedWrapper pose={panels.Content ? "open" : "closed"}>
          <Header />
          <MessageList />
          <MessageInput />
        </AnimatedWrapper>
      );
    };
    
    export { CurrentConversation };

    Show the current conversation

    The currentConversation/Header/Header.tsx component calls getConversationsById() to get details of the selected conversation from the local store, and renders them in the UI.

    export interface ConversationDescriptionFragment {
      id: string;
      name: string;
      description: string;
    }
    
    export const getCurrentConversationDescription = createSelector(
      [getConversationsById, getCurrentConversationId],
      (
        conversations: ConversationsIndexedById,
        currentConversationId: string
      ): ConversationDescriptionFragment => {
        return {
          ...conversations[currentConversationId]
        };
      }
    );
    
    const Header = () => {
      const conversation: ConversationDescriptionFragment = useSelector(
        getCurrentConversationDescription
      );
    
      const dispatch = useDispatch();
      return (
        <Wrapper>
          <Body>
            <BackIconWrapper
              onClick={() => {
                dispatch(setLayoutLeft());
              }}
            >
              <BackIcon />
            </BackIconWrapper>
            <Information>
              <Name>{conversation.name}</Name>
              <Description>{conversation.description}</Description>
            </Information>
            <ConversationOccupancy />
          </Body>
          <Border />
        </Wrapper>
      );
    };
    
    export { Header };

    We’ll explain the MessageList and MessageInput components in the next section.

    Send messages

    The currentConversation/MessageInput/MessageInput.tsx component renders the text input field and emoji picker.

    const MessageInput = () => {
      const [message, setMessage]: MessageFragment = useState(emptyMessage);
      const conversationId: string = useSelector(getCurrentConversationId);
      const textareaRef = useRef<HTMLTextAreaElement>(
        document.createElement("textarea")
      );
      const conversationMessageInputValue: string = useSelector(
        getConversationMessageInputValue
      );
    
    const send = () => {
        dispatch(
          sendMessageAction({
            type: "text",
            body: cleanMessage(message)
          })
        );
        dispatch(
          updateConversationMessageInputValueAction(conversationId, emptyMessage)
        );
        setMessage(emptyMessage);
      };
    
      useEffect(() => {
        setMessage(conversationMessageInputValue);
        autoExpand(textareaRef.current);
      }, [conversationId, conversationMessageInputValue]);
    
      return (
        <Wrapper>
          <EmojiSuggestion
            value={message}
            onSelection={messageWithEmoji => {
              setMessage(messageWithEmoji);
            }}
          />
          <Container>
            <TextArea
              ref={textareaRef}
              rows={1}
              value={message}
              onChange={changed}
              onKeyPress={handleKeyPress}
              placeholder="Type Message"
            />
            <EmojiInput
              value={message}
              onSelection={messageWithEmoji => {
                setMessage(messageWithEmoji);
              }}
            />
          </Container>
        </Wrapper>
      );
    };

    Send Message Command

    The sendMessageAction() method (in messages/sendMessageCommand.ts) sends your message to the other users in the conversation. It calls the sendMessage() command to publish a message to PubNub on the conversation channel. The message includes sender details.

    export const sendMessageAction = (message: MessageContent): ThunkAction => {
      return (dispatch, getState) => {
        const state = getState();
        return dispatch(
          sendMessage({
            channel: getCurrentConversationId(state),
            message: {
              content: message,
              sender: getLoggedInUserId(state)
            }
          })
        );
      };
    };

    Add emoji to a message

    The MessageInput.tsx component also includes an emoji/EmojiInput/EmojiInput.tsx component to add emoji in a message. The component uses the emoji-mart library to render emoji on the screen. If an emoji is selected, it adds that emoji to the message input.

    import "emoji-mart/css/emoji-mart.css";
    import { Picker, EmojiData } from "emoji-mart";
    
    interface EmojiInputProps {
      value: string;
      onSelection(contentWithEmoji: string): any;
    }
    
    const EmojiInput = ({ value, onSelection }: EmojiInputProps) => {
      const [showPicker, setPickerState] = useState(false);
      const picker = useRef<HTMLDivElement>(null);
    
      const dismissPicker = useCallback(() => {
        setPickerState(false);
      }, [setPickerState]);
    
      useClickOutside([picker], dismissPicker);
    
      const togglePicker = () => {
        setPickerState(!showPicker);
      };
    
      const addEmoji = (emoji: EmojiData) => {
        if ("native" in emoji) {
          onSelection(`${value}${emoji.native}`);
          dismissPicker();
        }
      };
    
      return (
        <div ref={picker}>
          <Dialog>
            {showPicker && <Picker emoji="" title="" onSelect={addEmoji} />}
          </Dialog>
          <EmojiButton onClick={togglePicker}>
            <FunnyEmoji />
          </EmojiButton>
        </div>
      );
    };
    
    export { EmojiInput };

    Receive messages

    The currentConversation/MessageList/MessageList.tsx component displays the list of messages when they are received by the app. The component calls the getCurrentConversationMessages() selector to fetch messages from the local store and render them on the screen.

    Each message that is received includes the message text as well as the id of the user who published that message. This component calls the getUsersById() selector to fetch details for the user from the local store to display it alongside the message.

    Go to the next section for more details on how a message is displayed.

    const MessageList = () => {
      type ConversationScrollPositionsType = { [conversationId: string]: number };
      const conversationId: string = useSelector(getCurrentConversationId);
      const [
        conversationsScrollPositions,
        setConversationsScrollPositions
      ] = useState<ConversationScrollPositionsType>({});
    
      const updateCurrentConversationScrollPosition = (scrollPosition: number) => {
        setConversationsScrollPositions({
          ...conversationsScrollPositions,
          [conversationId]: scrollPosition
        });
      };
    
      const handleScroll = (e: any) => {
        const scrollPosition = e.target.scrollTop;
        if (scrollPosition !== 0) {
          updateCurrentConversationScrollPosition(scrollPosition);
        }
      };
    
      const restoreConversationScrollPosition = (conversationId: string) => {
        const conversationScrollPosition: number =
          conversationsScrollPositions[conversationId];
        if (conversationScrollPosition) {
          wrapper.current.scrollTo(0, conversationScrollPosition);
        }
      };
    
      const memoizedRestoreConversationScrollPositionCallback = useCallback(
        restoreConversationScrollPosition,
        [conversationId]
      );
    
      const messages: MessageFragment[] = useSelector(
        getCurrentConversationMessages
      );
      const wrapper = useRef<HTMLDivElement>(document.createElement("div"));
      const el = wrapper.current;
    
      const scrollToBottom = useCallback(() => {
        return (el.scrollTop = el.scrollHeight - el.clientHeight);
      }, [el]);
    
      const hasReachedBottom = el.scrollHeight - el.clientHeight === el.scrollTop;
    
      useEffect(() => {
        if (hasReachedBottom) {
          scrollToBottom();
        }
      }, [messages.length, hasReachedBottom, scrollToBottom]);
    
      useEffect(() => {
        memoizedRestoreConversationScrollPositionCallback(conversationId);
      }, [memoizedRestoreConversationScrollPositionCallback, conversationId]);
    
      return (
        <Wrapper ref={wrapper} onScroll={handleScroll}>
          <WelcomeMessage />
          {messages.map(message => (
            <Message message={message} key={message.timetoken} />
          ))}
        </Wrapper>
      );
    };

    Display a message

    The Message/Message.tsx component styles a message and displays it in the UI. Messages are displayed with a timetoken, content, sender’s name and avatar. The message displays the sender as “unknown” if the user ID isn’t present in the local store.

    export interface MessageFragment {
      sender: {
        id: string;
        name: string;
      };
      timetoken: string;
      message: {
        content: {
          body: string;
        };
      };
    }
    
    interface MessageProps {
      message: MessageFragment;
      avatar?: ReactNode;
    }
    
    const Message = ({ message, avatar }: MessageProps) => {
      // show unknown sender when sender is missing
      let sender = message.sender || { id: "unknown", name: "Unknown" };
    
      return (
        <Wrapper>
          <Avatar>
            {avatar ? (
              avatar
            ) : (
              <UserInitialsAvatar size={36} name={sender.name} uuid={sender.id} />
            )}
          </Avatar>
          <Body>
            <Header>
              <SenderName>{sender.name}</SenderName>
              <TimeSent>{convertTimestampToTime(message.timetoken)}</TimeSent>
            </Header>
            <Content>{message.message.content.body}</Content>
          </Body>
        </Wrapper>
      );
    };
    
    export { Message };

    The features/messages/messageModel.ts file calls createMessageReducer(), which responds to actions dispatched to update the state of messages in the store. The message state is automatically updated and the UI change is rendered as the app receives messages in a conversation.

    /**
     * create a reducer which holds all known message envelope objects in a normalized form
     */
    export const MessageStateReducer = createMessageReducer<MessageEnvelope>();

    Conclusion

    That's it! You should now have a better understanding of how the team chat app uses the PubNub React and Redux frameworks and the underlying Javascript SDK to show user details, conversations, and send and receive messages with other users in the app. We’ll cover more features in future posts, including joining and leaving conversations, working with occupancy and network status, and profanity filtering.

    Team Chat Intro

    As next steps, go to our React, Redux and Javascript reference docs to build your realtime application with PubNub.

    More From PubNub