masalibの日記

システム開発、運用と猫の写真ブログです

React Firebase入門 Realtime Databaseでchatアプリ(一覧)

Firebase Realtime Databaseを使ってChatのアプリを作りたいと思います 書き込みよりも難しいのは一覧取得でした。

この記事はReact Firebase入門シリーズです 1-1-1・ Firebase初期設定とFirebaseAuthのSignUp
1-1-2・ 「react-hook-form」を入れてみた
1-1-3・ AuthのSignUpの通信エラー対応
1-2 ・ FirebaseAuthのログイン処理
1-3 ・ FirebaseAuthのログイン認証とログアウト処理
1-4 ・ FirebaseAuthのパスワード初期化処理
1-5 ・ FirebaseAuthのメールアドレスの有効化
1-6 ・ FirebaseAuthのメールアドレスとパスワード変更
1-7 ・ FirebaseAuthの表示名変更
1-8 ・ FirebaseAuthの拡張項目追加
1-9-1 ・ FirebaseAuthのTwitter認証
1-9-2 ・ FirebaseAuthのTwitter認証(既存ユーザー向け)と解除
1-10 ・ FirebaseAuthのGoogle認証(既存ユーザー含む)と解除 2-1・ FirebaseStorageのファイルアップ:基礎
2-2・ FirebaseStorageのファイルアップ前に画像の切り抜き
2-3・ FirebaseStorageのファイルアップの移動
3-1・FirestoreのCRUD
3-2・Firestoreのデータ取得補足
3-3・Firestoreのページネーション処理
3-4・Firestoreのコレクション(テーブル)のJOIN
4-1・Realtime Databaseでchatアプリ(書き込み)
4-2・Realtime Databaseでchatアプリ(一覧取得) 今ここ
5・ FirebaseのHosting(デプロイ)
6-1・ Cloud FunctionsでHello,world
6-2・ Cloud Functions(エミュレータ)でHello,world
6-3・ ユーザーの作成時にCloud Functionsを実行
6-4・ Cloud Functionsでメール送信
6-5・ Cloud Functionsでslackに通知する
よかったら他の記事も見てください。

やりたい事

  • chatアプリの作成
    • Realtimedatabaseの一覧取得

mysqlなら楽勝のレベルですが、他の人が更新に反映する部分がちょっと面倒くさいです

プログラム

ルーティングの設定

chatの一覧取得のルーティングを追加します

import {Index as chatIndex }  from "./chat/Index"
function App() {
  return (
    <>
    <Router>
      <AuthProvider>
        <Switch>
     ・
     ・
          <AuthFirebaseRoute path="/chat/index" component={chatIndex} />
     ・
        </Switch>
      </AuthProvider>
    </Router>
    </>
  );
}

このプログラムは書き込み時にユーザーも書き込みのでAuthの情報が必要です。

Chatのデータ取得プログラム

import {useMemo,useState, useEffect}  from 'react'
import {database} from "../../firebase"

// カスタムフックにしておく
const useDatabase = () => {
    // 同じパスでは毎回同じ結果が得られるのでmemo化しておく
    return useMemo(() => database.ref('/messages').orderByChild('createAt').limitToLast(30), []);
};

// hooksを使いたいのでカスタムhooksにしておく
const useFetchData = (ref) => {
    const [data, setData] = useState();
    useEffect(() => {
        // イベントリスナーを追加するにはonを使う
    ref.on('value', snapshot => {
            // パスに対する全データを含むsnapshotが渡される
            // ない場合はnullが変えるので存在をチェックしておく
        if (snapshot?.val()) {
            console.log("snapshot.val()")
            setData(snapshot.val());
        }
    });
    return () => {
        ref.off();
    };
    // refの変更に応じて再取得する
    }, [ref]);
    // データを返却する
    return { data };
}

// 実際に呼び出す際はこちらを使う
export const useFetchAllData = () => {
    // refを取得して
    const ref = useDatabase();
    // ref渡してデータを取得する
    return useFetchData(ref);
};
  • このプログラムは以下の記事を参考にしてつくりました

qiita.com

  • イベントリスナーを追加するにはonの部分が更新時に反映してくれる部分になります。

  • useMemoを使って無駄なRenderingを減らしています

  • orderByChildを使っているので下記のWARNINGが発生します

FIREBASE WARNING: Using an unspecified index. Your data will be downloaded and filtered on the client. Consider adding ".indexOn": "createAt" at /messages to your security rules for better performance. 

回避するためにルールの部分にインデックスを貼ります

{
  "rules": {
    ".read": "true",  
    ".write": "auth != null",
    "messages": {
      ".indexOn": ["createAt"]
    },  
  }
}

