みんなの「作ってみた」

VueとFirebaseで爆速でチャットサービスを作ろう

2019/02/09

ToshioAkaneya
ToshioAkaneya
Ruby on RailsとVueでプログラミングスクールのレビューサイトを運営しています。良ければご覧ください。

こんにちは、ネコチャ運営者のアカネヤ(@ToshioAkaneya)です。
3日で作成したチャットサービスネコチャが好評をいただき、Twitter上で多数のつぶやきをいただきました。

どんなサービスか

ネコチャは、とくめいチャットをすることが出来るサービスです。
仕組みは質問箱(Peingなど)と似ていて、自分のリンクをSNSで共有することでフォロワーがそのリンクから匿名でメッセージを送ることが出来るというものです。
質問箱と違うのは、会話を続けることが出来る点です。
話しかける側を匿名にすることでコミュケーションのハードルを下げることが可能になっています。

ネコチャトップページ

開発の経緯

2018年に流行ったとくめいチャットアプリのNYAGOがありました。
急激なユーザーの増加に対し、開発体制が整っておらずやむなく一時停止を発表しました。
匿名チャットアプリ「NYAGO」が一時停止を発表、公開1週間で1万ユーザー突破も課題を痛感

僕はNYAGOの再開を心待ちにしていたのですが、待てども待てども発表はなく...
「じゃあ作ろう」と思ったのが始まりでした。
(ネコチャのことはUNDEFINEDの方にもお話しているのでご安心を。)

構成

構成はサーバーレスで、Firebase Realtime DatabaseとVue.jsを使用しています。
Firebase Realtime Database は、リアルタイムにデータを保存およびユーザー間で同期できる、クラウドホスト型 NoSQL データベースです。
リアルタイムチャットをもっとも簡単に実現出来る構成だと思います。
(現在のネコチャはリニューアルをしたもので、Ruby on Rails APIとNuxtを使用したものに変わっています。こちらについてもまた記事にできればと思います。)

コード

完成品

完成するのは、こちらのリニューアル前のネコチャになります。ぜひ試しに僕にメッセージを送ってみて下さい。(「新しいバージョンのネコチャに移動しますか?(推奨)」でキャンセルを選択するとアクセス出来ます。)
なおリリース後の3日で約200名のユーザー登録を達成し、送られたメッセージ数は1500件以上にもなりました。


リニューアル前のネコチャ

Firebaseプロジェクトの設定

mioさんの認証付きの簡易チャットを作る!を参考にしてFirebaseプロジェクトを設定して下さい。
なお、TwitterログインではなくGoogleログインを使用するのでAuthenticationタブではGoogleログインを有効にして下さい。
また、データベースのルールでは次の内容をコピペして下さい。(「公開」ボタンを押すのを忘れずに!)

{
    "rules": {
        "chats": {
            "$chatUid": {
                ".write": "auth!=null",
                ".read": "
          root.child('chats/'+$chatUid+'/expireAt').val()> now
          &&root.child('members/'+$chatUid).child(auth.uid).exists()",
            }
        },
        "users": {
            "$userUid": {
                ".write": "auth!=null",
                ".read": "true",
            }
        },
        "messages": {
            "$chatUid": {
                ".write": "
          root.child('chats/'+$chatUid+'/expireAt').val()> now&&
          (root.child('members/'+$chatUid).child(auth.uid).exists())",
                ".read": "
          root.child('chats/'+$chatUid+'/expireAt').val()> now
          &&root.child('members/'+$chatUid).child(auth.uid).exists()",      
            }
        },
        "members": {
            "$chatUid": {
                ".write": "auth!=null",
                ".read": "
          root.child('chats/'+$chatUid+'/expireAt').val()> now
          &&root.child('members/'+$chatUid).child(auth.uid).exists()",      
            }
        }
    }
}

ネコチャを作るにあたり。このチュートリアルはとても参考になりました。mioさんありがとうございます。

準備

次のコマンドを実行して下さい

npm install -g firebase-tools @vue/cli
vue init webpack necocha #基本的にエンターでOKですが、以下の質問にはNoと答えて下さい。
? Install vue-router? No
? Use ESLint to lint your code? No
? Set up unit tests No
? Setup e2e tests with Nightwatch? No

