12 min read
.
on Mar 18, 2020
Part 2 of our react-redux chat tutorial. This covers conversation members, typing indicators and profanity filtering using PubNub React and Redux frameworks.

In part 1 of this tutorial, we introduced Team Chat, our group chat demo app. The app includes features like user login, multiple group conversations, online/offline presence status, and more. Its UI is built in React, and we're using the PubNub Redux framework to manage client state on the application. Following this approach allows you to easily add realtime chat capabilities to your own application.

In this part of the tutorial, you'll learn how more of Team Chat’s key features are put together, and how you can implement them in your own applications.

Here's what we’ll cover:

  • Joining and leaving conversations

  • Showing the members in a conversation

  • Showing typing indicators

  • Adding profanity filtering

The full GitHub repo for this project is available here.

react-chat-tutoriual-part-2-intro-gif

Join a Conversation

When you click the "+ button" on your list of joined conversations, the app displays an overlay listing all the other available conversations. Click a conversation to join it and start receiving the messages from that conversation.

join-conversations

The joinedConversations/JoinConversationDialog/JoinConversationDialog.tsx component displays the dialog with a list of conversations that are available to join. The component calls the getAllConversations selector to fetch all conversations from the local store. It also calls getConversationsByUserId selector to fetch the user’s current conversations from the local store so the list doesn't display them again.

// Fetch all conversations and remove the ones we're already a member of
const getJoinableConversations = createSelector(
  [getAllConversations, getLoggedInUserId, getConversationsByUserId],
  (
    conversations: Conversation[],
    userId: string,
    joinedConversations: MembershipHash
  ): ConversationDescriptionFragment[] => {
    return conversations.filter(conversation => {
      return !joinedConversations[userId]
        .map(conv => conv.id)
        .includes(conversation.id);
    });
  }
);

/**
 * Present list to the user of conversations that they could join, but have not.
 * Allow the user to select the conversation to join or back out.
 */
const JoinConversationDialog = () => {
  const conversations: ConversationDescriptionFragment[] = useSelector(
    getJoinableConversations
  );
  const views = useSelector(getViewStates);
  const currentUserId = useSelector(getLoggedInUserId);
  const dispatch = useDispatch();
  const themeContext = useContext(ThemeContext);
  const isSmall = useMediaQuery(themeContext.breakpoint.mediaQuery.small);

  return (
    <Overlay displayed={views.JoinConversation}>
      <Modal
        animate={views.JoinConversation ? "open" : "closed"}
        variants={getAnimatedModalVariants(isSmall)}
      >
        <Header>
          <Title>Join a Conversation</Title>
          <CloseButton
            onClick={() => {
              dispatch(joinConversationViewHidden());
            }}
          >
            <CrossIcon title="close" />
          </CloseButton>
        </Header>
        <ScrollView>
          {conversations.map(conversation => (
            <ConversationDescription
              key={`conversationDescription-${conversation.id}`}
              onClick={() => {
                const conversationId = conversation.id;
                dispatch(joinConversation(currentUserId, conversationId));
                dispatch(joinConversationViewHidden());
              }}
              conversation={conversation}
            />
          ))}
        </ScrollView>
      </Modal>
    </Overlay>
  );
};

export { JoinConversationDialog };

JoinSpaces command

The joinConversation() method (in the joinedConversations/joinConversationCommand.ts file) uses the joinSpaces command from the Redux framework to add the conversation membership for the user. It also calls pubnub.api.subscribe to subscribe to the conversation channel to start receiving messages on that channel.

export const joinConversation = (
  userId: string,
  conversationId: string
): ThunkAction<Promise<void>> => {
  return (dispatch, getState, context) => {
    return dispatch(
      joinSpaces({
        userId: userId,
        spaces: [{ id: conversationId }]
      })
    ).then(() => {
      context.pubnub.api.subscribe({
        channels: [conversationId],
        withPresence: true
      });
      dispatch(focusOnConversation(conversationId));
    });
  };
};

Leave a Conversation

Hover over a conversation in your list of joined conversations to display a leave icon. Click it, and you’ll leave the conversation. You'll no longer receiving messages sent to that conversation.

