Cat_Enthusiast
Cat_Enthusiast

Reputation: 15688

React. Chatkit API. MessageList component bug - rendering messages from other rooms. Component Lifecycle and state

I'm experiencing some strange activity with my Chatkit app built using React. Essentially, I'm testing with two different users in different rooms. When I send a message from a user in one room. The other user is able to see that message, although they are not in the same room. Here's a screenshot of what's going on.

This only appears to happen when the users have been in the same room at least once.

Buggy Chat

I can tell the messages are being created correctly because I see them in the right place in the ChatKit API. Also, if I re-render the component, the messages end up in the right place. But The cross-room messaging bug still persists.

Corrected Chat

I'm under the impression that it definitely has something to do with the state of the MessageList component. I've made sure to update the component state every time we enter a new room, but I suppose the real question is whether or not other instances of the applications even care about the change in component state for a different instance.

So without further ado, here is my code:

ChatScreen (Main app)

import React from "react"
import Chatkit from "@pusher/chatkit"
import MessageList from "./MessageList"
import SendMessageForm from "./SendMessageForm"
import WhosOnlineList from "./WhosOnlineList"
import RoomList from "./RoomList"
import NewRoomForm from "./NewRoomForm"
import { getCurrentRoom } from "../../actions/chatkitActions"
import { connect } from "react-redux"

class ChatScreen extends React.Component{
    constructor(props){
        super(props)
        this.state = {
            messages: [],
            currentRoom: {},
            currentUser: {},
            usersWhoAreTyping: [],
            joinableRooms: [],
            joinedRooms: [],
            errors: {}
        }

        this.sendMessage = this.sendMessage.bind(this)
        this.sendTypingEvent = this.sendTypingEvent.bind(this)
        this.subscribeToRoom = this.subscribeToRoom.bind(this)
        this.getRooms = this.getRooms.bind(this)
        this.createRoom = this.createRoom.bind(this)
    }

    componentDidMount(){
        //setup Chatkit
        let tokenUrl
        let instanceLocator = "somecode"
        if(process.env.NODE_ENV === "production"){
            tokenUrl = "somenedpoint"
        } else {
            tokenUrl = "http://localhost:3000/api/channels/authenticate"
        }

        const chatManager = new Chatkit.ChatManager({
            instanceLocator: instanceLocator,
            userId: this.props.chatUser.name,
            connectionTimeout: 120000,
            tokenProvider: new Chatkit.TokenProvider({
                url: tokenUrl
            })
        })

        //initiate Chatkit
        chatManager.connect()
            .then((currentUser) => {
                this.setState({
                    currentUser: currentUser
                })
                //get all rooms
                this.getRooms()

                // if the user is returning to the chat, direct them to the room they last visited
                if(this.props.chatkit.currentRoom.id > 0){
                    this.subscribeToRoom(this.props.chatkit.currentRoom.id)
                }
            })
    }

    sendMessage = (text) => {
        this.state.currentUser.sendMessage({
            roomId: this.state.currentRoom.id,
            text: text
        })
    }

    sendTypingEvent = () => {
        this.state.currentUser
            .isTypingIn({
                roomId: this.state.currentRoom.id
            })
            .catch((errors) => {
                this.setState({
                    errors: errors
                })
            })
    }

    getRooms = () => {
        this.state.currentUser.getJoinableRooms()
            .then((joinableRooms) => {
                this.setState({
                    joinableRooms: joinableRooms,
                    joinedRooms: this.state.currentUser.rooms
                })
            })
            .catch((errors) => {
                this.setState({
                    errors: { error: "could not retrieve rooms"}
                })
            })
    }