cd necocha
firebase login
firebase init
# Hostingをスペースバーで選択してエンター。
# Firebaseコンソールで作成したプロジェクトを選択。
# 残りはあとで設定ファイルを編集するので、エンターを押していけば大丈夫です。
npm install axios crypto-js express firebase firebase-admin firebase-functions jquery moment vue vue-axios vue-clipboard2 vue-cookie vue-meta vue-moment vue-nl2br

そしてfirebase.jsonを以下のように編集してください。これがfirebaseの設定ファイルです。

firebase.json
{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

コード

index.html src/main.js src/App.js src/components/TheRoot.jsの3ファイルを編集(なければ作成)します。
まずはコピペして動作させてみることをオススメします。

まずはindex.htmlを次のように作成・編集します。

index.html
<!DOCTYPE html>
<html>

<head>
  <!-- Global site tag (gtag.js) - Google Analytics -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
  <meta property="og:description" content="ネコチャはとくめいのネコになりチャットすることができるサービスです。">
  <meta property="og:title" content="ネコチャ|とくめいチャットサービス">
  <meta property="og:image"
    content="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/Screen%20Shot%202019-01-14%20at%200.36.18.png?alt=media&token=96df5db8-ed00-44b3-9e97-8e11160093e2">
  <meta property="twitter:card" content="summary_large_image">
  <link rel="shortcut icon" type="image/png"
    href="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/animal_mark04_neko.png?alt=media&token=4bd55443-839a-4bd9-bcd6-6f56cf7353c4" />
  <link href="https://fonts.googleapis.com/earlyaccess/nikukyu.css" rel="stylesheet">
  <title>ネコチャ|とくめいチャットサービス</title>
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css"
    integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" crossorigin="anonymous">
</head>

<body>
  <div id="app"></div>
  <!-- built files will be auto injected -->
</body>

</html>

src/main.jsではVueインスタンスの初期化を行います。var config =にはFirebaseコンソールで取得した値をペーストして下さい。

src/main.js
import firebase from 'firebase'
import("firebase/functions");
import Vue from 'vue'
import App from './App'
import axios from 'axios'
import VueAxios from 'vue-axios'
import VueClipboard from 'vue-clipboard2'
import Meta from 'vue-meta'

// Vue.use(Meta)
VueClipboard.config.autoSetContainer = true // add this line

Vue.use(VueClipboard)
Vue.use(VueAxios, axios)
var VueCookie = require('vue-cookie');
// Tell Vue to use the plugin
// import moment from 'vue-moment'
// moment.locale('ja');
Vue.use(VueCookie);

// Vue.use(moment);

Vue.config.productionTip = false

// Initialize Firebase
var config = {
 // ここにFirebaseから取得したconfigをペースト
};
firebase.initializeApp(config);
new Vue({
  el: '#app',
  components: { App },
  template: '<App/>'
})

src/App.vueは次のようにして下さい。これがrootコンポーネントになります。

src/App.vue
<template>
  <div id="app">
    <header class="header">
      <h1>
        <a href="/" style="font-family: Nikukyu; color: white;">ネコチャ</a>
      </h1>
    </header>
    <section v-if="isChat">
      <!-- 入力フォーム -->
      <form action @submit.prevent="sendMessage" class="form">
        <textarea v-model="input" :disabled="!user"></textarea>
        <button type="submit" :disabled="!user" class="send-button">送信</button>
      </form>
      <transition-group name="chat" tag="div" class="list content">
        <section v-for="{ key, name, image, message, isFromGuest } in chat" :key="key" class="item">
          <div class="item-image">
            <img
              :src="image"
              width="40"
              height="40"
            >
          </div>
          <div class="item-detail">
            <div class="item-name"></div>
            <div class="item-message">
              <nl2br tag="div" :text="message"/>
            </div>
          </div>
        </section>
      </transition-group>
    </section>
    <section v-else>
      <the-root :user="user" :chats="chats" :authPending="authPending" :clickHandler="signInTwitter"></the-root>
    </section>
  </div>
</template>

<script>
// firebase モジュール
// 改行を <br> タグに変換するモジュール
import firebase from "firebase";
import Nl2br from "vue-nl2br";
import TheRoot from "./components/TheRoot";

export default {
  components: { TheRoot, Nl2br },
  data() {
    return {
      authPending: true,
      user: null, // ユーザー情報
      chats: [], // 取得したメッセージを入れる配列
      chat: [],
      input: "",
      isChat: false, // 入力したメッセージ
      isGuest: false,
      key: ""
    };
  },
  mounted() {
    const authUser = Object.keys(window.localStorage)
      .filter(item => item.startsWith('firebase:host:necocha-me.firebaseio.com'))[0]
    if (!authUser) {
      this.authPending = false;
    }
  },
  beforeCreate() {

    firebase.auth().onAuthStateChanged(async user => {
      this.authPending = false
      this.user = user;
      if (user) {
        const refUser = firebase
          .database()
          .ref("users/" + this.user.uid)
        await refUser.child('name').set(user.displayName)
        const userSnap = await refUser.once('value');
        this.user.chats = userSnap.val().chats
      }
      const url = new URL(location.href);
      this.chatUid = url.searchParams.get("chatUid"); // "/ca
      this.isChat = !!this.chatUid;
      for (let chatUid of Object.keys(this.user.chats)) {
        const refChats = firebase
          .database()
          .ref("chats/" + chatUid);
        refChats.once('value', (snap) => {
          const newChat = snap.val()
          this.chats.push(({...newChat, uid: chatUid, isCreator: this.user.chats[chatUid] === 'creator'}))
        });
      }
      if (!user) {
        if (this.isChat) {
          alert('ログインして下さい')
          return this.signInTwitter()
        }
        return;
      }
      if (!this.isChat) {
        return;
      }
      const ref_messages = firebase
        .database()
        .ref("messages/" + this.chatUid);
      this.chat = [];
      const refUsers = firebase
        .database()
        .ref("users/" + this.user.uid + '/chats/' + this.chatUid);
      refUsers.once('value', (snap) => {
        this.isGuest = snap.val() === 'guest'
      })
      ref_messages.limitToLast(100).on("child_added", this.childAdded, e => {
      });
    });
  },
  created() {
    const url = new URL(location.href);
    this.chatUid = url.searchParams.get("chatUid"); // "/ca
    this.isChat = !!this.chatUid;
    if (!this.isChat) {
      return;
    }
    if (+url.searchParams.get("expireAt") < Date.now()) {
      alert("有効期限が過ぎています");
      return (window.location = "/");
    }
  },
  methods: {
    // ログイン処理
    // スクロール位置を一番下に移動
    scrollBottom() {
      this.$nextTick(() => {
        window.scrollTo(0, document.body.clientHeight);
      });
    },
    sendMessage() {
      firebase
        .database()
        .ref("messages/" + this.chatUid)
        .push({
          image: (this.isGuest ? 'https://firebasestorage.googleapis.com/v0/b/necocha-io.appspot.com/o/animal_mark04_neko.png?alt=media&token=ba4e9920-bf1f-45ea-a3e6-0a34e3a85b21' : this.user.photoURL),
          message: this.input,
          isFromGuest: this.isGuest
        }, () => {
          this.input = ""; // フォームを空にする
        });
    },
    childAdded(snap) {
      const message = snap.val();
      this.chat.push({
        key: snap.key,
        name: message.name,
        image: message.image,
        message: message.message,
        isFromGuest: message.isFromGuest
      });
      this.scrollBottom();
    },
    signInTwitter() {
      const providerTwitter = new firebase.auth.GoogleAuthProvider();
      firebase.auth().signInWithRedirect(providerTwitter);
    }
  }
};
</script>
<style>
* {
  margin: 0;
  box-sizing: border-box;
  font-family: sans-serif;
}

section {
  text-align: center;
}
.header {
  background: #3ab383;
  margin-bottom: 1em;
  padding: 0.4em 0.8em;
  color: #fff;
}
.content {
  margin: 80px auto 0;
  padding: 0 10px;
  max-width: 600px;
}
.form {
  position: fixed;
  display: flex;
  justify-content: center;
  align-items: center;
  top: 50px;
  height: 80px;
  width: 100%;
  background: #f5f5f5;
}
.form textarea {
  border: 1px solid #ccc;
  border-radius: 2px;
  height: 4em;
  width: calc(100% - 6em);
  resize: none;
}
.list {
  margin-bottom: 100px;
}
.item {
  position: relative;
  display: flex;
  align-items: flex-end;
  margin-bottom: 0.8em;
}
.item-image img {
  border-radius: 20px;
  vertical-align: top;
}
.item-detail {
  margin: 0 0 0 1.4em;
}
.item-name {
  font-size: 75%;
}
.item-message {
  position: relative;
  display: inline-block;
  padding: 0.8em;
  background: #deefe8;
  border-radius: 4px;
  line-height: 1.2em;
}
.item-message::before {
  position: absolute;
  content: " ";
  display: block;
  left: -16px;
  bottom: 12px;
  border: 4px solid transparent;
  border-right: 12px solid #deefe8;
}
.send-button {
  height: 4em;
}
/* トランジション用スタイル */
.chat-enter-active {
  transition: all 1s;
}
.chat-enter {
  opacity: 0;
  transform: translateX(-1em);
}

a {
  text-decoration: none;
}
</style>

TheRootコンポーネントをsrc/components/TheRoot.vueに次のように定義します。

src/components/TheRoot.vue
<template>
  <section>
    <img width="100%" src="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/logo.png?alt=media&token=e84771e8-f590-4a4b-a2f9-06dca4712490">
    <div v-if="authPending">
      <img style="margin: 0 auto;" src="https://loading.io/spinners/palette-ring/index.svg">
    </div>
    <div v-if="creator">
      {{creator.name}}にとくめいでチャットしますか?
      <button type="button" @click="createChat">チャットする</button>
    </div>
    <div v-if="user">
      <a
        :href="'https://twitter.com/intent/tweet?text=だれかとくめいのネコになってお話ししてくれませんか?送る側だけとくめいのチャット、ネコチャしよう!%20%23とくめいチャット%20%23ネコチャ&url=' + encodeURIComponent(
         `https://${window.location.host}/?creatorUid=${user.uid}`)"
      >あなたのリンクをTwitterで共有する</a>
      <div v-for="{uid,creatorName,expireAt,isCreator} in chats">
        <a :href="`./?chatUid=${uid}&expireAt=${expireAt}`">
          {{isCreator ? 'とくめいのネコ':creatorName}}さんとのチャット
          あと{{computeDate(expireAt).slice(0,computeDate(expireAt).length-1)}}
        </a>
      </div>
    </div>
    <div class="description">
      <p>ネコチャはとくめいのネコとチャットすることができるサービスです。</p>
      <p>ルームを作成してリンクを共有すると、みんなはあなたととくめいでチャットすることができます。</p>
      <p>ルームは6時間で誰もアクセス出来なくなります。</p>
      <button type="button" @click="clickHandler">Googleではじめる</button>
      <p>とくめいのネコを募集して、エモいひとときをお楽しみ下さい。</p>
    </div>
    <img width="100%" src="https://firebasestorage.googleapis.com/v0/b/necocha-me.appspot.com/o/sample.png?alt=media&token=66991375-4adc-44cb-8e48-6ed5469cf712">
    <button type="button" @click="clickHandler">Googleではじめる</button>
  </section>