leave-conversation

The joinedConversations/MyConversations/MyConversations.tsx component displays the icon next to the user’s conversations to leave a conversation. The next section goes over the method that is called. Note that the app doesn’t allow users to leave the “Introductions” conversation, which is set as default.

import { leaveConversation } from "../leaveConversationCommand";

const MyConversations = () => {
  const currentUserId = useSelector(getLoggedInUserId);
  const conversationsById = useSelector(getConversationsById);
  const conversations: ConversationFragment[] = useSelector(
    getJoinedConversations
  );
  const currentConversationId: string = useSelector(getCurrentConversationId);
  const members: UserFragment[] = useSelector(getCurrentConversationMembers);
  const dispatch = useDispatch();
  const openOverlay = () => {
    dispatch(fetchSpaces());
    dispatch(joinConversationViewDisplayed());
  };

  return (
    <Wrapper>
      <Title>
        Conversations
      </Title>
      <ConversationList>
        {conversations.map(conversation => (
          <ConversationItem
            id={conversation.id}
            name={conversation.name}
            onLeave={() => {
              dispatch(leaveConversation(currentUserId, conversation.id));
            }}
          ></ConversationItem>
        ))}
      </ConversationList>
    </Wrapper>
  );
};

export { MyConversations };

leaveSpaces command

The leaveConversation() method (in joinedConversations/leaveConversationCommand.ts) uses the leaveSpaces command from the Redux framework to remove the conversation membership for the user. It also calls pubnub.api.unsubscribe to unsubscribe the user from the conversation channel so they stop receiving messages on that channel.

/**
 * Leave the current conversation and select the default conversation
 * as the current conversation.  (The application expects that some
 * conversation will always be current.)
 */
export const leaveConversation = (
  userId: string,
  conversationId: string
): ThunkAction<Promise<void>> => {
  return (dispatch, getState, context) => {
    if (conversationId === DEFAULT_CONVERSATION) {
      return Promise.resolve();
    }
    return dispatch(
      leaveSpaces({
        userId: userId,
        spaces: [{ id: conversationId }]
      })
    ).then(() => {
      context.pubnub.api.unsubscribe({
        channels: [conversationId]
      });
      dispatch(focusOnConversation(DEFAULT_CONVERSATION));
    });
  };
};

Show Members in Conversation

Click the membership count at the top right of a conversation, and you’ll see the conversation’s member list. Online members are placed at the top of the list and have a green dot, while offline members are below, and greyed out.

team-chat-member-list

The conversationMembers/ConversationMembers/ConversationMembers.tsx component displays the list of members that belong to the conversations. It calls the fetchMembers command to get the list of members in the conversation from PubNub and stores the members in the local store.

The component also calls the fetchHereNow command from the Redux framework. This command uses presence to indicate if members in the conversation are online. Presence allows you to track the state of users in realtime. When users are connected to the app and present in the conversation, their "present" state is indicated with a green dot. If they are away from the app, their "away" state is indicated by removing the green dot and graying out their name.

export const getCurrentConversationMembers = createSelector(
  [
    getUsersById,
    getCurrentConversationId,
    getUsersByConversationId,
    getPresenceByConversationId
  ],
  (
    users: UsersIndexedById,
    conversationId: string,
    conversationMemberships: MembershipHash,
    conversationPresence: ConversationPresence
  ): UserFragment[] => {
    let presence = conversationPresence[conversationId];
    return conversationMemberships[conversationId]
      ? conversationMemberships[conversationId].map(user => {
          return {
            ...users[user.id],
            presence: presence
              ? presence.occupants.filter(occupant => {
                  return occupant.uuid === user.id;
                }).length > 0
              : false
          };
        })
      : [];
  }
);