    subscribeToRoom = (roomId) => {
        this.setState({
            messages: []
        })
        this.state.currentUser.subscribeToRoom({
            roomId: roomId,
            hooks: {
                onNewMessage: (message) => {
                    this.setState({
                        messages: [...this.state.messages, message]
                    })
                },
                onUserStartedTyping: (currentUser) => {
                    this.setState({
                        usersWhoAreTyping: [...this.state.usersWhoAreTyping, currentUser.name]
                    })
                },
                onUserStoppedTyping: (currentUser) => {
                    this.setState({
                        usersWhoAreTyping: this.state.usersWhoAreTyping.filter((user) => {
                            return user !== currentUser.name
                        })
                    })
                },
                onUserCameOnline: () => this.forceUpdate(),
                onUserWentOffline: () => this.forceUpdate(),
                onUserJoined: () => this.forceUpdate()
            }           
        })
        .then((currentRoom) => {
            this.setState({
                currentRoom: currentRoom
            })
            this.getRooms()
            //store currentRoom in redux state
            this.props.getCurrentRoom(currentRoom)
        })
        .catch((errors) => {
            this.setState({
                errors: errors
            })
        })
    }

    createRoom = (roomName) => {
        this.state.currentUser.createRoom({
            name: roomName
        })
        .then((newRoom) => {
            this.subscribeToRoom(newRoom.id)
        })
        .catch((errors) => {
            this.setState({
                errors: { error: "could not create room" }
            })
        })
    }

    render(){
        const username = this.props.chatUser.name
        return(
            <div className="container" style={{ display: "flex", fontFamily: "Montserrat", height: "100vh"}}>
                <div 
                    className="col-md-3 bg-dark mr-2 p-0" 
                    style={{display: "flex", flexDirection: "column", maxHeight: "80vh", padding: "24px 24px 0px"}}
                >
                    <div style={{flex: "1"}} className="p-4">
                        <WhosOnlineList users={this.state.currentRoom.users}/>
                        <RoomList
                            roomId={this.state.currentRoom.id} 
                            rooms={[...this.state.joinedRooms, ...this.state.joinableRooms]}
                            subscribeToRoom={this.subscribeToRoom}
                        />
                    </div>
                    <NewRoomForm createRoom={this.createRoom} user={this.state.currentUser}/>
                </div>

                <div 
                    className="col-md-9 border p-0" 
                    style={{display: "flex", flexDirection: "column", maxHeight: "80vh"}}
                >
                    <div className="mb-3">
                        { this.state.currentRoom.name ? (
                            <h4 
                                className="bg-black text-light m-0" 
                                style={{padding: "1.0rem 1.2rem"}}
                            >
                                {this.state.currentRoom.name}
                            </h4>
                            ) : ( 
                            this.props.chatkit.currentRoom.id > 0 ) ? ( 
                                <h3 className="text-dark p-4">Returning to room...</h3>
                            ) : (
                                <h3 className="text-dark p-4">&larr; Join a Room!</h3>
                        )}
                    </div>
                    <div style={{flex: "1"}}>
                        <MessageList messages={this.state.messages} room={this.state.currentRoom.id} usersWhoAreTyping={this.state.usersWhoAreTyping}/>
                    </div>
                    <SendMessageForm 
                        sendMessage={this.sendMessage}
                        userTyping={this.sendTypingEvent} 
                        currentRoom={this.state.currentRoom}
                    />
                </div>
            </div>
        )
    }
}

const mapStateToProps = (state) => {
    return{
        chatkit: state.chatkit
    }
}

const mapDispatchToProps = (dispatch) => {
    return{
        getCurrentRoom: (currentRoom) => {
            dispatch(getCurrentRoom(currentRoom))
        }
    }
}

export default connect(mapStateToProps, mapDispatchToProps)(ChatScreen)

MessageList (component)

import React from "react"
import ReactDOM from "react-dom"
import TypingIndicator from "./TypingIndicator"

class MessageList extends React.Component{
    constructor(props){
        super(props)
        this.state = {
            currentRoom: {}
        }
    }

    componentWillReceiveProps(nextProps){
        if(nextProps.room){
            console.log(nextProps.room)
            this.setState({
                currentRoom: nextProps.room
            })
        }
    }

    componentWillUpdate(){
        const node = ReactDOM.findDOMNode(this)
        //scrollTop is the distance from the top. clientHeight is the visible height. scrollHeight is the height on the component
        this.shouldScrollToBottom = node.scrollTop + node.clientHeight + 100 >= node.scrollHeight
    }

    componentDidUpdate(){
        //scroll to the bottom if we are close to the bottom of the component
        if(this.shouldScrollToBottom){
            const node = ReactDOM.findDOMNode(this)
            node.scrollTop = node.scrollHeight
        }
    }

