vue.jsとFirebaseのRealtime DatabaseとAuthorization機能を利用したSlackクローンの構築を複数回にわけて行っています。5回目となる今回はユーザ登録を行ったユーザ間でダイレクトメッセージの送受信を行う機能を実装します。この文書を読み終えるとチャット機能の実装方法を理解することができます。

Firebaseへのアクセスが必要になってくるのでFirebaseの基礎については第1回の文書を参考にしてください。

ユーザの登録処理

データベースへのユーザ情報の保存

Firebaseのユーザの認証と管理を使ってユーザの登録を行っていましたが、それとは別にFirebaseのデータベースにもユーザの情報を保存します。

データベースへのユーザ情報はRegister.vueファイルでFirebaseへのユーザ登録が完了した後に保存します。

ユーザ情報はデータベースのusersという場所の下に保存していきます。Register.vueファイルのregisterUserメソッドの更新を行います。

MySQLなどのリレーショナルデータベースに慣れている人であれば事前にusersのテーブルなどを作成する必要があるとおもうかもしれませんが、その必要はありません。

ユーザ情報の保存処理

ユーザ情報の保存のコードを説明していきます。Promiseを使っているので入れ子になっていることもあり少し長いですがシンプルなコードなので安心して読み進めてください。

  1. firebaseのcreateUserWithEmailAndPasswordメソッドでFirebaseへのユーザの登録を行います。
  2. createUserWithEmailAndPasswordメソッドのresponseデータに含まれるユーザ情報を使ってデータベースのusersという場所にuser_idとemailを保存します。childメソッドを利用することで各データにはユーザのuidを設定しています。
  3. Firebaseとデータベースへのユーザ登録が正常に行われれば/(ルート)にリダイレクトされます。もし、Firebaseのユーザ登録にエラーが発生した場合は、エラーを画面に表示させるように設定を行っています。

registerUser() {
  firebase
    .auth()
    .createUserWithEmailAndPassword(this.email, this.password)
    .then(response => {
      const user = response.user;
      firebase
        .database()
        .ref("users")      
        .child(user.uid)
        .set({
          user_id: user.uid,
          email: user.email
        })
        .then(() => {
          this.$router.push("/");
        })
        .catch(e => {
          console.log(e);
        });
    })
    .catch(e => {
      console.log(e);
      if (e.code == "auth/email-already-in-use") {
        this.errors.push("入力したメールアドレスは登録済みです。");
      } else {
        this.errors.push(
          "入力したメールアドレスかパスワードに問題があります。"
        );
      }
    });
}

エラーコードauth/email-already-in-useについてはドキュメントを参考に設定をしています。他にはauth/invalid-email等もあるので各自の要件に合わせて適切なエラー処理を行ってください。

authエラーコード
authエラーコード

エラーを画面上に表示させるためにデータプロパティにerrorsを追加します。firebase/databaseのimportも合わせて行います。


import firebase from "firebase/app";
import "firebase/auth";
import "firebase/database";

