11
12

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 1 year has passed since last update.

LINE風リアルタイムチャット(React18 + FirebaseSDK V9対応版)

Last updated at Posted at 2023-02-23

リアルタイムなLine風チャットアプリのReactとFirebaseを最新にしました

  • React18 + FirebaseSDK V9 + ReactRouter6
  • 名前からアイコンを作成して表示する機能を追加
  • 複数チャットルーム対応(/chat/room1、/chat/room2 ・・・)

ソース一式
github firebase-chat2

はじめに

  • 画面イメージ

    chat.png

  • Line風のチャットアプリです(複数人の投稿がリアルタイムに更新されます)

  • Firestoreを利用します(googleのアカウントがあれば利用できます)

  • 初回利用時に名前を入力すると保存されます(ログイン無し、名前はLocalStorageに保存)

  • 名前からアイコンを作って表示します(OffscreenCanvasに描画して、Suspenseで表示)

  • バックエンドのロジックはありません(Firestoreの同期機能を利用)

利用モジュール

React18(create-react-app)、ReactRouter6、 firebaseSDK9を利用

  "dependencies": {
    "firebase": "^9.17.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.8.1",
    "react-scripts": "5.0.1",
    "typescript": "^4.9.5"
  }

主なファイル

ファイル 概要
index.tsx <BrowserRouter>を追加(ルート用)
App.tsx ルート定義
firebaseConfig.ts FireBaseの設定と、初期化
ChatPage.tsx チャットコンポーネント(Line風)
ChatPage.css チャットらしく見せるためのstyle定義
iconMaker.ts 名前アイコン描画関数
NameIcon.tsx Suspenseを使いCanvasで生成した画像を表示するコンポー年と

Firestoreについて

Firebaseとは、Google が提供しているモバイル、Webアプリ向けのバックエンドサービスです。Firestoreというリアルタイムで同期ができるNoSQLデータベースを利用します。

Firebaseプロジェクトの作成方法は下記の外部ページでご確認ください。

[Firebase] プロジェクトを新規に作成する

(Firebaseコンソールから、コレクションを手動で作る必要はありません)

Firestore接続設定

.envファイルで設定します。

設定する内容はfirebaseのコンソール(プロジェクトの設定⇒全般)から確認します。

firebase_config.png

  • .env
#Firebase configuration
REACT_APP_API_KEY=XXXXXXXXXXXXXXXXXXX
REACT_APP_AUTH_DOMAIN=fir-chat2-b8c69.firebaseapp.com
REACT_APP_PROJECT_ID=fir-chat2-b8c69
REACT_APP_STORAGE_BUCKET=fir-chat2-b8c69.appspot.com
REACT_APP_MESSAGEING_SENDER_ID=XXXXXXXXXXXXXXXXXXX
REACT_APP_APP_ID=XXXXXXXXXXXXXXXXXXX
  • firebaseConfig.ts

Firebase SDKの初期化を行いdbをエクスポートします(V8以前と書き方が少し変わっています)

import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';

const firebaseConfig = {
  apiKey: process.env.REACT_APP_API_KEY,
  authDomain: process.env.REACT_APP_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_PROJECT_ID,
  storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_MESSAGEING_SENDER_ID,
  appId: process.env.REACT_APP_APP_ID,
};

// Initialize Firebase
export const firebaseApp = initializeApp(firebaseConfig);
export const db = getFirestore(firebaseApp);

ソースコード

Line風の見た目はCSSだけでLINE風の「吹き出し」を作る方法を参考にさせていただきました。

ルート定義

パスを省略時は/chat/room1へ遷移します。
:roomはパラメータとなっており、DBに見つからない場合は別チャットルームが作成されます。

<Routes>
  <Route path="/chat/:room" element={<ChatPage />} />
  <Route path="*" element={<Navigate replace to="/chat/room1" />} />
</Routes>

firestore-data.png

チャットの見た目部分

チャットログはChatLog型の配列です。自分の名前のログを右側に、他人を左側に表示するようにclass名を切り替えています。

type ChatLog = {
  key: string,
  name: string,
  msg: string,
  date: Date,
};
  • Chat1.tsx