    render(){
        const messages = this.props.messages
        let updatedMessages = []
        for(var i = 0; i < messages.length; i++){
            let previous = {}
            if(i > 0){
                previous = messages[i - 1]
            }
            if(messages[i].senderId === previous.senderId){
                updatedMessages.push({...messages[i], senderId: ""})
            } else{
                updatedMessages.push(messages[i])
            }
        }
        return(
            <div>
                {this.props.room && (
                    <div style={{overflow: "scroll", overflowX: "hidden", maxHeight: "65vh"}}>
                        <ul style={{listStyle: "none"}} className="p-3">
                            {updatedMessages.map((message, index) => {
                                return (
                                    <li className="mb-1" key={index}>
                                        <div>
                                            {message.senderId && (
                                                <span 
                                                    className="text-dark d-block font-weight-bold mt-3"
                                                >
                                                    {message.senderId}
                                                </span>
                                            )}
                                            <span 
                                                className="bg-info text-light rounded d-inline-block"
                                                style={{padding:".25rem .5rem"}}
                                            >
                                                {message.text}
                                            </span>
                                        </div>
                                    </li>
                                )
                            })}
                        </ul>
                        <TypingIndicator usersWhoAreTyping={this.props.usersWhoAreTyping}/>
                    </div>
                )}
            </div>
        )
    }
}

export default MessageList

RoomList (component)

import React from "react"

class RoomList extends React.Component{
    render(){
        const orderedRooms = [...this.props.rooms].sort((a, b) => {
            return a.id - b.id
        })
        return(
            <div>
                { this.props.rooms.length > 0 ? (
                    <div>
                        <div className="d-flex justify-content-between text-light mb-2">
                            <h6 className="font-weight-bold">Channels</h6><i className="fa fa-gamepad"></i>
                        </div>
                        <ul style={{listStyle: "none", overflow: "scroll", overflowX: "hidden", maxHeight: "27vh"}} className="p-2">
                            {orderedRooms.map((room, index) => {
                                return(
                                    <li key={index} className="font-weight-bold mb-2">
                                        <a  
                                            onClick={() => {
                                                this.props.subscribeToRoom(room.id)
                                            }} 
                                            href="#"
                                            className={room.id === this.props.roomId ? "text-success": "text-info"}
                                            style={{textDecoration: "none"}}
                                        >
                                            <span className="mr-2">#</span>{room.name}
                                        </a>
                                    </li>
                                )
                            })}
                        </ul>               
                    </div> 
                ) : (
                    <p className="text-muted p-2">Loading...</p>
                )}
            </div>
        )
    }
}

Here's the component (ChannelsContainer) that's rendering the ChatScreen as well

import React from "react"
import UsernameForm from "./UsernameForm"
import ChatScreen from "./ChatScreen"
import { connect } from "react-redux"

class ChannelsContainer extends React.Component{
    constructor(props){
        super(props)
        this.state = {
            chatScreen: false
        }
    }

    componentWillMount(){
        if(this.props.chatkit.chatInitialized){
            this.setState({
                chatScreen: true
            })
        }
    }

    componentWillReceiveProps(nextProps){
        if(nextProps.chatkit.chatInitialized){
            this.setState({
                chatScreen: true
            })
        }
    }

    render(){
        let chatStage
        if(this.state.chatScreen){
            chatStage = <ChatScreen chatUser={this.props.chatkit.chatUser}/>
        } else{
            chatStage = <UsernameForm/>
        }
        return(
            <div style={{minHeight: "90vh"}}>
                {chatStage}
            </div>
        )
    }
}

const mapStateToProps = (state) => {
    return{
        chatkit: state.chatkit
    }
}

export default connect(mapStateToProps)(ChannelsContainer)

Please let me know what you guys think.

Upvotes: 4

Views: 280

Answers (1)

Cat_Enthusiast
Cat_Enthusiast

Reputation: 15688

Fixed. All I had to do was compare room id of the message against the current room id. If they're the same, then I'll update my component state messages field.

onNewMessage: (message) => {
  if(message.room.id === this.state.currentRoom.id){
    this.setState({
      messages: [...this.state.messages, message]
    })                      
  }
}

Upvotes: 2

Related Questions