const orderByPresence = (members: UserFragment[]) => {
  return members.sort((userA, userB) =>
    userA.presence === userB.presence ? 0 : userA.presence ? -1 : 1
  );
};
const ConversationMembers = () => {
  const members: UserFragment[] = useSelector(getCurrentConversationMembers);
  const currentConversationId = useSelector(getCurrentConversationId);
  const dispatch = useDispatch();
  const pubnub = usePubNub();
  const views = useSelector(getViewStates);
  const themeContext = useContext(ThemeContext);
  const isSmall = useMediaQuery(themeContext.breakpoint.mediaQuery.small);

  useEffect(() => {
    if (members.length === 0) {
      dispatch(
        fetchMembers({
          spaceId: currentConversationId,
          include: {
            userFields: true,
            customUserFields: true,
            totalCount: false
          }
        })
      );

      dispatch(
        fetchHereNow({
          channels: [currentConversationId]
        })
      );
    }
  }, [members, currentConversationId, pubnub, dispatch]);

  return (
    <Wrapper
      animate={views.ConversationMembers ? "open" : "closed"}
      variants={getAnimatedWrapperVariants(isSmall)}
    >
      <Header>
        <Title>
          <BackIconWrapper
            onClick={() => {
              dispatch(conversationMembersViewHidden());
            }}
          >
            <BackIcon title="back" />
          </BackIconWrapper>
          Members
        </Title>
        <CloseIcon
          onClick={() => {
            dispatch(conversationMembersViewHidden());
          }}
        >
          <CrossIcon title="close members list" />
        </CloseIcon>
      </Header>
      <ScrollableView>
        {orderByPresence(members).map(user => (
          <MemberDescription user={user} key={user.id} />
        ))}
      </ScrollableView>
    </Wrapper>
  );
};

export { ConversationMembers };

Member details

The MemberDescription method (in conversationMembers/MemberDescription/MemberDescription.tsx) displays the name, profile image, and title of each member.

The member list also displays user presence to indicate if users are online or offline within a conversation. The app fetches the presence state from the user.presence flag in the local store. The next section provides more details on presence events.

const MemberDescription = ({ user }: MemberDescriptionProps) => {
  return (
    <Wrapper>
      <Avatar>
        <UserInitialsAvatar
          size={36}
          name={user.name}
          userId={user.id}
          muted={!user.presence}
        />
      </Avatar>
      <About>
        <UserName muted={!user.presence}>
          {user.name}{" "}
          {user.presence && <PresenceDot presence={user.presence} />}
        </UserName>
        <UserTitle muted={!user.presence}>{user.custom.title}</UserTitle>
      </About>
    </Wrapper>
  );
};

export { MemberDescription };

Presence events

The features/memberPresence/memberPresenceModel.ts file calls createPresenceReducer() reducer from the Redux framework. This automatically updates the presence state for users in the local store. The reducer responds to actions that are dispatched when presence join, leave, timeout or interval events are received by the app.

/**
 * Create a reducer to presence information for conversation members
 */
const MemberPresenceReducer = createPresenceReducer();
export { MemberPresenceReducer };

Presence occupancy

The currentConversation/ConversationOccupancy/ConversationOccupancy.tsx component uses joinedCount and presentCount to show counts of all members in the conversation and the count of members who are currently online.

The component uses the getUsersByConversationId selector to get all members from the local store and getPresenceByConversationId to get online users from the local store.

export interface ConversationOccupancyFragment {
  joinedCount: number;
  presentCount: number;
}

export const getCurrentConversationOccupancy = createSelector(
  [
    getCurrentConversationId,
    getUsersByConversationId,
    getPresenceByConversationId
  ],
  (
    currentConversationId: string,
    conversationMemberships: MembershipHash,
    conversationPresence: ConversationPresence
  ): ConversationOccupancyFragment => {
    const members = conversationMemberships[currentConversationId];
    const presence = conversationPresence[currentConversationId];
    return {
      joinedCount: members ? members.length : 0,
      presentCount: presence ? presence.occupancy : 0
    };
  }
);

