1. 概要
前回はSocketIOを使い、シンプルなチャットアプリを作る内容でした。
今回はCloud Firestoreのリアルタイムアップデート(onSnapshot)を使い、シンプルなチャットアプリを作る内容となります。
対象としては開発を1年程やってて自分で最初から開発してみたい方になります。そのため細かい用語などの説明はしません。
2. nodeのインストール
こちらを参考
3. プロジェクトを作成
こちらを参考
{
rooms: {
{roomID}:{
messages: {
{messageID}:{
message: string,
createdAt: timestamp,
user: { // 【map】
userId: string,
age: number
}
}
}
}
}
}
4. 必要なライブラリをインストール
下記を実行
npm i firebase uuid
5. ソースコード
5-1-1. .env.local
NEXT_PUBLIC_FIREBASE_API_KEY=[★]
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=[★]
NEXT_PUBLIC_FIREBASE_PROJECT_ID=[★]
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=[★]
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=[★]
NEXT_PUBLIC_FIREBASE_APP_ID=[★]
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=[★]
5-1-2. src/app/lib/utils/utils.ts
import { FieldValue, Timestamp } from "firebase/firestore";
export const convertFieldValueToStr = (date: FieldValue): string => {
if (date === null || date === undefined) return "";
const dateStr: string = new Date((date as Timestamp).toDate()).toUTCString();
return dateStr;
};
export const stringToObj = <T>(str: string): T => {
return JSON.parse(str);
};
5-1-3. src/app/lib/model/model.ts
import {
DocumentChangeType,
DocumentData,
FieldValue,
} from "firebase/firestore";
export const COLLECTION_NAME_ROOMS = "rooms";
export const COLLECTION_NAME_ROOMID = "myRoom";
export const SUB_COLLECTION_NAME_MESSAGES = "messages";
export type FsData = {
changeType?: DocumentChangeType;
docId: string;
data: DocumentData;
};
export type Room = {
roomId: string;
};
export type User = {
userId: string;
age: number;
};
export type Chat = {
message: string;
user: User;
createdAt: FieldValue;
};
5-1-4. src/app/lib/firebase/firestore.ts
import { initializeApp, FirebaseApp } from "firebase/app";
import { Firestore, getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
};
const app: FirebaseApp = initializeApp(firebaseConfig);
const firestore: Firestore = getFirestore(app);
export { firestore };
5-1-5. src/app/lib/firebase/firestore-data-converter.ts
import {
DocumentData,
FirestoreDataConverter,
QueryDocumentSnapshot,
SnapshotOptions,
WithFieldValue,
} from "firebase/firestore";
const CreateFirestoreDataConverter = <
T extends DocumentData
>(): FirestoreDataConverter<T> => {
return {
toFirestore(data: WithFieldValue<T>): DocumentData {
return data;
},
fromFirestore(
snapshot: QueryDocumentSnapshot<T>,
options: SnapshotOptions
): T {
return snapshot.data(options);
},
};
};
export default CreateFirestoreDataConverter;
5-1-6. src/app/lib/firebase/firestore-helper.ts
import {
Firestore,
collection,
doc,
DocumentData,
DocumentReference,
QuerySnapshot,
setDoc,
onSnapshot,
query,
orderBy,
Query,
QueryDocumentSnapshot,
OrderByDirection,
FieldPath,
DocumentChangeType,
} from "firebase/firestore";
import { firestore } from "@/app/lib/firebase/firebase";
import CreateFirestoreDataConverter from "@/app/lib/firebase/firestore-data-converter";
import { FsData } from "@/app/lib/model/model";
const getDocRef = <T extends DocumentData>(
db: Firestore,
path: string,
pathSegments: string[]
): DocumentReference<T, DocumentData> => {
return doc(db, path, ...pathSegments).withConverter(
CreateFirestoreDataConverter<T>()
);
};
const addDocument = async <T extends DocumentData>(
path: string,
pathSegments: string[],
params: T
): Promise<void> => {
const docRef: DocumentReference<T, DocumentData> = getDocRef<T>(
firestore,
path,
pathSegments
);
await setDoc(docRef, { ...params });
};
const applyQueryFilters = (
q: Query<DocumentData, DocumentData>,
{ column, sort }: { column?: string | FieldPath; sort?: string }
): Query<DocumentData, DocumentData> => {
if (sort) {
q = query(q, orderBy(column as FieldPath, sort as OrderByDirection));
}
return q;
};
const getSnapshot = (
path: string,
pathSegments: string[],
cb,
filters = {}
) => {
if (typeof cb !== "function") {
console.log("Error: The callback parameter is not a function.");
return;
}
let q: Query<DocumentData, DocumentData> = query(
collection(firestore, path, ...pathSegments)
);
q = applyQueryFilters(q, filters);
const unsubscribe = onSnapshot(
q,
(querySnapshot: QuerySnapshot<DocumentData, DocumentData>) => {
const results: FsData[] = querySnapshot.docs.map(
(doc: QueryDocumentSnapshot<DocumentData, DocumentData>): FsData => {
const fsData: FsData = {
docId: doc.id,
data: doc.data(),
};
return fsData;
}
);
cb(results);
}
);
return unsubscribe;
};
const getSnapshotChange = (
path: string,
pathSegments: string[],
cb,
filters = {}
) => {
if (typeof cb !== "function") {
console.log("Error: The callback parameter is not a function.");
return;
}
let q: Query<DocumentData, DocumentData> = query(
collection(firestore, path, ...pathSegments)
);
q = applyQueryFilters(q, filters);
const unsubscribe = onSnapshot(
q,
(querySnapshot: QuerySnapshot<DocumentData, DocumentData>) => {
const results: FsData[] = [];
for (const doc of querySnapshot.docChanges()) {
if (doc.type === ("added" as DocumentChangeType)) {
const fsData: FsData = {
changeType: doc.type,
docId: doc.doc.id,
data: doc.doc.data(),
};
results.push(fsData);
}
}
cb(results);
}
);
return unsubscribe;
};
export { addDocument, getSnapshot, getSnapshotChange };
5-1-7. src/app/components/view-data.tsx
"use client";
import { useEffect, useState } from "react";
import { getSnapshotChange } from "@/app/lib/firebase/firestore-helper";
import {
Chat,
COLLECTION_NAME_ROOMS,
COLLECTION_NAME_ROOMID,
SUB_COLLECTION_NAME_MESSAGES,
User,
FsData,
} from "@/app/lib/model/model";
import { DocumentChangeType, Unsubscribe } from "firebase/firestore";
import { stringToObj } from "@/app/lib/utils/utils";
const ViewData = () => {
const initialFilters = { column: "createdAt", sort: "asc" };
const initialChat: Chat[] = [];
const [chats, setChats] = useState<Chat[]>(initialChat);
const [filters, setFilters] = useState(initialFilters);
useEffect(() => {
const unsubscribe: Unsubscribe | undefined = getSnapshotChange(
COLLECTION_NAME_ROOMS,
[COLLECTION_NAME_ROOMID, SUB_COLLECTION_NAME_MESSAGES],
(fsData: FsData[]) => {
const newChat: Chat[] = [];
for (const fs of fsData) {
if (fs.changeType === ("added" as DocumentChangeType)) {
const user: User = stringToObj<User>(JSON.stringify(fs.data.user));
const chat: Chat = {
message: fs.data.message,
user,
createdAt: fs.data.createdAt,
};
newChat.push(chat);
}
}
if (newChat && newChat.length > 0) {
setChats((chats) => [...chats, ...newChat]);
}
},
filters
);
return () => {
unsubscribe!();
};
}, [filters]);
return (
<div>
<ul>
{chats?.map((msg: Chat, idx: number) => (
<li key={idx}>
[{msg.user.userId}({msg.user.age})] {msg.message}
</li>
))}
</ul>
</div>
);
};
export default ViewData;
5-1-8. src/app/components/chat-form.tsx
"use client";
import { FormEvent, useState } from "react";
import { serverTimestamp } from "firebase/firestore";
import { v4 as uuidv4 } from "uuid";
import {
COLLECTION_NAME_ROOMID,
COLLECTION_NAME_ROOMS,
Chat,
SUB_COLLECTION_NAME_MESSAGES,
User,
} from "@/app/lib/model/model";
import { addDocument } from "@/app/lib/firebase/firestore-helper";
const ChatForm = () => {
const [msg, setMsg] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useState<User | null>(null);
const chat = async (targetUser: User | null, newMsg: string) => {
const message: Chat = {
message: newMsg,
user: targetUser!,
createdAt: serverTimestamp(),
};
const messageId: string = uuidv4();
await addDocument<Chat>(
COLLECTION_NAME_ROOMS,
[COLLECTION_NAME_ROOMID, SUB_COLLECTION_NAME_MESSAGES, messageId],
message
);
setIsLoading(false);
setMsg("");
};
const sendMsg = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (user === null) {
alert(`Please join`);
return;
}
setIsLoading(true);
await chat(user, msg);
};
const join = async () => {
const userId: string = user === null ? uuidv4() : user.userId;
const age: number =
user === null ? Math.floor(Math.random() * 10) : user.age;
setUser({ userId, age });
await chat({ userId, age }, "joined.");
};
const leave = async () => {
if (user) await chat(user, "leaved.");
setUser(null);
};
return (
<>
<button onClick={join}>Join</button>
<button onClick={leave}>Leave</button>
<p>Status: {user === null ? "Leaved" : "Joined"}</p>
<form onSubmit={(e) => sendMsg(e)}>
<input value={msg} onChange={(e) => setMsg(e.target.value)} />
<button type="submit" disabled={isLoading}>
Chat
</button>
</form>
</>
);
};
export default ChatForm;
5-1-9. src/app/page.tsx
import styles from "./page.module.css";
import ViewData from "@/app/components/view-data";
import ChatForm from "@/app/components/chat-form";
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<ViewData />
<ChatForm />
</main>
</div>
);
}
6. サーバーを起動
npm run dev
7. ブラウザで確認
- http://localhost:3000
- ブラウザを3つ立ち上げて確認
8. ディレクトリの構造
.
├── README.md
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│ ├── file.svg
│ ├── globe.svg
│ ├── next.svg
│ ├── vercel.svg
│ └── window.svg
├── src
│ └── app
│ ├── components
│ │ ├── chat-form.tsx
│ │ └── view-data.tsx
│ ├── favicon.ico
│ ├── fonts
│ │ ├── GeistMonoVF.woff
│ │ └── GeistVF.woff
│ ├── globals.css
│ ├── layout.tsx
│ ├── lib
│ │ ├── firebase
│ │ │ ├── firebase.ts
│ │ │ ├── firestore-data-converter.ts
│ │ │ └── firestore-helper.ts
│ │ ├── model
│ │ │ └── model.ts
│ │ └── utils
│ │ └── utils.ts
│ ├── page.module.css
│ └── page.tsx
├── tailwind.config.ts
└── tsconfig.json
9 directories, 27 files
9. 備考
今回はCloud Firestoreのリアルタイムアップデート(onSnapshot)を使い、シンプルなチャットアプリを作る内容についてでした。
10. 参考
投稿者プロフィール
-
開発好きなシステムエンジニアです。
卓球にハマってます。