8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

FirebaseでNEETがCHATを作った

Last updated at Posted at 2019-06-19

はじめに

Firebase の勉強のためにチャットアプリを作ってみました 🤤
Google 認証 ・ Firestore ・ Vuetify ・ vuexfire を使っています 🙄

DEMO

https://neetschat.firebaseapp.com/

Screen Shot 2019-06-19 at 16.32.06.png

コードはこちらにあります

対象読者

Vue と Firebase をさらっと勉強して何か作ってみたい方

以下コードを抜粋したものです

Plugins

Firebase の設定
(firebase.initializeApp はご自身のプロジェクトの値を設定してください)

src/plugins/firebase.js
import firebase from "firebase/app";
import "firebase/firestore";
import "firebase/auth";

firebase.initializeApp({
  apiKey: "xxxxxx",
  authDomain: "xxxxxx.firebaseapp.com",
  databaseURL: "xxxxxx.firebaseio.com",
  projectId: "xxxxxx",
  storageBucket: "xxxxxx.appspot.com",
  messagingSenderId: "xxxxxx"
});

export default firebase;

ログインしているかの確認用

src/plugins/auth.js
import firebase from "@/plugins/firebase";

function auth() {
  return new Promise(resolve => {
    firebase.auth().onAuthStateChanged(user => {
      resolve(user || false);
    });
  });
}
export default auth;

Vuetify の設定
@mdi/font パッケージを追加してアイコンフォントを mdi に変更しました
Installing Icons — Vuetify.js

src/plugins/vuetify.js
import "@mdi/font/css/materialdesignicons.css";
import Vue from "vue";
import Vuetify from "vuetify/lib";
import "vuetify/src/stylus/app.styl";

Vue.use(Vuetify, {
  iconfont: "mdi"
});

Store

vuexfire を使うと firestore と state を bind してくれて捗ります。

getters で message と user 情報を map している箇所がありますが、
firestore には参照型があるそうなので、そっちを使ったほうがいいのかも

src/store.js
import Vue from "vue";
import Vuex from "vuex";
import firebase from "@/plugins/firebase";
import { vuexfireMutations, firestoreAction } from "vuexfire";

const firestore = firebase.firestore();
const provider = new firebase.auth.GoogleAuthProvider();

Vue.use(Vuex);

export default new Vuex.Store({
  strict: true,
  state: {
    user: null,
    users: [],
    messages: [],
    isLoaded: false
  },
  mutations: {
    ...vuexfireMutations,
    setCredential(state, user) {
      state.user = user;
    },
    setIsLoaded(state, isLoaded) {
      state.isLoaded = isLoaded;
    }
  },
  getters: {
    messages: state => {
      return state.messages.map(message => {
        message.user = state.users.find(user => user.uid === message.uid);
        return message;
      });
    },
    user: state => state.user,
    users: state => state.users,
    isLoaded: state => state.isLoaded,
    isLoggedin: state => !!state.user
  },
  actions: {
    initUsers: firestoreAction(({ bindFirestoreRef }) => {
      const usersCollection = firestore.collection("users");
      bindFirestoreRef("users", usersCollection);
    }),
    initMessages: firestoreAction(({ bindFirestoreRef }) => {
      const messagesCollection = firestore
        .collection("messages")
        .orderBy("createdAt", "desc");
      bindFirestoreRef("messages", messagesCollection);
    }),
    signIn() {
      firebase.auth().signInWithRedirect(provider);
    },
    async signOut({ commit }) {
      await firebase.auth().signOut();
      commit("setCredential", null);
    },
    async setCredential({ commit }, user) {
      if (!user) return;
      const usersCollection = firestore.collection("users");
      await usersCollection.doc(user.email).set({
        name: user.displayName,
        uid: user.uid,
        email: user.email,
        icon: user.photoURL
      });

      commit("setCredential", {
        name: user.displayName,
        uid: user.uid,
        icon: user.photoURL
      });
    },
    addMessage(context, { content, uid }) {
      const serverTimestamp = firebase.firestore.FieldValue.serverTimestamp();
      firestore
        .collection("messages")
        .add({
          content,
          uid,
          createdAt: serverTimestamp,
          updatedAt: serverTimestamp
        })
        .then(docRef => {
          console.log(`${docRef.id} successfully added!`);
        })
        .catch(error => {
          console.log(error);
        });
    },
    removeMessage(context, id) {
      firestore
        .collection("messages")
        .doc(id)
        .delete()
        .then(function() {
          console.log("message successfully deleted!");
        })
        .catch(error => {
          console.log(error);
        });
    },
    loadComplete({ commit }) {
      commit("setIsLoaded", true);
    }
  }
});

