[solved] Chat type indicator doesn't appear for all clients

Hi,

I’m working on a writing indicator, which shows up next to the name on the online liste, whenever the user writes something inside the textarea. Problem is, it only shows up for the person that writes but not for other tabs/clients.

Here is my code:

Online list template: imports/ui/components/chat/chat_onlinelist.html

<template name="onlineliste">
  <div  id="online-liste" class="onlineList">
    {{#each characters}}
  <div class="characterBasicInteraction" oncontextmenu="return false;">
    <span class="typeIndicator" id="typeInd_{{name}}">✎</span>
 <!-- TypeIndicator shows up here, code gets id with this.name 
and uses it to change jquery fadeIn/fadeOut --> 
    <a href="/c/{{name}}" target="_blank" class="{{name}}" {{checkIfFriend}}><li>{{name}}</li></a>
    <div id="panel_{{name}}" class="panelChat">
      <span><b>{{name}}</b></span>      
      <span id="whisp">FlΓΌstern</span>      
      {{{checkIfFriendButton}}}
      <span>Bookmark</span>
      <span>Ignore</span>
      <span>Report</span>  
    </div>
  </div>
     {{/each}}
</div>
</template>

Now I tried 3 approaches so far, all had the same outcome as mentioned above.

First Approach, Event keydown textarea in imports/ui/components/chat/chat.js

 'keydown textarea'(event, template) {
      var character = Session.get("activeChar");
      var getIndicator = "#typeInd_"+character;

        //setup before functions
      var typingTimer;                //timer identifier
      var doneTypingInterval = 5000;  //time in ms (5 seconds)
        
        //on keyup, start the countdown
        template.$('textarea').keyup(function(){
            clearTimeout(typingTimer);
            if (template.$('textarea').val()) {
                typingTimer = setTimeout(doneTyping, doneTypingInterval);
            }
        });
        
        //user is "finished typing,"
        function doneTyping () {
          template.$(getIndicator).fadeOut(); 
        }

      template.$(getIndicator).fadeIn();
      template.$(".input-box_text").focusout(function() {
      template.$(getIndicator).fadeOut(); 
      })
        
    }

Approach 2: Writing the function in imports/api/chat/chat.js to have it on the server (?) and load it inside imports/ui/components/chat/chat.js

 typeIndicator = function typeIndicator (character, getIndicator) {
  var typingTimer;                //timer identifier
  var doneTypingInterval = 5000;  //time in ms (5 seconds)
  
  //on keyup, start the countdown
  $('textarea').keyup(function(){
      clearTimeout(typingTimer);
      if ($('textarea').val()) {
          typingTimer = setTimeout(doneTyping, doneTypingInterval);
      }
  });
  
  //user is "finished typing," do something
  function doneTyping () {
   $(getIndicator).fadeOut(); 
  }

//$(getIndicator).fadeIn();
$(getIndicator).fadeIn();

$(".input-box_text").focusout(function() {
$(getIndicator).fadeOut(); 
});

};

    'keydown textarea'(event, template) {
      var character = Session.get("activeChar");
      var getIndicator = "#typeInd_"+character;

     TypeIndicator(character, getIndicator); 
}

Approach 3: Basically the same as 2 but this time I didn’t use the blaze-template.events helper

document.addEventListener("keydown", event => {
  var character = Session.get("activeChar");
  var getIndicator = "#typeInd_"+character;
  typeIndicator(character, getIndicator);
  
});

So it looks like those changes do nothing different. Can someone help? Thank you!

From your different approaches, it seems there’s a fundamental misunderstanding of web applications and isomorphic javascript.

Each client loads and runs the client version of your app individually. They run in an isolated environment (a browser tab).
When you run code in that application, it is only able to affect itself.
For example, your current approaches all look like this:

     β”Œβ”€β”€β”€β”€β”€β”
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β” β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client β”‚ β”‚ β”‚ Client β”‚  β”‚ Client β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     β””β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Server β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The client is only talking to itself. Which is why the other clients don’t update the chat indicator.
(Note that Session is also isolated to each client instance)

What we want is for the client to tell the server that the status has changed and then the server can tell the other clients. Those clients can then update their ui in response to the change:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client β”‚  β”‚ Client β”‚  β”‚ Client β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   β”‚   β–²         β–²           β–²
   β–Ό   β”‚         β”‚           β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚           β”‚
β”‚ Server β”‚β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜

For that individual instance of your application to communicate to the server or other instances, it needs to make a network request.
This is usually a HTTP request (eg XHR, fetch, jquery $.http), though in Meteor’s case, we use DDP over websockets.
(Note, you can have the clients talk to each other directly, but true p2p is much more complicated)

In Meteor, the recommended way to communicate to the server is using Meteor.methods.
And the recommended way to send data to clients in real time is using pub/sub with Mongo Collections.
When a client subscribes to a data feed that the server is publishing, the server will send updates to the client through websockets.

To do this for your chat indicator problem, lets create a collection with chat statuses, set up a method, and pub/sub

import { Meteor } from "meteor/meteor";
import { Mongo } from "meteor/mongo";
// Create the collection
export const ChatStatus = new Mongo.Collection("chat-status");

// Set up the method
Meteor.methods({
  updateStatus(characterId, status) {
    ChatStatus.upsert({
      characterId: characterId,
      status: status,
    });
  },
});
// Publications are server-only
if (Meteor.isServer) {
  Meteor.publish("chat-status", function() {
    return ChatStatus.find();
  });
}
// Subscriptions are client only. Normally you would put this in template code, not here.
if (Meteor.isClient) {
  Meteor.subscribe("chat-status");
}

Because we want to access this data on the server (for long-term storage) and the client (so we can show the indicator), lets place it in:
/both/chat-status.js
And import that file (import '/both/chat-status.js') into
/client/main.js
and
/server/main.js

This is what is meant by isomorphic. We write the code in one place, and load it on both the server and client.

Now lets give your template access to the collection by importing it and add a helper to check if the status is editing

// chat.js;
import { ChatStatus } from "/both/chat-status.js";

Template.chat_onlinelist.helpers({
  isEditing: function(characterId) {
    const document = ChatStatus.findOne({ characterId: characterId });
    if (document) {
      return document.status === "editing";
    } else {
      return false;
    }
  },
});

And update the template code to use the new helper:

{{#each characters}}
  <div class="characterBasicInteraction" oncontextmenu="return false;">
    {{#if isEditing _id }}
    <span class="typeIndicator" id="typeInd_{{name}}">✎</span>
    {{/if}}
 <!-- TypeIndicator shows up here, code gets id with this.name
and uses it to change jquery fadeIn/fadeOut -->
    <a href="/c/{{name}}" target="_blank" class="{{name}}" {{checkIfFriend}}><li>{{name}}</li></a>
    <div id="panel_{{name}}" class="panelChat">
      <span><b>{{name}}</b></span>
      <span id="whisp">FlΓΌstern</span>
      {{{checkIfFriendButton}}}
      <span>Bookmark</span>
      <span>Ignore</span>
      <span>Report</span>
    </div>
  </div>
{{/each}}

Now the template code for all clients depends on the value inside the database.
The last thing is to update that value in the database by calling the meteor method:

Template.chat_onlinelist.events({
  "keydown textarea"(event, templateInstance) {
    var character = Session.get("activeChar");
    var doneTypingInterval = 5000; //time in ms (5 seconds)
    // Assuming that you're storing the whole document?
    Meteor.call("update-status", character._id, "editing");
    // set the timer identifier on the template instance so we can get it later
    templateInstance.typingTimer = setTimeout(() => {
      // use empty string to denote no status
      Meteor.call("update-status", character._id, "");
    }, doneTypingInterval);
  },
  "focusout textarea"(event, templateInstance) {
    if (templateInstance.typingTimer) {
      clearTimeout(templateInstance.typingTimer);
    }
    Meteor.call("update-status", character._id, "");
  },
});

So now the value in the database is set by events, and the UI is a representation of the state in the database.


Note: I’ve oversimplified a few things, written the code from memory, and dropped the fadeIn, fadeOut behaviour from before.
Please edit the code to your needs and I recommend doing animations with a package like:
gwendall:template-animations, or
gwendall:ui-hooks

6 Likes

Thank you very much. I really need a lot of that basic knowledge! However, here is my slightly edited code of your instructions:

Regarding method, I changed it that way:

  'update-status'(characterId, status) {
    ChatStatus.update({characterId: characterId}, {
      $set: {
      characterId: characterId,
      status: status,
      },
     },
     {upsert:true}
     );
  },

The event that calls the method I changed too - I took the activeChar-session which saves the name of the character and replaced it with the character_id. In my app you can create β€˜characters’ and log in with any of them. If you log in the session sets the value to the character you chose - so that I can later do more stuff with it like all kind of queries regarding this logged in character.

  "keydown textarea"(event, templateInstance) {
    var character = Session.get("activeChar");
    var doneTypingInterval = 5000; //time in ms (5 seconds)
    // Assuming that you're storing the whole document?
     Meteor.call('update-status', character, "editing", (error) => { if (error) { alert(error.reason); } });

    // set the timer identifier on the template instance so we can get it later
    templateInstance.typingTimer = setTimeout(() => {
      // use empty string to denote no status
      Meteor.call("update-status", character, "");
    }, doneTypingInterval);
  },
  
  "focusout textarea"(event, templateInstance) {
    if (templateInstance.typingTimer) {
      clearTimeout(templateInstance.typingTimer);
    }
    var character = Session.get("activeChar");

    Meteor.call("update-status", character, "");
  },

Regarding // Assuming that you're storing the whole document?.
By storing, do you mean the subscription of the character-publication? If thats the case, yes, it’s the whole characters document. Using character._id confuses me though. How would the app know which ._id to take? In my chatroom I use a subscription of a collection called characters, where all present characters are.

But I was wondering for a while if there is a way to retrieve a document id, for example, with something like characters._id, just by having those documents subscribed? (Or was it a placeholder?)

The only way I know about retrieving data from collections right now is only by using β€œthis._id” inside an each-iteration. When I use findOne and displaying it with {{characters.name}} I already wouldn’t know how to do it.

Second question would be, since you mentioned p2p: What exactly would be the advantages of p2p if you compare it to the clients talking to each other with websockets? Regarding a chat with chatrooms where multiple people can write to each other and all that social media stuff? This is more or less what I’m doing, it’s just more a platform for literature, art and roleplay - not a facebook-clone or anything similar.

I mean in the Session value here:

Session.get("activeChar");

But, from your edit it looks like you store the Id in session so it’s all good.

Subscribing just adds the data to the client version of the database. So when you call Characters.find on the client it uses the data that came through the subscription. By limiting what comes through the subscription, you can limit what each user can see.
This article is a great explanation of what happens to data in Meteor: https://medium.com/meteor-js/data-flow-from-the-database-to-the-ui-three-layers-of-meteor-d5e208b466c3

The main advantage is that it reduces the load on your server as the server won’t need to send a copy of the message to each client.
But it really is very complicated. Pretty much nobody does it except for stuff where performance is critical (skype, screen sharing, remote desktop). Facebook, iMessage, WhatsApp, Slack, IRC all don’t bother and have the clients talk to servers instead

2 Likes

I’m sorry to reopen this again but I experienced something odd during testing the writing indicator.

"keydown textarea"(event, templateInstance) {
    var character = Session.get("activeChar");
    var doneTypingInterval = 5000; //time in ms (5 seconds)
    // Assuming that you're storing the whole document?
     Meteor.call('update-status', character, "editing", (error) => { if (error) { alert(error.reason); } });

    // set the timer identifier on the template instance so we can get it later
    templateInstance.typingTimer = setTimeout(() => {
      // use empty string to denote no status
      Meteor.call("update-status", character, "");
    }, doneTypingInterval);
  },

The timeout function doesn’t seem to work right with the doneTypingInterval -> sometimes it’ll wait those 5 seconds, sometimes it calls the Meteor method immediately, causing a flickering of that indicator while writing longer texts.

I tried to change the timeout with an interval but same result. Then I put a console.log(β€œeh?”); in the setTimeout. Two first timeouts work fine but then


it shoots up in one second. The reason for this seems to be that for every keydown I do it sets up a new timeout. Also it’s activated during the whole time period pressing a keydown and not letting go. Maybe keyup is better for this?

keyup would have the same problem, it just happens slightly later than keydown.
Your original code was more correct by clearing the timeout when a new key is pressed. So lets copy that in from the earlier code:

"keydown textarea"(event, templateInstance) {
    var character = Session.get("activeChar");
    var doneTypingInterval = 5000; //time in ms (5 seconds)
    // Assuming that you're storing the whole document?
     Meteor.call('update-status', character, "editing", (error) => { if (error) { alert(error.reason); } });

    // Clear the timeout if it exists so we don't get a separate update for each keypress
    clearTimeout(templateInstance.typingTimer);
    // set the timer identifier on the template instance so we can get it later
    templateInstance.typingTimer = setTimeout(() => {
      // use empty string to denote no status
      Meteor.call("update-status", character, "");
    }, doneTypingInterval);
  },

I missed that detail when I was writing up my answer!

1 Like

Oh right, sorry. I blanked that out there. Thank you!