ついでにWriteのセキュリティーのルールも変えます。 この設定でエラーが消えます。

メッセージ単体

メッセージデータをもらって出力するだけのコンポーネントです。 AUTH関係のContextのデータも使っていないので単純なfunctionコンポーネントです。CSSの部分が長い

import React , { useEffect, useRef } from 'react'
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Avatar from '@material-ui/core/Avatar';
import { deepOrange } from '@material-ui/core/colors';


const useStyles = makeStyles((theme: Theme) =>
    createStyles({
        messageRow: {
            display: "flex",
        },
        messageRowRight: {
            display: "flex",
            justifyContent: "flex-end"
        },
        messageBlue: {
            position: "relative",
            marginLeft: "20px",
            padding: "10px",
            backgroundColor: "#A8DDFD",
            marginBottom: "10px",
            width: "60%",
            minWidth: "30%",
            //minHeight: "50px",
            textAlign: "left",
            font: "400 .9em 'Open Sans', sans-serif",
            border: "1px solid #97C6E3",
            borderRadius: "10px",
            '&:after': {
                content: "''",
                position: "absolute",
                width: "0",
                height: "0",
                borderTop: "15px solid #A8DDFD",
                borderLeft: "15px solid transparent",
                borderRight: "15px solid transparent",
                top: "0",
                left: "-15px",
            },
            '&:before': {
                content: "''",
                position: "absolute",
                width: "0",
                height: "0",
                borderTop: "17px solid #97C6E3",
                borderLeft: "16px solid transparent",
                borderRight: "16px solid transparent",
                top: "-1px",
                left: "-17px",
            },
        },
        messageOrange: {
            position: "relative",
            marginRight: "20px",
            marginBottom: "10px",
            padding: "10px",
            backgroundColor: "#f8e896",
            width: "60%",
            //height: "50px",
            textAlign: "left",
            font: "400 .9em 'Open Sans', sans-serif",
            border: "1px solid #dfd087",
            borderRadius: "10px",
            '&:after': {
                content: "''",
                position: "absolute",
                width: "0",
                height: "0",
                borderTop: "15px solid #f8e896",
                borderLeft: "15px solid transparent",
                borderRight: "15px solid transparent",
                top: "0",
                right: "-15px",
            },
            '&:before': {
                content: "''",
                position: "absolute",
                width: "0",
                height: "0",
                borderTop: "17px solid #dfd087",
                borderLeft: "16px solid transparent",
                borderRight: "16px solid transparent",
                top: "-1px",
                right: "-17px",
            },
        },

        messageContent: {
            padding: 0,
            margin: 0,
        },  
        messageTimeStampRight: {
            position: "absolute",
            fontSize: ".85em",
            fontWeight: "300",
            marginTop: "10px",
            bottom: "-3px",
            right: "5px",
        },

        orange: {
            color: theme.palette.getContrastText(deepOrange[500]),
            backgroundColor: deepOrange[500],
            width: theme.spacing(4),
            height: theme.spacing(4),
        },
        avatarNothing: {
            color: "transparent",
            backgroundColor: "transparent",
            width: theme.spacing(4),
            height: theme.spacing(4),
        },
        displayName: {
            marginLeft: "20px",
        },
    })
);

//avatarが左にあるメッセージ(他人)
export const MessageLeft = (props) => {
    const message = props.message ? props.message : 'no message' ;
    const timestamp = props.timestamp ? props.timestamp : '' ;
    const photoURL = props.photoURL ? props.photoURL : 'dummy.js' ;
    const displayName = props.displayName ? props.displayName : '名無しさん' ;
    const classes = useStyles();
    return (
        <div className={classes.messageRow}>

            <Avatar alt={displayName} className={classes.orange}  src={photoURL} ></Avatar> 
            <div>
            <div className={classes.displayName}>{displayName}</div>
            <div className={classes.messageBlue}>
                <div><p className={classes.messageContent}>{ message}</p></div>
                <div className={classes.messageTimeStampRight}>{timestamp}</div>
            </div>
            </div>
        </div>
    )
}
//自分のメッセージ
export const MessageRight = (props) => {
    const classes = useStyles();
    const message = props.message ? props.message : 'no message' ;
    const timestamp = props.timestamp ? props.timestamp : '' ;
    const messagesEndRef = useRef(null)
    const scrollToBottom = () => {
        messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
    }
    useEffect(scrollToBottom, [props]);
    return (
        <>
        <div className={classes.messageRowRight}>
            <div className={classes.messageOrange}>
                <p className={classes.messageContent}>{ message}</p>
                <div className={classes.messageTimeStampRight}>{timestamp}</div>
            </div>
        </div>
        <div ref={messagesEndRef} />
        </>
    )
}