const ConversationOccupancy = () => {
  const {
    joinedCount,
    presentCount
  }: ConversationOccupancyFragment = useSelector(
    getCurrentConversationOccupancy
  );
  const views = useSelector(getViewStates);
  const isConversationMembersLayoutVisible = views.ConversationMembers;
  const dispatch = useDispatch();

  return (
    <Wrapper
      highlighted={isConversationMembersLayoutVisible}
      onClick={() => {
        isConversationMembersLayoutVisible
          ? dispatch(conversationMembersViewHidden())
          : dispatch(conversationMembersViewDisplayed());
      }}
    >
      <OccupancyNumber>
        <em>{presentCount}</em> | {joinedCount}
      </OccupancyNumber>
      <IconWrapper>
        <PeopleGroupIcon
          title={
            isConversationMembersLayoutVisible
              ? "Hide members list"
              : "Show conversation members"
          }
          active={isConversationMembersLayoutVisible}
        />
      </IconWrapper>
    </Wrapper>
  );
};

export { ConversationOccupancy };

Typing Indicators

When someone is typing, the app displays a message showing the name of the user who is typing. If more than one person is typing, the message changes to reflect that multiple users are typing.

typing-indicator

The typingIndicator/TypingIndicatorDisplay/TypingIndicatorDisplay.tsx component adds logic to display typing indicators in the app. The typing indicators are displayed to all users in a conversation as a user is typing a message.

The component calls the getTypingIndicatorsById selector to get the typing indicator signals for a conversation from the local store. If the store has typing indicator signals for the conversation and the signal was triggered less than 10 seconds ago, “User is typing ...” text is displayed on the screen. If typing indicators are present from multiple users, “Multiple users typing …” text is displayed.

export interface TypingIndicatorFragment {
  sender: {
    id: string;
    name: string;
  };
  timetoken: string;
  message: TypingIndicator;
}

export const getCurrentConversationTypingIndicators = createSelector(
  [getTypingIndicatorsById, getCurrentConversationId, getUsersById, getLoggedInUserId],
  (typingIndicators, conversationId, users, loggedInUserId): TypingIndicatorFragment[] => {
    return typingIndicators[conversationId]
      ? Object.values(
          Object.values(typingIndicators[conversationId] || [])
            .filter(typingIndicator => typingIndicator.channel === conversationId )
            .reduce((grouped: {[key:string]: TypingIndicatorEnvelope}, typingIndicator) => {
              grouped[typingIndicator.publisher] = typingIndicator;
              return grouped;
            }, {})
          )
          .filter(typingIndicator => (Date.now() - (typingIndicator.timetoken / 10000)) < (TYPING_INDICATOR_DURATION_SECONDS * 1000))
          .map(
            typingIndicator => {
              return {
                ...typingIndicator,
                timetoken: String(typingIndicator.timetoken),
                sender:
                  users[typingIndicator.publisher || ''] ||
                  (typingIndicator.publisher
                    ? {
                        id: typingIndicator.publisher,
                        name: typingIndicator.publisher
                      }
                    : {
                        id: "unknown",
                        name: "unknown"
                      })
              };
            }
          )
      : [];
  }
);

/**
 * Display a Message based on its type
 */
export const TypingIndicatorDisplay = () => {

  const typingIndicators: TypingIndicatorFragment[] = useSelector(
    getCurrentConversationTypingIndicators
  );


  if (typingIndicators.length === 0) {
    return <Wrapper>&nbsp;</Wrapper>;
  } else if (typingIndicators.length === 1) {
    return <Wrapper>{typingIndicators[0].sender.name} is typing ...</Wrapper>
  } else {
    return <Wrapper>Multiple users typing ...</Wrapper>;
  }
};

Receive a typing signal

The features/typingIndicator/typingIndicatorModel.ts file calls the createSignalReducer() reducer from the Redux framework. This responds to actions dispatched when signals are received by the app. The reducer automatically updates state in the local store when it receives a signal.

The code then either stores the "typing on" signal in the store, or removes this signal from the store if an actual message was received from the sender. The signal is also removed if the sender stopped typing, which sends a "typing off" signal.

It also initiates a 10-second timer to expire each typing signal, and dispatch an action that removes the “User is typing ...” text from the screen. The expectation is that the sender of the message will trigger another typing signal if they continue typing once 10 second window elapses. In such a case, the app will continue to show that the user is typing.

const signalReducer = createSignalReducer<TypingIndicatorEnvelope>();

const defaultState = { byId: {} };

