Stimulus Discourse

How to create a typing indicator with ActionCable

I’m building a chatroom alongside Actioncable and was stumped on how I can implement a typing indicator, along the lines of “Taylor started typing…”. I’ve managed to get it setup in with a Stimulus controller to listen when someone starts typing in the input field, and broadcast across the channel.

initChannel() {

this.startedTyping = this.startedTyping.bind(this)
this.inputTarget.addEventListener('keydown', this.startedTyping)

this.stoppedTyping = this.stoppedTyping.bind(this)
this.inputTarget.addEventListener('blur', this.stoppedTyping)

}

startedTyping() {

this.channel.perform('typing', { chatroom_id: chatroom_id, typing: 'started' } )

}

This works fine and dandy but it results in ActionCable broadcasting a message for every single keystroke. It’s not bad, and not that it doesn’t work but it seems that it’s overkill to perform this for every keystroke, rather broadcast across ActionCable only once. Looking at Basecamp, there is one broadcast when a person begins typing, and one when they stop - they toggle the typing indicator based on these actions.

18:43:36 web.1 | ChatroomsChannel#typing({“chatroom_id”=>“2”, “typing”=>“started”})
18:43:36 web.1 | [ActionCable] Broadcasting to chatrooms:2: {:typing=>“started”, :user_name=>“Taylor Cooney”, :chatroom_id=>“2”}
18:43:36 web.1 | ChatroomsChannel transmitting {“typing”=>“started”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:36 web.1 | ChatroomsChannel transmitting {“typing”=>“started”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:36 web.1 | ChatroomsChannel#typing({“chatroom_id”=>“2”, “typing”=>“started”})
18:43:36 web.1 | [ActionCable] Broadcasting to chatrooms:2: {:typing=>“started”, :user_name=>“Taylor Cooney”, :chatroom_id=>“2”}
18:43:36 web.1 | ChatroomsChannel transmitting {“typing”=>“started”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:36 web.1 | ChatroomsChannel transmitting {“typing”=>“started”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:36 web.1 | ChatroomsChannel#typing({“chatroom_id”=>“2”, “typing”=>“started”})
18:43:36 web.1 | [ActionCable] Broadcasting to chatrooms:2: {:typing=>“started”, :user_name=>“Taylor Cooney”, :chatroom_id=>“2”}
18:43:36 web.1 | ChatroomsChannel transmitting {“typing”=>“started”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:36 web.1 | ChatroomsChannel transmitting {“typing”=>“started”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:36 web.1 | ChatroomsChannel#typing({“chatroom_id”=>“2”, “typing”=>“started”})
18:43:36 web.1 | [ActionCable] Broadcasting to chatrooms:2: {:typing=>“started”, :user_name=>“Taylor Cooney”, :chatroom_id=>“2”}
18:43:36 web.1 | ChatroomsChannel transmitting {“typing”=>“started”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:36 web.1 | ChatroomsChannel transmitting {“typing”=>“started”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:37 web.1 | ChatroomsChannel#typing({“chatroom_id”=>“2”, “typing”=>“stopped”})
18:43:37 web.1 | [ActionCable] Broadcasting to chatrooms:2: {:typing=>“stopped”, :user_name=>“Taylor Cooney”, :chatroom_id=>“2”}
18:43:37 web.1 | ChatroomsChannel transmitting {“typing”=>“stopped”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)
18:43:37 web.1 | ChatroomsChannel transmitting {“typing”=>“stopped”, “user_name”=>“Taylor Cooney”, “chatroom_id”=>“2”} (via streamed from chatrooms:2)

1 Like

Hey @tbcooney!

You’ll want to keep some state here so you can conditionally broadcast or not. Another tip might be to set a timeout so that if the person doesn’t type anything for a period of time, it doesn’t continue to indicate that they’re typing. An implicit stop, if you will.

Example:

initChannel() 
  this.typingHandler = this.typing.bind(this)
  this.inputTarget.addEventListener('keydown', this.typingHandler)

  this.stoppedTyping = this.stoppedTyping.bind(this)
  this.inputTarget.addEventListener('blur', this.stoppedTyping)
}

typing() {
  // Don't broadcast if we're already typing
  if(!this.isTyping) {
    this.isTyping = true
    this.channel.perform('typing', { chatroom_id: chatroom_id, typing: 'started' } )
  }

  // Do this no matter what so it resets the timer
  this.startTypingTimer() 
}

stoppedTyping() {
  this.isTyping = false
  this.stopTypingTimer()
  this.channel.perform('typing', { chatroom_id: chatroom_id, typing: 'stopped' } )
}

startTypingTimer() {
  // Clear the old timer or it'll still fire after 10 seconds. We're effectively resetting the timer.
  this.stopTypingTimer()
  // No need to save a reference to bound function since we don't need to reference it to stop the timer.
  // After 10 seconds of not typing, don't consider the user to be typing
  this.typingTimeoutID = setTimeout(this.stoppedTyping.bind(this, 10000)
}

stopTypingTimer() {
  if(this.typingTimeoutID) {
    clearTimeout(this.typingTimeoutID)
  }
}

Hope this helps!

1 Like

Very cool, to clarify this is the state, correct?

this.isTyping = true

And then you set a guard previously to check whether or not that state is true. Learned something new, thanks welearnednothing. There is a joke somewhere there, but I’m too tried…

Correct! I should have pointed that out :wink: Instead, I threw in a bunch of extra code that wasn’t directly part of your question. That was a test. You passed! HUZZAH! :tada:

Keep us posted on how the chat app goes, sounds like good stuff.

Cheers!

Thanks man! I understand this is more a JS-related question than Stimulus-specific but I got an error this.stopTypingTimer is not a function on the first try with this. If I removed () the brackets from all of the subsequent references to this.startTypingTimer() it works. Could you shed a little more context on that for me?

typing() {
    // Don't broadcast if we're already typing
    if(!this.isTyping) {
      this.isTyping = true
      this.channel.perform('typing', { chatroom_id: chatroom_id, typing: 'started' } )
    }

    // Do this no matter what so it resets the timer
    this.startTypingTimer()
  }

  stoppedTyping() {
    this.isTyping = false
    this.stopTypingTimer
    this.channel.perform('typing', { chatroom_id: chatroom_id, typing: 'stopped' } )
  }

  startTypingTimer() {
    // Clear the old timer or it'll still fire after 10 seconds.
    this.stopTypingTimer
    // No need to save a reference to bound function since we don't need to reference it
    // to stop the timer.
    this.stopTypingTimer = setTimeout(this.stoppedTyping.bind(this), 10000)
  }

  stopTypingTimer() {
    if(!this.stopTypingTimeout) {
      clearTimeout(this.stopTypingTimeout)
      // After 10 seconds of not typing, don't consider the user to be typing
      this.stopTypingTimeout = setTimeout(this.stoppedTyping.bind(this), 10000)
    }
  }

Doh, In startTypingTimer() I had this:

this.stopTypingTimer = setTimeout(this.stoppedTyping.bind(this, 10000)

which should be this:

this.stopTypingTimeout = setTimeout(this.stoppedTyping.bind(this, 10000)

There’s stopTypingTimer() which is a function and stopTypingTimeout which is the ID returned from setTimeout() and needed by clearTimeout(). Different naming would be recommended there, those two are a little close for comfort. Maybe typingTimerID in place of stopTypingTimeout would be more clear and help prevent silly mistakes like that.

I also had this line erroneously duplicated in stopTypingTimer():
this.stopTypingTimeout = setTimeout(this.stoppedTyping.bind(this), 10000)

I updated my original code to correct these, hopefully it’s good now. Sorry about that!

I found one more negation bug. This is what I get for hurrying and not proof reading! You’re better off just copy/pasting the entire code block at this point :slight_smile:

Thanks for that clarification. I’ve got everything working nice and smoothly now

1 Like

@tbcooney you also might want to check out CableReady, which is incredibly useful for building reactive applications with Rails and Stimulus. We finally have a video walkthrough up now, too.

1 Like