<>
  {/* チャットログ */}
  <div>
    {chatLogs.map((item) => (
      <div
        className={`balloon_${userName === item.name ? 'r' : 'l'}`}
        key={item.key}
      >
        {userName === item.name ? `[${formatHHMM(item.date)}]` : ''}
        <div className="faceicon">
          <NameIcon
            userName={item.name}
            option={{ foreColor: userName === item.name ? '#69C' : '#969' }}
          />
        </div>
        <div style={{ marginLeft: '3px' }}>
          {item.name}
          <p className="says">{item.msg}</p>
        </div>
        {userName === item.name ? '' : `[${formatHHMM(item.date)}]`}
      </div>
    ))}
  </div>

  {/* メッセージ入力 */}
  <form
    className="chatform"
    onSubmit={async (e) => {
      e.preventDefault();
      await submitMsg();
    }}
  >
    <div>{userName}</div>
    <input
      type="text"
      value={inputMsg}
      onChange={(e) => setInputMsg(e.target.value)}
    />
    <input
      type="image"
      onClick={() => submitMsg}
      src="../img/airplane.png"
      alt="Send Button"
    />
  </form>
</>

データ同期処理

チャットルーム名をURLのパラメータ(:room)から取得します(存在しない場合は、チャットルームが新規に登録されます(別チャットルームができます))

チャットルームのリファレンス(messagesRef)を取得して、最新10件を取得します