const removeTypingIndicator = (state: SignalState<TypingIndicatorEnvelope>, channel: string, userId: string, timetoken?: number): SignalState<TypingIndicatorEnvelope> => {
  let newState = {
    byId: { ...state.byId },
  };

  newState.byId[channel] = newState.byId[channel].filter(
    (signal) => timetoken ? !(signal.publisher === userId && signal.timetoken === timetoken) : !(signal.publisher === userId)
  );
  return newState;
};

/**
 * create a reducer which holds all typing indicator signal objects in a normalized form
 */
export const TypingIndicatorStateReducer = (state: SignalState<TypingIndicatorEnvelope>, action: AppActions): SignalState<TypingIndicatorEnvelope> => {
  switch (action.type) {
    case SignalActionType.SIGNAL_RECEIVED:
      if (action.payload.message.type === TypingIndicatorType.ShowTypingIndicator) {
        // we only want to store the show typing indicator signals
        // the hide signal is handled by the listener below
        return signalReducer(state, action);
      }
      
      return state || defaultState;
    case TypingIndicatorActionType.REMOVE_TYPING_INDICATOR:
      return removeTypingIndicator(state, action.payload.channel, action.payload.userId, action.payload.timetoken);
    case TypingIndicatorActionType.REMOVE_TYPING_INDICATOR_ALL:
      return removeTypingIndicator(state, action.payload.channel, action.payload.userId);
    case MessageActionType.MESSAGE_RECEIVED:
      return removeTypingIndicator(state, action.payload.channel, action.payload.message.senderId);
    default:
      return state || defaultState;
  }
};

export const typingIndicatorRemoved = (
  payload: RemoveTypingIndicatorPayload
): RemoveTypingIndicatorAction => ({
  type: TypingIndicatorActionType.REMOVE_TYPING_INDICATOR,
  payload,
});

export const typingIndicatorRemovedAll = (
  payload: RemoveTypingIndicatorAllPayload
): RemoveTypingIndicatorAllAction => ({
  type: TypingIndicatorActionType.REMOVE_TYPING_INDICATOR_ALL,
  payload,
});

/**
 * This listener will initiate a timer to dispatch a RemoveTypingIndicatorAction once the 
 * TYPING_INDICATOR_DURATION_SECONDS time is passed
 */
export const createTypingIndicatorsListener = (
  dispatch: Dispatch<AppActions>
): any => ({
  signal: (payload: TypingIndicatorEnvelope) => {
    if (payload.message.type === TypingIndicatorType.ShowTypingIndicator) {
      // hide indicator after display seconds
      setTimeout(() => {
        dispatch(typingIndicatorRemoved({
          userId: payload.publisher,
          channel: payload.channel,
          timetoken: payload.timetoken,
        }));
      }, TYPING_INDICATOR_DURATION_SECONDS * 1000);
    } else if (payload.message.type === TypingIndicatorType.HideTypingIndicator) {
      // hide indicator now, removes all for user regardless of time token
      dispatch(typingIndicatorRemovedAll({
        userId: payload.publisher,
        channel: payload.channel,
      }));
    }
  }
});

Send a typing signal

The currentConversation/MessageInput/MessageInput.tsx component dispatches actions that trigger signals as a user is typing a message. The code automatically dispatches the "typing on" signal in a conversation when a user starts typing in that conversation. If the user is still typing the message after 10 seconds, another signal is sent to indicate that the user is still typing. If the user stops typing and clears off the message from the screen, a "typing off" signal is triggered to remove the typing indicator from the screen.

const notifyTyping = () => {
    if (!typingIndicators[conversationId]) {
      typingIndicators[conversationId] = true;
      dispatch(sendTypingIndicator(TypingIndicatorType.ShowTypingIndicator));

      // allow sending additional typing indicators 1 seconds before display duration ends
      setTimeout(() => {
        typingIndicators[conversationId] = false;
      }, (TYPING_INDICATOR_DURATION_SECONDS - 1) * 1000);
    }
  };

  const notifyStopTyping = () => {
    if (typingIndicators[conversationId]) {
      typingIndicators[conversationId] = false;
      dispatch(sendTypingIndicator(TypingIndicatorType.HideTypingIndicator));
    }
  };

  const send = (appMessage: DraftMessage) => {
    dispatch(sendMessage(appMessage));
    dispatch(discardMessageDraft(conversationId));
    typingIndicators[conversationId] = false;
  };

  const update = (appMessage: DraftMessage) => {
    dispatch(updateMessageDraft(conversationId, appMessage));

    if (appMessage.text.length > 0) {
      notifyTyping();
    } else {
      notifyStopTyping();
    }
  };

