Firebase / Firestore を使って簡単な Chat を作ってみる。(JS, Vue)

最近話題の FireStore の使い方を Chat を作りながら説明してみます。
以下の順番でやってみます。

* HTML / JavaScript で素朴に
* Vue でモダンに
* Quasar でアプリに

Quasar でやった最終の形を
https://firestore-p.firebaseapp.com
でホストしてあります。
適当にサインアップして使ってみてもらって構いません。

![SS5.png](https://qiita-image-store.s3.amazonaws.com/0/73493/12825c6b-1931-c940-de2c-c8c1428ed815.png)

# 準備

## Firebase / Firestore をセッティング

### 公式ドキュメントにのっとればプロジェクトのセットアップまでは簡単です。
https://firebase.google.com/docs/firestore/quickstart?hl=ja

### Authentication のログイン方法で、メール／パスワードを有効にします。
![SS1.png](https://qiita-image-store.s3.amazonaws.com/0/73493/17b7458e-88f6-b570-a204-1628a15492fd.png)

### データベースのルールをとりあえずテストモードにしておきます。
![SS2.png](https://qiita-image-store.s3.amazonaws.com/0/73493/0455e1a6-53e9-8bdb-4b56-c4a608670bd4.png)

Firebase / Firestore の準備はこれだけで OK です。


## チャットのデータの形

以下の4つのプロバティからなるドキュメントで、1つの投稿を表すものとします。

* body メッセージの本文
* date 投稿日
* name ハンドル（ニックネーム）
* user 投稿者の ユーザー UID 

キーは自動生成にします。

コレクションの名前は接頭詞 room- に部屋を表すキーワードをつけたものとします。

これらは決めておくだけです。SQL 型のデータベースのようにテーブル定義とかフィールド定義とかする必要はありません。

![SS3.png](https://qiita-image-store.s3.amazonaws.com/0/73493/cc723cab-f764-db4e-d974-75e1c1e53ef1.png)


# HTML / JavaScript でやってみる

以下のファイルをローカルに作ってブラウザにくわせてみてください。
firebase.initializeApp のパラメータは、firebase プロジェクトの設定からもってきてください。

```index.html
<!DOCTYPE html>
<html>

  <div id="auth" style="display: none">
    <input  id="email"    placeholder="email"    type="email">
    <input  id="password" placeholder="password" type="password">
    <button id="login">Login</button>
    <input  id="nickname" placeholder="nickname">
    <button id="signup">Signup</button>
  </div>

  <div id="main" style="display: none">
    <div>
      You're <span id="user"></span>
      <button id="logout">Logout</button>
    </div>
    <div id='messages'></div>
    <textarea id="body" rows="7" cols="60" placeholder="Your message"></textarea>
    <button id="submit">Submit</button>
  </div>

  <script src="https://www.gstatic.com/firebasejs/4.10.1/firebase.js"></script>
  <script src="https://www.gstatic.com/firebasejs/4.10.1/firebase-firestore.js"></script>
  <script>
    firebase.initializeApp(
      { apiKey            : "ここには"
      , authDomain        : "自分の"
      , databaseURL       : "やつを"
      , projectId         : "はりつけて"
      , storageBucket     : "ください"
      , messagingSenderId : "ね！"
      }
    )

    document.addEventListener(
      'DOMContentLoaded'
    , function() {

        const wCR = firebase.firestore().collection( 'room-japanese' )
        const wAuth = firebase.auth()

        let wUnsubscriber
        wAuth.onAuthStateChanged(
          user => {
            document.querySelector( '#auth' ).style.display = user ? 'none' : 'block'
            document.querySelector( '#main' ).style.display = user ? 'block' : 'none'
            document.querySelector( '#user' ).textContent = user ? user.displayName : ''
            if ( wUnsubscriber ) {
              wUnsubscriber()
              wUnsubscriber = null
            }
            if ( user ) {
              wUnsubscriber = wCR.orderBy( 'date' ).onSnapshot(
                ss => {
                  let w = '<table>'
                  ss.forEach(
                    doc => {
                      const wData = doc.data()
                      w += '<tr>'
                      w += '<td>' + wData.name + '</td>'
                      w += '<td>' + new Date( wData.date ) + '</td>'
                      w += '<td>' + wData.body + '</td>'
                      w += '</tr>'
                    }
                  )
                  w += '</table>'
                  document.querySelector( '#messages' ).innerHTML = w
                }
              )
            }
          }
        )

        document.querySelector( '#login' ).addEventListener(
          'click'
        , event => wAuth.signInWithEmailAndPassword(
            document.querySelector( '#email' ).value
          , document.querySelector( '#password' ).value
          ).catch( e => alert( e ) )
        )

        document.querySelector( '#signup' ).addEventListener(
          'click'
        , event => {
            let wNickname = document.querySelector( '#nickname' ).value
            if ( !wNickname ) alert( 'Input your nickname' )
            else {
              wAuth.createUserWithEmailAndPassword(
                document.querySelector( '#email' ).value
              , document.querySelector( '#password' ).value
              ).then(
                user => user.updateProfile( { displayName: wNickname } )
              ).then(
                () => document.querySelector( '#user' ).textContent = wNickname
              ).catch( e => alert( e ) )
            }
          }
        )

        document.querySelector( '#logout' ).addEventListener(
          'click'
        , event => wAuth.signOut()
        )

        document.querySelector( '#submit' ).addEventListener(
          'click'
        , event => {
            let wBody = document.querySelector( '#body' )
            if ( !wBody.value ) alert( 'Input your message' )
            else {
              const wUser = wAuth.currentUser
              wCR.add(
                { body: wBody.value
                , date: Date.now()
                , name: wUser.displayName
                , user: wUser.uid
                } 
              ).then(
                () => wBody.value = ''
              ).catch( e => alert( e ) )
            }
          }
        )
      }
    )

  </script>
</html>

```

# Vue でやってみる



```
vue init webpack ＜プロジェクトを格納するフォルダの名前＞
```

上のコマンドで下のようにオプションを選択したものとします。（ESLint をオンにするとエラーが出まくりになります。）

```
? Project name 適当な名前
? Project description A Vue.js project
? Author Satoru Ogura <satoru.ogura@me.com>
? Vue build standalone
? Install vue-router? Yes
? Use ESLint to lint your code? No
? Set up unit tests No
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been created? (recommended) npm
```

firebase をインストールします。

```
cd ＜プロジェクトを格納するフォルダの名前＞
npm i firebase --save
```

説明の都合上、全部 main.js にまとめてあります。

```main.js
import firebase from 'firebase'

firebase.initializeApp(
  { apiKey            : "ここには"
  , authDomain        : "自分の"
  , databaseURL       : "やつを"
  , projectId         : "はりつけて"
  , storageBucket     : "ください"
  , messagingSenderId : "ね！"
  }
)

import Vue from 'vue'
Vue.config.productionTip = false

import  Vuex from 'vuex'
Vue.use( Vuex )

import VueRouter from 'vue-router'
Vue.use( VueRouter )

const
Login = {
  template: `
    <div>
      <input  v-model="email"     placeholder="email"     type='email'>
      <input  v-model="password"  placeholder="password"  type='password'>
      <button @click="login">Login</button>
      <input  v-model="nickname"  placeholder='nickname'>
      <button @click="signup">Signup</button>
    </div>
  `
, data() {
    return {
      email : ''
    , password: ''
    , nickname: ''
    }
  }
, methods: {
    login() {
      firebase.auth().signInWithEmailAndPassword( this.email, this.password ).then(
        user => this.$router.push( this.$route.query.redirect ? this.$route.query.redirect : '/' )
      ).catch( e => alert( e ) )
    }
  , signup() {
      if ( this.nickname ) {
        firebase.auth().createUserWithEmailAndPassword( this.email, this.password ).then(
          user => user.updateProfile( { displayName: this.nickname } )
        ).then(
          () => {
            this.$store.commit( 'user', firebase.auth().currentUser )
            this.$router.push( this.$route.query.redirect ? this.$route.query.redirect : '/' )
          }
        ).catch( e => alert( e ) )
      }
    }
  }
}

import 'firebase/firestore'

const
Chat = {
  template: `
    <div>
      <span v-if="$store.state.user">You're {{ $store.state.user.displayName }}</span>
      <button @click="$router.push( '/login' )">Logout</button>
      <table>
        <tr v-for="( post, index ) in posts" :key='index'>
          <td>{{ post.name }}</td>
          <td>{{ ( new Date( post.date ) ).toString() }}</td>
          <td>{{ post.body }}</td>
        </tr>
      </table>
      <textarea v-model="body" rows="7" cols="60" placeholder="Your message"></textarea>
      <button @click="submit">Submit</button>
    </div>
  `
, data() {
    return {
      body        : ''
    , posts       : []
    , unsubscribe : null
    }
  }
, methods: {
    submit() {
      if ( !this.body ) return
      firebase.firestore().collection( "room-japanese" ).add( 
        { body: this.body
        , date: Date.now()
        , name: this.$store.state.user.displayName
        , user: this.$store.state.user.uid
        }
      ).then(
        () => this.body = ''
      ).catch( e => alert( e ) )
    }
  }
, created() {
    this.unsubscribe = firebase.firestore().collection( "room-japanese" ).orderBy( 'date' ).onSnapshot(
      ss => {
        let w = []
        ss.forEach( doc => w.push( doc.data() ) )
        this.posts = w
      }
    )
  }
, beforeDestroy() {
    this.unsubscribe()
  }
}

let app
firebase.auth().onAuthStateChanged(
  p => {
    if ( app ) {
      app.$store.commit( 'user', p )
    } else {
      let router = new VueRouter(
        { routes: [
            { path: '/login', component: Login }
          , { path: '/'     , component: Chat }
          ]
        }
      )
      router.beforeEach(
        async ( to, from, next ) => {
          if ( firebase.auth().currentUser ) {
            if ( to.path == '/login' ) await firebase.auth().signOut()
            next()
          } else {
            if ( to.path == '/' ) {
              next( { path: '/login', query: { redirect: to.path } } )
            } else {
              next()
            }
          }
        }
      )
      app = new Vue(
        { el    : '#app'
        , store : new Vuex.Store(
            { state     : { user: p }
            , mutations : { user: ( state, payload ) => state.user = payload }
            }
          )
        , router
        , template: '<router-view id="app" />'
        }
      )
    }
  }
)
```

firebase の Auth は初期状態みたいなモードがあって、その時は使えまないというちょっと悩ましい仕様があります。
なので、
firebase.auth().onAuthStateChanged
を最初に発行して、初期状態が終わってからアプリを始めるようにしてあります。


#Quasar でやってみる

上の２つの例にはチャット部屋が一つしかありませんでしたが、ここからは複数のチャット部屋があるような場合をやってみます。
この場合、部屋を選択するための UI を用意する必要が出てきます。
そういう場合素の Vue でガリガリ書いてもいいんですが、コンポーネントライブラリを使用すると楽です。
ここでは最近お気に入りの Quasar を使ってみます。

```
quasar init プロジェクトを格納するフォルダの名前
```

説明の都合上、Error404.vue 以外は全部 main.js にまとめてあります。
まとめる都合上、config/index.js の alias に以下の行を追加してあります。

```
vue: 'vue/dist/vue.js'
```

また ESLint を止めるために
build/webpack.base.conf.js
を編集して、module - rules の // eslint のところをまるまるコメントアウトしてください。

```
 38 //      { // eslint
 39 //        enforce: 'pre',
 40 //        test: /\.(vue|js)$/,
 41 //        loader: 'eslint-loader',
 42 //        include: projectRoot,
 43 //        exclude: /node_modules/,
 44 //        options: {
 45 //          formatter: require('eslint-friendly-formatter')
 46 //        }
 47 //      },
```

```main.js
import firebase from 'firebase'

firebase.initializeApp(
  { apiKey            : "ここには"
  , authDomain        : "自分の"
  , databaseURL       : "やつを"
  , projectId         : "はりつけて"
  , storageBucket     : "ください"
  , messagingSenderId : "ね！"
  }
)

import Vue from 'vue'
Vue.config.productionTip = false

import  Vuex from 'vuex'
Vue.use( Vuex )

import Router from 'vue-router'
Vue.use( Router )

import Quasar from 'quasar'
Vue.use( Quasar )

require(`quasar/dist/quasar.${__THEME}.css`)

if (__THEME === 'mat') {
  require('quasar-extras/roboto-font')
}
import 'quasar-extras/material-icons'

import {
  QLayout
, QToolbar
, QToolbarTitle
, QBtn
, QIcon
, QInput
, QList
, QSideLink
, QItemMain
, QListHeader
, QTabs
, QScrollArea
, QCard
, QCardTitle
} from 'quasar'

function
load ( component ) {
  // '@' is aliased to src/components
  return () => import( `@/${component}.vue` )
}

const
App = {
  template:`
    <q-layout ref="layout">

      <q-toolbar slot="header">
        <q-btn flat @click="$refs.layout.toggleLeft()">
          <q-icon name="menu" />
        </q-btn>
        <q-toolbar-title>
          Talk Circle
          <span slot="subtitle">Running on Quasar v{{$q.version}}</span>
        </q-toolbar-title>
        <template v-if='$store.state.user'>
          <q-toolbar-title>{{ $store.state.user.displayName }} ({{ $store.state.user.email }})</q-toolbar-title>
          <q-btn flat @click="logout()">LOGOUT</q-btn>
        </template>
      </q-toolbar>

      <q-scroll-area slot="left" style="width: 100%; height: 100%;">
        <q-side-link item to='/chat/javascript'>
          <q-item-main label='JavaScript' />
        </q-side-link>
        <q-side-link item to='/chat/vue'>
          <q-item-main label='Vue' />
        </q-side-link>
        <q-side-link item to='/chat/quasar'>
          <q-item-main label='Quasar' />
        </q-side-link>
        <q-side-link item to='/chat/firebase'>
          <q-item-main label='Firebase' />
        </q-side-link>
      </q-scroll-area>

      <router-view :key="$route.path" />

    </q-layout>
  `
, components: {
    QLayout
  , QToolbar
  , QToolbarTitle
  , QBtn
  , QIcon
  , QList
  , QSideLink
  , QItemMain
  , QListHeader
  , QTabs
  , QScrollArea
  }
, methods: {
    async logout() {
      await firebase.auth().signOut()
      this.$router.push( "/" )
    }
  }
}

const
Login = {
  template: `
    <div>
      <q-input v-model="email"    float-label="email"     type="email" />
      <q-input v-model="password" float-label="password"  type="password" />
      <q-btn @click="login">Login</q-btn>
      <q-input v-model="nickname" float-label="nickname" />
      <q-btn @click="signup">Signup</q-btn>
    </div>
  `
, components  : {
    QBtn
  , QInput
  }
, data() {
    return {
      email   : ''
    , password: ''
    , nickname: ''
    }
  }
, methods: {
    login() {
      firebase.auth().signInWithEmailAndPassword( this.email, this.password ).then(
        p => this.$router.push( this.$route.query.redirect ? this.$route.query.redirect : '/' )
      ).catch( e => alert( e ) )
    }
  , signup() {
      if ( this.nickname ) {
        firebase.auth().createUserWithEmailAndPassword( this.email, this.password ).then(
          user => user.updateProfile( { displayName: this.nickname } )
        ).then(
          () => {
            this.$store.commit( 'user', firebase.auth().currentUser )
            this.$router.push( this.$route.query.redirect ? this.$route.query.redirect : '/' )
          }
        ).catch( e => alert( e ) )
      }
    }
  }
}

const
Main = {
  template: '<div>Select talk room from left menu. If you do not see the left menu, press humberger button sitting on the left side of the menu bar.</div>'
}


import 'firebase/firestore'

const
Chat = {
  template: `
    <div>
      <q-card flat v-for="( post, index ) in posts" :key='index'>
        <q-card-title>
          {{ post.body }}
          <span slot='subtitle'>{{ post.name }}</span>
          <span slot='right'>{{ ( new Date( post.date ) ).toString() }}</span>
        </q-card-title>
      </q-card>
      <q-input type="textarea" :min-rows="7" v-model="body" float-label="message" />
      <q-btn @click="submit">送信</q-btn>
    </div>
  `
, components: {
    QBtn
  , QInput
  , QCard
  , QCardTitle
  }
, data() {
    return {
      body        : ''
    , posts       : []
    , unsubscribe : null
    }
  }
, methods: {
    submit() {
      if ( !this.body ) return
      firebase.firestore().collection( "room-" + this.$route.params.id ).add(
        {   body: this.body
        ,   date: Date.now()
        ,   name: this.$store.state.user.displayName
        ,   user: this.$store.state.user.uid
        }
      ).then(
        () => this.body = ''
      ).catch( e => alert( e ) )
    }
  }
, created() {
    this.unsubscribe = firebase.firestore().collection( "room-" + this.$route.params.id ).orderBy( 'date' ).onSnapshot(
      ss => {
        let w = []
        ss.forEach( doc => w.push( doc.data() ) )
        this.posts = w
      }
    )
  }
, beforeDestroy() {
    this.unsubscribe()
  }
}

let app
firebase.auth().onAuthStateChanged(
  p => {
    if ( app ) {
      app.$store.commit( 'user', p )
    } else {
      Quasar.start(
        () => {
          app = new Vue(
            { el    : '#q-app'
            , store : new Vuex.Store(
                { state   : { user: p }
                , mutations : { user: ( state, payload ) => state.user = payload }
                }
              )
            , router  : new Router(
                { mode: 'history'
                , scrollBehavior: () => ( { y: 0 } )
                , routes: [
                  , { path: '/'         , component: Main }
                  , { path: '/chat/:id' , component: Chat }
                  , { path: '/login'    , component: Login }
                  , { path: '*'         , component: load( 'Error404' ) }
                  ]
                }
              )
            , render  : h => h( App )
            }
          )
          app.$router.beforeEach(
            async ( to, from, next ) => {
              if ( firebase.auth().currentUser ) {
                if ( to.path == '/login' ) await firebase.auth().signOut()
                next()
              } else {
                if ( to.path.startsWith( '/chat/' ) ) {
                  next( { path: '/login', query: { redirect: to.path } } )
                } else {
                  next()
                }
              }
            }
          )
        }
      )
    }
  }
)
```

# Firestore のルールの実例

Firestore のルールをテストモード（全ての読み書きを無条件で許可）にしままなのもなんなので、実際は以下のようにしてあります。

```
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow create: if request.auth != null && request.resource.data.user == request.auth.uid;
      allow update: if false;
      allow delete: if false;
      allow list: if request.auth != null;
      allow get: if request.auth != null;
    }
  }
}
```