onSnapshot()で変更イベントを受け取ります。別の人が追加したチャットメッセージを受け取り、ログに追加します。

  • 追加しか行わないため、追加イベントのみを処理します。
  // /chat/:room urlのパラメータ(チャットルーム名)
  const { room } = useParams<{ room: string }>();
  const messagesRef = useMemo(
    () => collection(db, 'chatroom', room, 'messages'),
    [room]
  );

  // ~~中略~~

  useEffect(() => {
    // 最新10件をとるためdateでソート
    const q = query(messagesRef, orderBy('date', 'desc'), limit(10));
    // データ同期(講読解除(cleanup)のためreturn)
    return onSnapshot(q, (snapshot: QuerySnapshot) => {
      snapshot.docChanges().forEach((change) => {
        if (change.type === 'added') {
          // チャットログへ追加
          addLog(change.doc.id, change.doc.data());

          // 画面最下部へスクロール
          const doc = document.documentElement;
          window.setTimeout(
            () => window.scroll(0, doc.scrollHeight - doc.clientHeight),
            100
          );
        }
      });
    });

チャットメッセージ追加処理

自身が入力した場合と、データ同期(のコールバック)から呼び出される処理です

  /**
   * チャットログに追加
   */
  const addLog = (id: string, data: any) => {
    const log = {
      key: id,
      ...data,
    };
    // Firestoreから取得したデータは時間降順のため、表示前に昇順に並び替える
    setChatLogs((prev) =>
      [...prev, log].sort((a, b) => a.date.valueOf() - b.date.valueOf())
    );
  };

ソースコード全体

import React, { useState, useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { db } from './firebaseConfig';
import {
  collection,
  onSnapshot,
  addDoc,
  QuerySnapshot,
  query,
  orderBy,
  limit,
} from 'firebase/firestore';
import NameIcon from './NameIcon';
import './ChatPage.css';

type ChatLog = {
  key: string;
  name: string;
  msg: string;
  date: Date;
};

/**
 * ユーザー名 (localStrageに保存)
 * */
const getUName = (): string => {
  const userName = localStorage.getItem('firebase-Chat2-username');
  if (!userName) {
    const inputName = window.prompt('ユーザー名を入力してください', '');
    if (inputName) {
      localStorage.setItem('firebase-Chat2-username', inputName);

      return inputName;
    }
  }

  return userName;
};

/**
 * UNIX TIME => hh:mm
 * */
const formatHHMM = (time: Date) => {
  return new Date(time).toTimeString().slice(0, 5);
};

/**
 * チャットコンポーネント(Line風)
 * ・localStorageに名前がなければ入力
 * ・自分の入力を右側、他の人は左側に表示
 */
const ChatPage: React.FC = () => {
  const [chatLogs, setChatLogs] = useState<ChatLog[]>([]);
  const [inputMsg, setInputMsg] = useState('');

  const userName = useMemo(() => getUName(), []);

  // /chat/:room urlのパラメータ(チャットルーム名)
  const { room } = useParams<{ room: string }>();
  const messagesRef = useMemo(
    () => collection(db, 'chatroom', room, 'messages'),
    [room]
  );

  /**
   * チャットログに追加
   */
  const addLog = (id: string, data: any) => {
    const log = {
      key: id,
      ...data,
    };
    // Firestoreから取得したデータは時間降順のため、表示前に昇順に並び替える
    setChatLogs((prev) =>
      [...prev, log].sort((a, b) => a.date.valueOf() - b.date.valueOf())
    );
  };

  /**
   * メッセージ送信
   */
  const submitMsg = async (argMsg?: string) => {
    const message = argMsg || inputMsg;
    if (!message) {
      return;
    }

    await addDoc(messagesRef, {
      name: userName,
      msg: message,
      date: new Date().getTime(),
    });

    setInputMsg('');
  };

  useEffect(() => {
    // 最新10件をとるためdateでソート
    const q = query(messagesRef, orderBy('date', 'desc'), limit(10));
    // データ同期(講読解除(cleanup)のためreturn)
    return onSnapshot(q, (snapshot: QuerySnapshot) => {
      snapshot.docChanges().forEach((change) => {
        if (change.type === 'added') {
          // チャットログへ追加
          addLog(change.doc.id, change.doc.data());

          // 画面最下部へスクロール
          const doc = document.documentElement;
          window.setTimeout(
            () => window.scroll(0, doc.scrollHeight - doc.clientHeight),
            100
          );
        }
      });
    });

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      {/* チャットログ */}
      <div>
        {chatLogs.map((item) => (
          <div
            className={`balloon_${userName === item.name ? 'r' : 'l'}`}
            key={item.key}
          >
            {userName === item.name ? `[${formatHHMM(item.date)}]` : ''}
            <div className="faceicon">
              <NameIcon
                userName={item.name}
                option={{ foreColor: userName === item.name ? '#69C' : '#969' }}
              />
            </div>
            <div style={{ marginLeft: '3px' }}>
              {item.name}
              <p className="says">{item.msg}</p>
            </div>
            {userName === item.name ? '' : `[${formatHHMM(item.date)}]`}
          </div>
        ))}
      </div>

      {/* メッセージ入力 */}
      <form
        className="chatform"
        onSubmit={async (e) => {
          e.preventDefault();
          await submitMsg();
        }}
      >
        <div>{userName}</div>
        <input
          type="text"
          value={inputMsg}
          onChange={(e) => setInputMsg(e.target.value)}
        />
        <input
          type="image"
          onClick={() => submitMsg}
          src="../img/airplane.png"
          alt="Send Button"
        />
      </form>
    </>
  );
};

export default ChatPage;

名前アイコン表示コンポーネント

OffscreenCanvasから画像化する場合、async関数となるため表示が面倒になります。このような場合、React.Suspenseを使うとすっきりとしたコードにすることができます。

詳細は名前表示アイコン コンポーネント(React) 参照

  • Canvasの場合

    非同期処理が不要なためのsrcに直接埋め込むことができます

const createImage = () => {
  // 描画部分は省略
  var imageUrl = canvas.toDataURL( "image/jpeg", 0.75 ) ;
  return imageUrl;
}

const ImageTag: VFC = () => {
   return <img alt="icon" src={createImage()} />;
};
  • OffscreenCanvasの場合

    非同期処理となるためuseEffect()を利用した副作用で処理する必要がありますが、Suspenseを利用すると、すっきりとしたコードにすることができます。

const createImage = () => {
  // 描画部分は省略
  const blob = await canvas.convertToBlob();
  const imageUrl = URL.createObjectURL(blob);
  return imageUrl;
}

/**
 * Suspenseを使い、Canvasで生成した画像を表示するコンポーネント
 * canvas.convertToBlob()が非同期関数のため、描画した画像をそのまま埋め込むことができない
 */

type IconMakerFC = FC<{ userName: string; option?: IconOption }>;

const iconImage: { [key: string]: string } = {};
const IconMaker: IconMakerFC = ({ userName, option }) => {
  const iconMakerWrapper = () => {
    if (!iconImage[userName]) {
      // eslint-disable-next-line no-return-assign
      throw iconMaker(userName, option).then((r) => (iconImage[userName] = r));
    } else {
      return iconImage[userName];
    }
  };

  return <img alt="icon" src={iconMakerWrapper()} />;
};

const NameIcon: IconMakerFC = ({ userName, option }) => (
  <Suspense fallback={<p>Loading...</p>}>
    <IconMaker userName={userName} option={option} />
  </Suspense>
);

export default NameIcon;

参考ページ

Firebase Javascript SDK v8→v9における進め方と注意事項

[Firebase] プロジェクトを新規に作成する

[Firebase] Firestoreで読み書きする (Web編)

[Firebase] Firestoreでリアルタイムなチャットを作る (Web編) その1

CSSだけでLINE風の「吹き出し」を作る方法

11
12
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
11
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?