SendSignal command

The typingIndicator/sendTypingIndicator.ts file calls the sendSignal command from the Redux framework to trigger typing on/off signals. These signals are sent to all users in a channel and can be used to display typing indicators in the app.

/**
 * Send a typing indicator to the current conversation
 *
 * This command does not handle failure and leaves the error to the caller
 */
export const sendTypingIndicator = (typingIndicatorType: TypingIndicatorType): ThunkAction => {
  return (dispatch, getState) => {
    const state = getState();
    return dispatch(
      sendSignal({
        channel: getCurrentConversationId(state),
        message: { type: typingIndicatorType }
      })
    );
  };
};

Filter Profanity in Messages

Filtering input is a common request, as it helps automate moderation for busy chat applications. An easy way to implement profanity filtering, in real time, is to implement a serverless PubNub Function.

profanity-filter

Adding a profanity filter lets you moderate chat messages in real time as they go over the network. The function detects objectionable words in any given text, and lets you censor the content in a variety of ways. Whenever a user publishes a message, the function processes the text for profanity, and then either blocks the publishing of the message, or censors the specific words.

Functions Overview

Functions, our serverless Function-as-a-Service platform, allows you to build your own microservices and incorporate realtime logic for routing, augmenting, filtering, transforming, and aggregating messages. PubNub Functions seamlessly handles scaling, global deployment, redundancy, and many other operations-related tasks for you.

By providing the ability to program the network, PubNub Functions simplifies deploying custom, realtime logic onto reliable, serverless architecture.

Create a Function

Go to your PubNub Dashboard and create a new module, and then create a new Before Publish function. The function should be set up to trigger on a specific set of channels (such as chat.*) or on all channels using wildcards (*).

function-setup-1

Copy the Function Code

Copy the function code that checks the contents of each message against a list of objectionable words and replaces the text. You can make changes to the dictionary and either replace the swear words, or block the entire message from being published on the channel.

Note: we’ve removed the list of profanity for the inline sample here. Get the actual version from the gist before you start this function!

export default (request) => {
    if (request.channels[0].endsWith('-pnpres') || request.channels[0].startsWith("blocks-output") ) {
        return request.ok();
    }

// The code in the linked gist has the real list of
// profanity to be blocked. Don’t use this code as-is!

var badWords =new RegExp(/.*\b(hello|world)\b.*/,"i");
    
    //Option 1 - Replace profanity text with * and allow the publish
    if(request.message && request.message.text && badWords.test(request.message.text)){
        var newString = request.message.text;
        newString = newString.replace(badWords, "***");
        request.message.text = newString;
        return request.ok(); // Return a promise when you're done
    }
    
    //Option 2 - Block message and return a publish error if the text includes profanity
    /*if(badWords.test(request["message"]["text"])) {
        console.log('moderated message: ' + request["message"]["text"]);
        return request.abort("moderated message")
        return request.ok();
    }*/
    
    
    return request.ok(); // Return a promise when you're done
};

Start the Function

Click "Start Module" to start the Function, and test it using the "Publish" button and payload field on the left.

Your app should now be ready to detect profanity in messages. From here you can block the message, filter out the bad parts, send a note to the offending user, or ban them completely. How you use this information is up to you and your application.

Other filtering use cases

As you can see, this is a simple but flexible approach. The dictionary-driven approach works well with a well-defined list of words, but using a regular expression lets you work with much more complex patterns, such as screening for particular types of personally-identifiable information in a HIPAA-compliant environment.

Conclusion

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 join and leave conversations, work with occupancy and presence status, and to filter profanity in messages using PubNub Functions.

Go to our React, Redux and Javascript reference docs to build your realtime application with PubNub.

More From PubNub