App.vue

認証チェック ・ メッセージ と ユーザー情報の初期化 をしています。

src/App.vue
<template>
  <v-app>
    <v-navigation-drawer v-model="drawer" fixed app>
      <v-list dense>
        <v-list-tile to="/">
          <v-list-tile-action>
            <v-icon>home</v-icon>
          </v-list-tile-action>
          <v-list-tile-content>
            <v-list-tile-title>Home</v-list-tile-title>
          </v-list-tile-content>
        </v-list-tile>
        <v-list-tile to="/code">
          <v-list-tile-action>
            <v-icon>code</v-icon>
          </v-list-tile-action>
          <v-list-tile-content>
            <v-list-tile-title>Code</v-list-tile-title>
          </v-list-tile-content>
        </v-list-tile>
      </v-list>
    </v-navigation-drawer>
    <v-toolbar color="indigo" dark fixed app prominent>
      <v-toolbar-side-icon @click.stop="drawer = !drawer"></v-toolbar-side-icon>
      <v-toolbar-title>
        <span>NEET</span>
        <span class="font-weight-light">Chat</span>
      </v-toolbar-title>
      <v-spacer></v-spacer>
      <v-toolbar-items v-if="isLoaded">
        <div v-if="isLoggedin" class="text-xs-center">
          <v-menu v-model="menu" :close-on-content-click="false" offset-y>
            <template v-slot:activator="{ on }">
              <v-btn flat fab v-on="on">
                <v-avatar>
                  <img :src="user.icon" />
                </v-avatar>
              </v-btn>
            </template>

            <v-card>
              <v-list>
                <v-list-tile avatar>
                  <v-list-tile-avatar>
                    <img :src="user.icon" :alt="user.name" />
                  </v-list-tile-avatar>

                  <v-list-tile-content>
                    <v-list-tile-title>{{ user.name }}</v-list-tile-title>
                    <v-list-tile-sub-title>
                      {{ user.email }}
                    </v-list-tile-sub-title>
                  </v-list-tile-content>
                </v-list-tile>
              </v-list>
              <v-divider></v-divider>
              <v-list>
                <v-list-tile @click="signOut">
                  <v-list-tile-title>Signout</v-list-tile-title>
                </v-list-tile>
              </v-list>
            </v-card>
          </v-menu>
        </div>
        <v-btn v-if="!isLoggedin" flat @click="signIn">Signin Google</v-btn>
      </v-toolbar-items>
    </v-toolbar>
    <v-content>
      <Loading v-if="!isLoaded" />
      <router-view v-else />
    </v-content>
    <v-footer color="indigo" app>
      <span class="white--text">&copy; 2019</span>
    </v-footer>
  </v-app>
</template>

<script>
import { mapActions, mapGetters } from "vuex";
import auth from "@/plugins/auth";
import Loading from "@/components/Loading";

export default {
  name: "App",
  components: {
    Loading
  },
  data: () => ({
    drawer: null,
    menu: false
  }),
  computed: {
    ...mapGetters(["user", "users", "messages", "isLoaded", "isLoggedin"])
  },
  async mounted() {
    let user;
    if (!this.user) user = await auth();
    await Promise.all([
      this.user
        ? Promise.resolve()
        : this.$store.dispatch("setCredential", user || null),
      this.users.length ? Promise.resolve() : this.$store.dispatch("initUsers"),
      this.messages.length
        ? Promise.resolve()
        : this.$store.dispatch("initMessages")
    ]);
    this.$store.dispatch("loadComplete");
  },
  methods: {
    ...mapActions(["signOut", "signIn"])
  }
};
</script>

Component

メッセージ入力

ログインユーザのみメッセージ入力できるように v-if で表示切替しています。
絵文字入力には、シンプルで使いやすそうだった v-emoji-picker を使っています。
joaoeudes7/V-Emoji-Picker

関係ないですが絵文字って海外でも emoji で通じるんですね!