export default {
  data() {
    return {
      email: "",
      password: "",
      errors: []
    };

ユーザの登録

Firebaseにユーザを登録するだけではなくデータベースのusersにもユーザ情報を保存するようにコードの変更を行ったので、既存のユーザを一度Firebaseのコンソールから削除し再度ユーザ登録を行います。

ユーザ登録画面
ユーザ登録画面

ユーザの登録はFirebaseのコンソールから確認します。これは以前行ったユーザ登録後と変わりません。

Firebaseのコンソールからユーザ登録確認
Firebaseのコンソールからユーザ登録確認

データベースを確認するとusersの下に登録したjohn@example.comが登録されていることが確認できます。また設定した通り各ユーザのIDとuser_idの値が同じになっていることも確認できます。

データベースに登録したユーザ情報
データベースに登録したユーザ情報

ユーザ登録のエラー確認

作成したユーザをサインアウトして再度同じemailアドレスを利用してどのようにエラーが表示されるかの確認を行います。

先程作成したユーザと同じメールアドレスを入力してエラーが表示されることを確認してください。同じメールアドレスを入力した場合はエラーコードauth/email-already-in-useがFirebaseより戻ってくるので”入力したメールアドレスは登録済みです”がブラウザ上に表示されます。

ユーザ登録のエラー確認
ユーザ登録のエラー確認

サインインエラー確認

ユーザ登録と同様にサインイン時にもサインイン画面にエラーが表示されるようにSignIn.vueファイルの更新を行います。追加するコードはRegister.vueファイルとほとんど同じです。

データプロパティerrorsを追加してサインインにエラーがあった場合はRegister.vueファイルと同様にcatchの中で処理を行います。


export default {
  data() {
    return {
      email: "",
      password: "",
      errors: []
    };
  },
  methods: {
    signIn() {
      firebase
        .auth()
        .signInWithEmailAndPassword(this.email, this.password)
        .then(response => {
          console.log(response);
          this.$router.push("/");
        })
        .catch(() => {
          this.password = "";
          this.errors.push("メールアドレスかパスワードに誤りがあります。");
        });
    }
  }

HTML側でエラーを表示するタグを追加します。


  <div class="mb-2">
    <input
      type="password"
      v-model="password"
      class="text-xl w-3/5 p-3 border rounded"
      placeholder="パスワード"
    />
  </div>
  <div v-if="errors.length">
    <ul class="my-4">
      <li
        v-for="(error, index) in errors"
        :key="index"
        class="font-semibold text-red-700"
      >{{ error }}</li>
    </ul>
  </div>
  <button type="submit" class="text-xl w-3/5 bg-green-800 text-white py-2 rounded">サインイン</button>
</form>
errorsは配列にしエラーをpushで配列に追加しているのでサインエラーがある毎にエラーは上書きするのではなく追加されます。要件に合わせてエラーの表示についても変更を行ってください。

ユーザ一覧の表示と選択

リスナーを使ってユーザ一覧の表示

これまではサイドバーのユーザ一覧は手動で記述していましたが、Firebaseのデータベースに保存されているユーザの情報を取得して表示させます。そのために動作確認を行うために数名のユーザの登録を行ってください。

ユーザ一覧の取得はライフサイクルフックのmountedの中で行います。

firebaseのrefメソッドでデータベース内のusersの場所を指定し、onメソッドでchild_adddedイベント設定します。この設定がリスナーとなり、child_addedのイベントを監視します。child_addedイベントを設定するとmounted時に一度usersからユーザ情報を一括で取得します、その後usersにユーザが登録される度に登録したユーザ情報を取得する処理が実行されます。


mounted() {
  this.user = firebase.auth().currentUser;

  firebase
    .database()
    .ref("users")
    .on("child_added", snapshot => {
      this.users.push(snapshot.val());
    });

}
child_addedイベントの他のvalueやremoved, changedイベントもあります。usersでvalueを設定した場合は、ユーザが追加される度にすべてのusersデータを取得します。removedはユーザの削除、changedであればユーザ情報の変更に利用できるイベントです。

データプロパティのusersはデフォルトで空の配列に変更します。


  data() {
    return {
      user: "",
      users: [],

ブラウザで確認するとmountedライフライクルフックの処理でデータベースからユーザ情報取得し、ブラウザ上に表示されます。

データベースから取得したユーザ情報を表示
データベースから取得したユーザ情報を表示

別のブラウザ(現在使用しているのがChromeならEdgeやSafariやFirefox)で接続しユーザの登録を行うとユーザを登録と同時にユーザ一覧にユーザ(jack@example.co.jp)が登録されます。(child_addedイベントを設定しているため)

ユーザの登録がリアルタイムで更新
ユーザの登録がリアルタイムで更新

リスナーの停止

child_addedイベントに監視するために起動したリスナーを停止する処理を設定しておく必要があります。リスナーを停止していないとユーザがサインアウトした後もリスナーは起動した状態になりもしサインインした後にユーザが登録されれれば裏側ではWebSocketを使ってユーザの追加情報を取得し続けることになります。

リスナーの停止はライフサイクルフックのbeforeDestroyの中で設定を行っておきます。


mounted(){
//略
},
beforeDestroy() {
  firebase
    .database()
    .ref("users")
    .off();
}

ユーザの選択

ダイレクトメッセージを送信したいユーザのemailをクリックするとメインの上部に表示されているメールアドレスをクリックしたユーザのemailに変更できるように更新します。

データプロパティにchannel_nameを追加します。


  data() {
    return {
      user: "",
      users: [],
      channel_name: '',

メインの上部でサインインしていたユーザのemailからchannel_nameに変更します。


<div class="font-bold text-lg">{{ channel_name }}</div>

サイドバーのユーザ一覧のemailを表示するspanタグにclickイベントを追加しメソッド名はdirectMessageを設定します。


<span class="opacity-50" @click="directMessage(user.email)">{{ user.email }}</span>

vue.jsにdirectMessageメソッドを追加します。directMessageの中身は引数で受け取ったemailをchannel_nameに設定しているだけです。


methods: {
  signOut() {
    firebase.auth().signOut();
    this.$router.push("/signin");
  },
  directMessage(email) {
    this.channel_name = email;
  }
},

サイドバーからユーザのemilをクリックするとメインの上部のメールアドレスが変わることが確認できます。

下記ではsusan@test.comでサインインし、サイドバーのダイレクトメッセージの下にあるjack@example.co.jpをクリックするとメイン部分のメールアドレスがjack@example.co.jpに変わります。他のユーザのEmailをクリックするとクリックしたemailに変更されることを確認してください。

上部のメールアドレスが変わる
上部のメールアドレスが変わる

メッセージ送受信の設定

メッセージの入力

前回の文書でメッセージの入力する箇所は作成していましたが、メッセージ欄に文章を入力して送信ボタンを押しても何も変化がありません。

入力したメッセージをvue.js内で処理できるようにメッセージ入力フォームのtextareaをバインドします。バインドする前にバインド用のデータプロパティmessageを追加します。


  data() {
    return {
      user: "",
      users: [],
      channel_name: "",
      message: "",

textareaタグにv-modelの設定を行います。


<textarea
  class="w-full pt-4 pl-8 outline-none"
  placeholder="XXXXへのメッセージ"
  v-model="message"
></textarea>

入力した値をvue.js側で受け取れるように送信ボタンにclickイベントを追加します。clickイベントに設定するメソッドはsendMesssageです。


<div class="border border-gray-900 rounded mb-4">
  <textarea
    class="w-full pt-4 pl-8 outline-none"
    placeholder="XXXXへのメッセージ"
    v-model="message"
  ></textarea>
  <div class="bg-gray-100 p-2">
    <button
      class="bg-green-900 text-sm text-white font-bold py-1 px-2 rounded"
      @click="sendMessage"
    >送信</button>
  </div>
</div>

追加するsendMessageメソッドでは動作確認のためtextareaに入力したテキストをコンソールに表示させます。


methods: {
  signOut() {
    firebase.auth().signOut();
    this.$router.push("/signin");
  },
  sendMessage() {
    console.log(this.message);
  },
  directMessage(email) {
    this.channel_name = email;
  }
},

textareaのplaceholderは選択したユーザによって動的に変わるように変更を行います。

新たにデータプロパティのplaceholderを追加します。


data() {
  return {
    user: "",
    users: [],
    channel_name: "",
    message: "",
    placeholder: "",
    channels: []

textareaの属性のplaceholderをバインドしvue.jsのデータプロパティで追加したplaceholderを設定します。


<textarea
  class="w-full pt-4 pl-8 outline-none"
  :placeholder="placeholder"
  v-model="message"
></textarea>

ユーザの選択時に追加したdirectMessageメソッドに以下の追加を行います。


directMessage(email) {
  this.channel_name = email;
  this.placeholder = email + "へのメッセージ";
}

サイドバーのメールアドレスをクリックするとtextareaに表示されるplaceholderの値が変わります。john@example.comをクリックしたので以下が表示されます。

placeholderの内容が動的に変化
placeholderの内容が動的に変化

textareaにテキストを入力してコンソールログに表示されるか確認してください。

入力したテキストをコンソールに表示
入力したテキストをコンソールに表示

ダミーで入れていた”初めてのメッセージ”の部分に入力したテキストが表示できるように変更を行います。


<div class="mt-2 mb-4 flex">
  <Avator :user="user.email" />
  <div class="ml-2">
    <div class="font-bold">{{ user.email }}</div>
    <div>{{ message }}</div>
  </div>
</div>

変更後は、文字を入力する毎に画面上に文字が表示されることが確認できます。

入力してテキストを画面に表示
入力してテキストを画面に表示

メッセージを送受信するための準備

本アプリケーションで送受信するメッセージはすべてFirebaseのデータベースの中に保存します。

本アプリケーションでは現時点で4名のユーザが保存されています。例えばjohnとkevin間でダイレクトメッセージを使ってメッセージを送受信する場合はjohnとkevinのメッセージは他のユーザからは見えないように保存する必要があります。

そのためにまずすべてのメッセージを保存する場所messageを指定し、その下にjohnとkevinを識別するchannel_idという場所を指定します。channel_idは本アプリケーションの中で一意である必要があるため、johnとkevinの情報を利用して一意のchannel_idを作成する必要があります。

johnとsusanであれば別の一意のchanneld_idを作成します。

各ユーザはFirebaseの作成時にuidを付与されているのでそのuidを使うことで一意のchannel_id作成を実現します。johnとkevinであればjohnのuidとkevinのuidを組み合わせることで一意のchannel_idを作成します。

データプロパティchannel_idを追加します。


  data() {
    return {
      user: "",
//略
      channels: [],
      channel_id: ""
    };

そのchannel_idを作成するコードは下記の通りです。


this.user.uid > user.uid
  ? (this.channel_id = this.user.uid + "-" + user.user_id)
  : (this.channel_id = user.user_id + "-" + this.user.uid);

this.userはjohnでuserはkevinに対応します。uidを比較することでjohnとkevinであれば必ず同じchannel_idを設定します。もし比較をせずにchannel_idを設定するとjohnがサインインしてkevinを指定した時とkevinがサインインしてjohnを指定した場合と異なるchannel_idが設定されることになります。

メッセージの送受信

データベースから取得したメッセージを保存するためにデータプロパティのmessagesを追加します。


data() {
  return {
    user: "",
    users: [],
    channel_name: "",
    message: "",
    messages: [],
    placeholder: "",

channel_idの設定はサイドバーからユーザを選択時のdirectMessageメソッドの中で行います。directMessageに渡す値がemailでしたが、userに変更を行います。


<div class="mt-2 flex items-center" v-for="user in users" :key="user.user_id">
  <span class="bg-yellow-400 rounded-full w-3 h-3 mr-2"></span>
  <span class="opacity-50" @click="directMessage(user)">{{ user.email }}</span>
</div>

directMessage内ではサインインしたユーザとdirectMessageから受け取った(サイドバークリック)ユーザの情報を利用してchannel_idを作成し、データベース上でchannel_idの下にメッセージの追加がないかをリスナーを作成しchild_addedイベントを監視します。


directMessage(email) {
  messages = [];
  this.user.uid > user.user_id
    ? (this.channel_id = this.user.uid + "-" + user.user_id)
    : (this.channel_id = user.user_id + "-" + this.user.uid);

  this.channel_name = user.email;
  this.placeholder = email + "へのメッセージ";

  firebase
    .database()
    .ref("messages")
    .child(this.channel_id)
    .on("child_added", snapshot => {
      this.messages.push(snapshot.val());
    });
}

channel_idの設定が完了したのでメッセージを送信した時に実行されるsendMessageメソッドを更新します。設定されたchannel_idの下にcontentにメッセージ、userにユーザを識別するemailを保存します。そのほかにメッセージを識別するためにkeyを保存し、作成時刻も保存しておきます。

pushメソッドを行いメッセージを保存する場所を作成すると同時にpush毎に割り振られる一意のidを取得します。その後setでその場所にメッセージを保存しています。一意のkeyを保存することでそのkeyを持つメッセージの削除や更新を行うことができます。

sendMessage() {
  const newMessage = firebase
    .database()
    .ref("messages")
    .child(this.channel_id)
    .push();

  const key_id = newMessage.key;

  newMessage.set({
    key: key_id,
    content: this.message,
    user: this.user.email,
    createdAt: firebase.database.ServerValue.TIMESTAMP
  });

  this.message = "";
},

Firebase上では下記のように保存されます。messagesの直下にあるug…Usの長い文字列は2人のユーザのuidを組み合わせたものです。そのchannel_idの下にメッセージが保存されることになります。

Firebase上でのデータ構造
Firebase上でのデータ構造

最後にmessagesをHTML側で展開します。


<div class="mt-2 mb-4 flex" v-for="message in messages" :key="message.key">
  <Avator :user="message.user" />
  <div class="ml-2">
    <div class="font-bold">{{ message.user }}</div>
    <div>{{ message.content }}</div>
  </div>
</div>

実際にsusanとjohnを使ってメッセージを送受信します。1台のPC上で2人のユーザがリアルタイムにメッセージを送受信しあうためには2つの異なるブラウザを起動する必要があります。

susanでサインインをして、サイドバーからjohnを選択しメッセージを送信します。また別のブラウザではjohnでサインインしてsusanを選択してメッセージを送信します。

Susanでサインインしてメッセージを見ている場合。

互いのメッセージを確認
susanからメッセージを確認

Johnでサインインをしてメッセージを見ている場合。

johnから見ているメッセージ
johnから見ているメッセージ

一見正常に動作しているみたいに思いますがemailをクリックする度にdirectMessageメソッドでchild_addedイベントが実行されるので数回john@example.comをクリックして後にメッセージを入力すると同じメッセージが一度に表示されます。

それを防ぐためにdirectMessageを実行した後に現在設定されているチャンネルがある場合はそのイベントを削除します。削除はoffメソッドを利用します。この結果何度同じemailをクリックしてもイベントが複数起動することはなくなります。


directMessage(user) {
  this.messages = [];

  this.user.uid > user.user_id
    ? (this.channel_id = this.user.uid + "-" + user.user_id)
    : (this.channel_id = user.user_id + "-" + this.user.uid);

  if (this.channel_id != "") {
    firebase
      .database()
      .ref("messages")
      .child(this.channel_id)
      .off();
  }

ここまでの設定でユーザ間のダイレクトメッセージの送受信が可能になりました。複数のユーザ間でメッセージを送信して動作するか確認を行ってください。

usersのリスナーとchannleのリスナーを停止しなければサインアウトした後もブラウザではWebSocketを使ってmessageを受信し続けることになります。

ライフサイクルメソッドのbeforeDestroyでmessagesのリスナーを停止します。


  beforeDestroy() {
    firebase
      .database()
      .ref("users")
      .off();

    firebase
      .database()
      .ref("messages")
      .child(this.channel_id)
      .off();
  }

ChomeデベロッパーツールでWebSocketの確認

Firebaseからのメッセージの取得はWebsocketを使って行われます。メッセージの情報を取得したい場合はChomeのデベロッパーツールを起動してAllまたはWSでWebsocketの情報を確認してください。

ChomeのデベロッパーツールでWebSocket
ChomeのデベロッパーツールでWebSocket

詳細上はWSタブをクリックしてMessagesを見てください。左側のNameから表示されているものをクリックすると受信した時間やメッセージの内容も確認することができるので実際に確認を行ってください。

WebSocketでのデータのやり取り
WebSocketでのデータのやり取り

次回はユーザ間のダイレクトメッセージではなくチャンネルによるメッセージの送受信部分を実装していきます。