単体の部分だとこんな感じになります

他人のメッセージの場合

f:id:masalib:20201218214343p:plain

自分の場合はメッセージ

f:id:masalib:20201218214439p:plain

データが入った時にスクロールするようにしています。いらない場合はこの処理を消す形になる

const messagesEndRef = useRef(null)
const scrollToBottom = () => {
     messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}

Chatの一覧プログラム

import React ,{useMemo}  from 'react'
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import { Paper, } from '@material-ui/core';
import {TextInput} from './TextInput.js';
import {MessageLeft,MessageRight} from './Message';
import { useFetchAllData } from './firebaseDB';
import { useAuth } from "../../contexts/AuthContext"
import moment from 'moment';

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
        paper: {
            width: '80vw',
            height: '80vh',
            maxWidth: '500px',
            maxHeight: '700px',
            display: 'flex',
            alignItems: 'center',
            flexDirection: 'column',
            position: 'relative'
        },
        paper2: {
            width: '80vw',
            maxWidth: '500px',
            display: 'flex',
            alignItems: 'center',
            flexDirection: 'column',
            position: 'relative'
        },
        container: {
            width: '100vw',
            height: '100vh',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center'
        },
        messagesBody: {
            width: 'calc( 100% - 20px )', 
            margin: 10,
            overflowY: 'scroll', 
            height: 'calc( 100% - 80px )'
        },
    })
);



export const Index = () => {
    const classes = useStyles();
    const { currentUser} = useAuth()   //Firebaseの共通変数と変更などの関数

    const { data } = useFetchAllData();
    // object形式なので使いやすいように{key, value}形式のリストに変換する
    // また、データが変わらない限り結果は同じなのでメモ化しておく
    const dataList = useMemo(() => Object.entries(data || {}).map(([key, value]) => ({ key, value })), [data]);
    console.log(dataList)
    let viewdate = '';

    return (
            <div className={classes.container}>
                <Paper className={classes.paper} >
                    <Paper id="style-1" className={classes.messagesBody}>
                    {dataList.length === 0  && "loading..."}   
                    {dataList.map(({ key, value }) =>
                            <React.Fragment key={`${key}`}>
                                {
                                //日付がちがった場合は日付を出力する(jsxだと単純にif文をかけないのでアロー関数で逃げる)
                                (() => {
                                if (viewdate !== moment(value.createAt).format('YYYY/MM/DD')) {
                                    viewdate = moment(value.createAt).format('YYYY/MM/DD')
                                    return(viewdate);
                                    } 
                                })()
                                }
                                { currentUser.uid !== value.uid &&
                                <MessageLeft 
                                    message={value.message + '  '}
                                    timestamp={moment(value.createAt).format('HH:mm') }
                                    photoURL={value.photoURL}
                                    displayName={value.displayName}
                                />
                                }
                                { currentUser.uid === value.uid &&
                                <MessageRight 
                                    message={value.message + '  '}
                                    timestamp={moment(value.createAt).format('HH:mm') }
                                    photoURL={value.photoURL}
                                    displayName={value.displayName}
                                />
                                }
                            </React.Fragment>
                        )}
                    </Paper>
                    <TextInput />
                </Paper>
            </div>
    )
}

プログラムの補足

  • JSXは普通にIFをかけないのでアロー関数で書いています。ちょっと面倒くさい
{
//日付がちがった場合は日付を出力する(jsxだと単純にif文をかけないのでアロー関数で逃げる)
(() => {
if (viewdate !== moment(value.createAt).format('YYYY/MM/DD')) {
    viewdate = moment(value.createAt).format('YYYY/MM/DD')
    return(viewdate);
    } 
})()
}
  • currentUser.uid === value.uid は自分のメッセージは右からのコンポーネントを呼ぶために切り替えています。メッセージのコンポーネント側で切り替えてもよかった気がする。

  • {dataList.length === 0 && "loading..."} はRealTime Databaseは初期の読み込みに時間がかかるのでLoadingの文字を出力しています

  • message={value.message + '  '} は半角だけのメッセージだと枠におさまらないという問題があったので全角スペースを足すことでさけました。CSSの力があればこんな作りにならないですが・・・

結果

f:id:masalib:20201218215354p:plain

できていない事

  • テキストしか入力できないです。LINEだと画像とかスタンプとか入れれます。作りたいけど難しい

感想

  • データ取得部分は丸パクリなので、理解が浅いのもう少し理解を深めたい