</template>

<script>
import $ from "jquery";
import firebase from "firebase";
import "moment/locale/ja";

import moment from "moment";
// import 'moment/locale/ja'

function getRandomInt(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive
}

export default {
  name: "TheRoot",
  props: ["chats", "user", "authPending", "clickHandler"],
  data() {
    return {
      creator: null,
      window: {}
    };
  },
  computed: {
    chatData: () => {
      this.chats.map(chat => {
        return { uid: Object.keys(chat)[0], ...Object.values(chat) };
      });
    }
  },
  created: async function() {
    this.window = window
    const url = new URL(location.href);
    const creatorUid = url.searchParams.get("creatorUid"); // "/ca
    if (creatorUid) {
      const creatorSnap = await firebase
        .database()
        .ref("users/" + creatorUid)
        .once("value");
      this.creator = creatorSnap.val();

      this.creator.uid = creatorUid;
    }
    const refChats = firebase.database().ref("chats");
    refChats.on("child_added", snap => {
      // if (!this.user.chats) {
      //   return
      // }
      // const newChat = snap.val()
      // if (this.user.chats[snap.key] === 'creator') {
      //   this.chats.creator.push(newChat)
      // } else if (this.user.chats[snap.key] === 'guest') {
      //   this.chats.guest.push(newChat)
      // }
    });
  },
  methods: {
    createChat: async function() {
      if (!this.user) {
        alert("Googleログインが必要です(名前は相手には分かりません)");
        return;
      }
      if (!(this.user && this.creator)) {
        return;
      }
      const refChats = firebase.database().ref("chats");
      const expireAt = Date.now() + 1000 * 60 * 60 * 6;
      const chatUid = refChats.push().getKey();
      const refUser = firebase.database().ref("users");
      const updateObj = {};
      updateObj["name"] = this.user.displayName;
      updateObj["chats/" + chatUid] = "guest";
      refUser.child(this.user.uid).update(updateObj);
      await refChats
        .child(chatUid)
        .set({ creatorName: this.creator.name, expireAt });
      delete updateObj.name;
      updateObj["chats/" + chatUid] = "creator";
      await refUser.child(this.creator.uid).update(updateObj);
      const refMembers = await firebase
        .database()
        .ref("members/" + chatUid)
        .set({ [this.user.uid]: "guest", [this.creator.uid]: "creator" });
      await firebase
        .database()
        .ref("messages/" + chatUid)
        .push({
          image:
            "https://firebasestorage.googleapis.com/v0/b/necocha-io.appspot.com/o/animal_mark04_neko.png?alt=media&token=ba4e9920-bf1f-45ea-a3e6-0a34e3a85b21",
          message: "こんにちは!とくめいのネコさんが入室しました!By運営",
          isFromGuest: true
        });
      return (location = `/?chatUid=${chatUid}&expireAt=${expireAt}`);
    },
    computeDate(timestamp) {
      return moment(timestamp).fromNow();
    }
  }
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
section {
  max-width: 500px;
  margin: 0 auto;
  text-align: center;
}
.description {
  margin-top: 10px;
  line-height: 30px;
  padding: 0 50px;
}
button {
  border: none;
  border-radius: 2px;
  margin: 10px 0;
  color: white;
  font-size: 20px;
  background-color: #3ab383;
}

p {
  margin-bottom: 5px;
}
.ribbon11 {  
  margin-bottom: 30px;
  display: inline-block;
  position: relative;
  height: 45px;
  vertical-align: middle;
  text-align: center;
  box-sizing: border-box;
}
.ribbon11:before {
  /*左側のリボン端*/
  content: "";
  position: absolute;
  width: 10px;
  bottom: -10px;
  left: -35px;
  z-index: -2;
  border: 20px solid #6cbf86;
  border-left-color: transparent; /*山形に切り抜き*/
}

.ribbon11:after {
  /*右側のリボン端*/
  content: "";
  position: absolute;
  width: 10px;
  bottom: -10px;
  right: -35px;
  z-index: -2;
  border: 20px solid #6cbf86;
  border-right-color: transparent; /*山形に切り抜き*/
}

.ribbon11 h3 {
  display: inline-block;
  position: relative;
  margin: 0;
  padding: 0 20px;
  line-height: 45px;
  font-size: 15px;
  color: #fff;
  background: #42bc8e; /*真ん中の背景色*/
}
.ribbon11 h3:before {
  position: absolute;
  content: "";
  top: 100%;
  left: 0;
  border: none;
  border-bottom: solid 10px transparent;
  border-right: solid 15px #318c69; /*左の折り返し部分*/
}
.ribbon11 h3:after {
  position: absolute;
  content: "";
  top: 100%;
  right: 0;
  border: none;
  border-bottom: solid 10px transparent;
  border-left: solid 15px #318c69; /*右の折り返し部分*/
}
</style>

デプロイ

最後にデプロイです。

npm run build
firebase deploy

表示されたURLにアクセスすると、デプロイの完了が確認できます。
いかがでしたでしょうか。ご感想などをぜひコメントして下さると幸いです。

終わりに

Ruby on RailsとVueで作成したプログラミングスクールのレビューサイトを運営しています。良ければご覧ください。https://school-report.com/