src/components/MessageInput.vue
<template>
  <v-flex v-if="isLoggedin" class="my-3" xs12 sm10>
    <v-form ref="messageForm" v-model="valid" @submit.prevent="addMessage">
      <v-text-field
        ref="messageText"
        v-model="newMessage"
        :rules="required"
        label="message"
        prepend-inner-icon="mdi-emoticon"
        append-outer-icon="mdi-send"
        required
        clear-icon="mdi-close-circle"
        clearable
        @click:clear="clearMessage"
        @click:prepend-inner="toggleEmojiPicker"
        @click:append-outer="addMessage"
        @blur="$refs.messageForm.resetValidation()"
      ></v-text-field>
      <v-dialog v-model="dialog" :hide-overlay="true" width="325px">
        <VEmojiPicker
          :pack="pack"
          :show-search="false"
          :emojis-by-row="8"
          label-search="search"
          @select="selectEmoji"
        />
      </v-dialog>
    </v-form>
  </v-flex>
  <v-flex v-else class="my-3" xs12 sm10>
    <p class="font-weight-medium headline red--text">
      Sign in. with your Google Account.
    </p>
  </v-flex>
</template>

<script>
import { mapGetters } from "vuex";
import VEmojiPicker from "v-emoji-picker";
import packData from "v-emoji-picker/data/emojis.json";

export default {
  name: "MessageInput",

  components: {
    VEmojiPicker
  },
  data: () => ({
    newMessage: "",
    valid: true,
    pack: packData,
    required: [v => !!(v + "").trim() || "message is required"],
    dialog: false
  }),
  computed: {
    ...mapGetters(["isLoggedin", "user"])
  },
  watch: {
    dialog() {
      if (!this.dialog) {
        this.inputFocus();
      }
    }
  },
  mounted() {
    this.inputFocus();
  },
  methods: {
    async addMessage() {
      if (!this.$refs.messageForm.validate()) {
        return;
      }

      await this.$store.dispatch("addMessage", {
        content: this.newMessage,
        uid: this.user.uid
      });
      this.clearMessage();
    },
    selectEmoji(dataEmoji) {
      if (!this.dialog) {
        return;
      }

      if (!this.newMessage) {
        this.newMessage = "";
      }

      this.newMessage += dataEmoji.emoji;
      this.dialog = false;
      this.inputFocus();
    },
    toggleEmojiPicker() {
      this.dialog = !this.dialog;
    },
    inputFocus() {
      this.$refs.messageText.focus();
    },
    clearMessage() {
      this.$refs.messageForm.resetValidation();
      this.newMessage = "";
    }
  }
};
</script>

メッセージリストの表示

自分が投稿したメッセージのみ削除できるようにしています。

src/components/MessageList.vue
<template>
  <v-flex class="my-3" xs12 sm10>
    <v-list three-line>
      <transition-group name="slide-fade">
        <template v-for="(message, index) in messages">
          <v-divider v-if="index != 0" :key="index"></v-divider>
          <v-list-tile :key="message.id" avatar>
            <v-list-tile-avatar>
              <img :src="message.user.icon" />
            </v-list-tile-avatar>
            <v-list-tile-content>
              <v-list-tile-sub-title class="text--primary subheading">{{
                message.content
              }}</v-list-tile-sub-title>
              <v-list-tile-sub-title>{{
                fromNow(message.createdAt)
              }}</v-list-tile-sub-title>
            </v-list-tile-content>
            <v-list-tile-action v-if="user && user.uid == message.uid">
              <v-btn icon ripple @click="removeMessage(message.id)">
                <v-icon color="amber lighten-1">delete</v-icon>
              </v-btn>
            </v-list-tile-action>
          </v-list-tile>
        </template>
      </transition-group>
    </v-list>
  </v-flex>
</template>

<script>
import { mapGetters } from "vuex";
import moment from "moment-timezone";

export default {
  name: "MessageList",
  computed: {
    ...mapGetters(["messages", "user"])
  },
  methods: {
    removeMessage(id) {
      this.$store.dispatch("removeMessage", id);
    },
    fromNow(timestamp) {
      if (!timestamp) {
        return;
      }

      return moment(timestamp.toDate().toISOString()).fromNow();
    }
  }
};
</script>

<style scoped>
.slide-fade-enter-active,
.slide-fade-leave-active {
  transition: all 0.5s ease;
}
.slide-fade-enter,
.slide-fade-leave-to {
  transform: translateX(10px);
  opacity: 0;
}
</style>

さいごに

最後まで読んでいただきありがとうございました 😆

Firebase を触る前は「サーバレス何それって」感じだったんですが Firebase かなりイイ感じですね
なんと言っても無料でスタートできるって、スゴイですよね Google さんまじ太っ腹 😁

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?