diff --git a/README.md b/README.md index d68adb6..b61ab74 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,12 @@ For More information, please refer to the Top Page. [TODO REAL](https://todo-real-c28fa.web.app/) ## Getting Started -First run this command to install required packages: - -```bash -npm install -``` - -Then, run the development server: +Run the development server: ```bash npm run dev ``` +This includes `npm i` and `next dev`, so you don't have to care about refreshing packages. ## Learn More To learn more about Next.js, take a look at the following resources: @@ -46,7 +41,7 @@ Then, you can start the emulator by running the following command if you have ac cd .\functions\ npm run emu ``` -This command includes `firebase emulators:start` and `npx tsc --watch` which watches the files and restarts the server when the files are changed. +This command includes `npm i`, `firebase emulators:start` and `npx tsc --watch` which watches the files and restarts the server when the files are changed. if you don't want ts-node to watch the files, just use ``` diff --git a/functions/package.json b/functions/package.json index e0ea552..d9e1ee2 100644 --- a/functions/package.json +++ b/functions/package.json @@ -9,7 +9,7 @@ "start": "npm run shell", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", - "emu": "concurrently -k -n \"TypeScript,Emulator\" -c \"cyan.bold,green.bold\" \"npx tsc --watch\" \"firebase emulators:start\"" + "emu": "npm i && concurrently -k -n \"TypeScript,Emulator\" -c \"cyan.bold,green.bold\" \"npx tsc --watch\" \"firebase emulators:start\"" }, "engines": { "node": "18" diff --git a/functions/src/index.ts b/functions/src/index.ts index cfb43c0..2939a36 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -52,12 +52,7 @@ const verifyAppCheckToken = async ( // Postmanを使うためにCloud FunctionsのApp Checkは開発環境では使用しない if (process.env.NODE_ENV === "production") { app.use((req, res, next) => { - // /sendNotificationと/receiveTestは別の認証を使用するのでApp Checkを使用しない - if (req.path !== "/sendNotification" && req.path !== "/receiveTest") { - verifyAppCheckToken(req, res, next); - } else { - next(); - } + verifyAppCheckToken(req, res, next); }); } diff --git a/functions/src/tasks.ts b/functions/src/tasks.ts index 76ae49b..e0bfd9f 100644 --- a/functions/src/tasks.ts +++ b/functions/src/tasks.ts @@ -42,9 +42,10 @@ export const createTasksOnGoalCreate = onDocumentCreated( const postData = { message: { token: fcmToken, // 通知を受信する端末のトークン - notification: { + data: { title: `${marginTime}分以内に目標を完了し写真をアップロードしましょう!`, body: goalData.text, + icon: "https://todo-real-c28fa.web.app/appIcon.svg", }, }, }; diff --git a/package.json b/package.json index 92d05e2..6b6aa06 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "npm i && next dev", "build": "next build", "start": "next start", "lint": "next lint" diff --git a/public/messaging-sw.js b/public/messaging-sw.js index 0b3a8d5..431133e 100644 --- a/public/messaging-sw.js +++ b/public/messaging-sw.js @@ -40,5 +40,19 @@ const messaging = firebase.messaging(); // バックグラウンド時の通知を処理(通知自体は何もしなくても受信される) messaging.onBackgroundMessage((payload) => { - console.log("Received background message:", payload); + try { + console.log("Received background message:", payload); + + const { title, body, icon } = payload.data; + const notificationOptions = { + body, + icon, + }; + + self.registration.showNotification(title, notificationOptions); + } catch (error) { + console.error("Error while receiving background message:", error); + } }); + +// フォアグラウンド通知は`src\utils\CloudMessaging\getNotification.ts`で処理 diff --git a/src/Components/Account/LoggedInView.tsx b/src/Components/Account/LoggedInView.tsx index 4f24fd6..066d817 100644 --- a/src/Components/Account/LoggedInView.tsx +++ b/src/Components/Account/LoggedInView.tsx @@ -3,9 +3,9 @@ import NotificationButton from "@/Components/NotificationButton/NotificationButt import { handleSignOut } from "@/utils/Auth/signOut"; import { getSuccessRate } from "@/utils/successRate"; import { useUser } from "@/utils/UserContext"; +import Typography from "@mui/joy/Typography"; import Button from "@mui/material/Button"; import { styled } from "@mui/material/styles"; -import Typography from "@mui/material/Typography"; import { useEffect, useState } from "react"; export const RoundedButton = styled(Button)(({ theme }) => ({ @@ -43,47 +43,53 @@ export default function LoggedInView() { return ( <> {user.loginType === "Guest" ? ( - + ゲストとしてログイン中 ) : ( <> - + ようこそ、{user.name}さん! - - 連続達成日数: {userStats.streak}日目 - - - 目標達成率: {userStats.successRate}% - - - 達成回数: {userStats.completed}回 - +
+ + 連続達成日数: {userStats.streak}日目 + + + 目標達成率: {userStats.successRate}% + + + 達成回数: {userStats.completed}回 + +
)} {!user.isMailVerified && ( - + メールに届いた認証リンクを確認してください。 -
認証が完了するまで閲覧以外の機能は制限されます。
)} {user.loginType === "Guest" && ( - + ゲストユーザーは閲覧以外の機能は制限されます。 全ての機能を利用するにはログインが必要です。 )} - {/* ゲストかメール未認証の場合は名前を変更できないようにする */} + {/* ゲストかメール未認証の場合は名前の変更や通知の使用をできないようにする */} {user.loginType !== "Guest" && user.isMailVerified && ( -
- - -
+ <> +
+ + +
+ + 通知を有効にすると、目標が未達成の場合に期限の5分前に通知を送信します。 + + )} diff --git a/src/Components/DashBoard/DashBoard.tsx b/src/Components/DashBoard/DashBoard.tsx index 40d3265..721546e 100644 --- a/src/Components/DashBoard/DashBoard.tsx +++ b/src/Components/DashBoard/DashBoard.tsx @@ -5,9 +5,11 @@ import { fetchResult, handleFetchResultError, } from "@/utils/API/Result/fetchResult"; +import CircularProgress from "@mui/joy/CircularProgress"; import Typography from "@mui/joy/Typography"; import LinearProgress from "@mui/material/LinearProgress"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import CenterIn from "../Animation/CenterIn"; import Progress from "../Progress/Progress"; import styles from "./DashBoard.module.scss"; @@ -26,7 +28,7 @@ export default function DashBoard({ failed?: boolean; pending?: boolean; orderBy?: "asc" | "desc"; -} = {}) { +}) { const [successResults, setSuccessResults] = useState( [] ); @@ -38,15 +40,101 @@ export default function DashBoard({ ); const [noResult, setNoResult] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [reachedBottom, setReachedBottom] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const bottomRef = useRef(null); + const isAlreadyFetching = useRef(false); + const offset = useRef(0); + const noMore = useRef(false); + + const limit = 10; // limitずつ表示 + + useEffect(() => { + setTimeout(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isLoading && !noMore.current) { + setReachedBottom(true); + fetchData(); + } + }, + { threshold: 1 } + ); + + if (bottomRef.current) { + observer.observe(bottomRef.current); + } + + return () => { + if (bottomRef.current) { + observer.disconnect(); + } + }; + }, 1000); + }, [isLoading, noMore.current, bottomRef.current, bottomRef]); + + useEffect(() => { + offset.current = + successResults.length + failedResults.length + pendingResults.length; + }, [successResults, failedResults, pendingResults]); const fetchData = () => { - setIsLoading(true); - fetchResult({ userId, success, failed, pending }) + if (noMore.current) { + return; + } + if (isAlreadyFetching.current) { + return; + } else { + isAlreadyFetching.current = true; + } + if (reachedBottom && !isLoadingMore) { + setIsLoadingMore(true); + } + fetchResult({ + userId, + success, + failed, + pending, + offset: offset.current, + limit, + }) .then((data) => { - setSuccessResults(data.successResults); - setFailedResults(data.failedResults); - setPendingResults(data.pendingResults); + // 既に追加されている場合は追加しない + setSuccessResults((prev) => { + const newResults = data.successResults.filter( + (result: GoalWithIdAndUserData) => + !prev.some((item) => item.goalId === result.goalId) + ); + return [...prev, ...newResults]; + }); + setFailedResults((prev) => { + const newResults = data.failedResults.filter( + (result: GoalWithIdAndUserData) => + !prev.some((item) => item.goalId === result.goalId) + ); + return [...prev, ...newResults]; + }); + setPendingResults((prev) => { + const newResults = data.pendingResults.filter( + (result: GoalWithIdAndUserData) => + !prev.some((item) => item.goalId === result.goalId) + ); + return [...prev, ...newResults]; + }); + + if ( + data.successResults.length + + data.failedResults.length + + data.pendingResults.length < + limit + ) { + noMore.current = true; + } + setIsLoading(false); + setReachedBottom(false); + isAlreadyFetching.current = false; + setIsLoadingMore(false); }) .catch((error) => { console.error("Error fetching results:", error); @@ -61,7 +149,9 @@ export default function DashBoard({ useEffect(() => { rerenderDashBoard = fetchData; - fetchData(); + if (userId) { + fetchData(); + } }, [userId, success, failed, pending]); useEffect(() => { @@ -83,9 +173,14 @@ export default function DashBoard({ }} /> ) : noResult ? ( - - +ボタンから目標を作成しましょう! - + + + +ボタンから目標を作成しましょう! + + ) : (
+
+ + {!noMore.current && + (reachedBottom ? ( +
+ +
+ ) : ( + + スクロールしてもっと表示 + + ))}
)} diff --git a/src/Components/GoalModal/CopyGoalAfterPostButton.tsx b/src/Components/GoalModal/CopyGoalAfterPostButton.tsx index 4551ede..1d5365d 100644 --- a/src/Components/GoalModal/CopyGoalAfterPostButton.tsx +++ b/src/Components/GoalModal/CopyGoalAfterPostButton.tsx @@ -22,8 +22,8 @@ export default function CopyGoalAfterPostButton({ onClick={() => { setOpen(true); showSnackBar({ - message: "1日後の同じ時間で同じ目標を作成できます", - type: "success", + message: "明日の同じ時間で同じ目標を作成できます", + type: "normal", }); }} > diff --git a/src/Components/GoalModal/CopyGoalButton.tsx b/src/Components/GoalModal/CopyGoalButton.tsx index dd70c6f..ce5a35d 100644 --- a/src/Components/GoalModal/CopyGoalButton.tsx +++ b/src/Components/GoalModal/CopyGoalButton.tsx @@ -18,8 +18,8 @@ export default function CopyModalButton({ onClick={() => { setOpen(true); showSnackBar({ - message: "1日後の同じ時間で同じ目標を作成できます", - type: "success", + message: "明日の同じ時間で同じ目標を作成できます", + type: "normal", }); }} sx={{ cursor: "pointer", fontSize: "23px" }} diff --git a/src/Components/GoalModal/CreateGoalModal.tsx b/src/Components/GoalModal/CreateGoalModal.tsx index 84a9b3f..d56761d 100644 --- a/src/Components/GoalModal/CreateGoalModal.tsx +++ b/src/Components/GoalModal/CreateGoalModal.tsx @@ -33,6 +33,17 @@ export default function CreateGoalModal({ const { user } = useUser(); + const resetDeadline = () => { + // 次の日の23時に設定 + const nextDay = new Date(); + nextDay.setDate(nextDay.getDate() + 1); + nextDay.setHours(23, 0, 0, 0); + const localNextDay = new Date( + nextDay.getTime() - nextDay.getTimezoneOffset() * 60000 + ); + setDeadline(localNextDay.toISOString().slice(0, 16)); + }; + useEffect(() => { if (defaultText) { setText(defaultText); @@ -42,17 +53,10 @@ export default function CreateGoalModal({ const localDate = new Date( convertedDate.getTime() - convertedDate.getTimezoneOffset() * 60000 ); - localDate.setDate(localDate.getDate() + 1); // 1日後にする + localDate.setDate(localDate.getDate() + 1); // 明日にする setDeadline(localDate.toISOString().slice(0, 16)); } else { - // 初期値の指定が無い場合は次の日の23時に設定 - const nextDay = new Date(); - nextDay.setDate(nextDay.getDate() + 1); - nextDay.setHours(23, 0, 0, 0); - const localNextDay = new Date( - nextDay.getTime() - nextDay.getTimezoneOffset() * 60000 - ); - setDeadline(localNextDay.toISOString().slice(0, 16)); + resetDeadline(); } }, [defaultText, defaultDeadline]); @@ -75,7 +79,7 @@ export default function CreateGoalModal({ triggerDashBoardRerender(); setText(""); - setDeadline(""); + resetDeadline(); setOpen(false); } catch (error: unknown) { console.error("Error creating goal:", error); diff --git a/src/Components/Loader/Loader.tsx b/src/Components/Loader/Loader.tsx index a503579..38fc66f 100644 --- a/src/Components/Loader/Loader.tsx +++ b/src/Components/Loader/Loader.tsx @@ -25,7 +25,7 @@ export const Loader = ({ children }: LoaderProps) => { setTimeout(() => { setShowErrorButton(true); - }, 10000); + }, 15000); return ( <> diff --git a/src/Components/Progress/Progress.tsx b/src/Components/Progress/Progress.tsx index 64e0cf9..07525de 100644 --- a/src/Components/Progress/Progress.tsx +++ b/src/Components/Progress/Progress.tsx @@ -189,12 +189,15 @@ const SuccessStep = ({ zIndex: 0, }} > -
+
{imageURL && (
- {goalText} + + {goalText} + ); @@ -404,7 +420,7 @@ const StepperBlock = ({ : 0; return ( - + ({ display: "flex", @@ -38,8 +38,8 @@ export default function Account() { sx={{ padding: 2 }} > - - TODO-REAL + + TODO REAL {user ? : } diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index 0ddbbad..2663059 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -1,8 +1,15 @@ "use client"; -import DashBoard from "@/Components/DashBoard/DashBoard"; +import DashBoard, { + triggerDashBoardRerender, +} from "@/Components/DashBoard/DashBoard"; import GoalModalButton from "@/Components/GoalModal/GoalModalButton"; +import { useEffect } from "react"; export default function Discover() { + useEffect(() => { + triggerDashBoardRerender(); + }, []); + return ( <> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2f82703..12f374b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import type { Metadata } from "next"; import "./firebase"; const description = "TODO REALはTODOリストとBeRealを組み合わせたアプリです。"; +export const rootURL = "https://todo-real-c28fa.web.app/"; export const metadata: Metadata = { title: "Todo Real", @@ -24,8 +25,6 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const rootURL = "https://todo-real-c28fa.web.app/"; - return ( @@ -38,7 +37,7 @@ export default function RootLayout({ {/* Twitter Card */} - + diff --git a/src/app/mycontent/page.tsx b/src/app/mycontent/page.tsx index 8cac74d..fd86ead 100644 --- a/src/app/mycontent/page.tsx +++ b/src/app/mycontent/page.tsx @@ -46,13 +46,14 @@ export default function MyContent() { {value === "pending" ? ( ) : ( - + )} diff --git a/src/utils/API/Result/fetchResult.ts b/src/utils/API/Result/fetchResult.ts index 67483a9..bce571d 100644 --- a/src/utils/API/Result/fetchResult.ts +++ b/src/utils/API/Result/fetchResult.ts @@ -9,11 +9,15 @@ import { appCheckToken, functionsEndpoint } from "@/app/firebase"; */ export const fetchResult = async ({ userId = "", + offset, + limit, success = true, failed = true, pending = true, }: { userId?: string; + offset?: number; + limit?: number; success?: boolean; failed?: boolean; pending?: boolean; @@ -24,6 +28,12 @@ export const fetchResult = async ({ } else if (success && failed && !pending) { queryParams.append("onlyFinished", "true"); } + if (offset) { + queryParams.append("offset", offset.toString()); + } + if (limit) { + queryParams.append("limit", limit.toString()); + } const response = await fetch( `${functionsEndpoint}/result/${userId}?${queryParams.toString()}`, diff --git a/src/utils/CloudMessaging/getNotification.ts b/src/utils/CloudMessaging/getNotification.ts index 7f9254d..26fc671 100644 --- a/src/utils/CloudMessaging/getNotification.ts +++ b/src/utils/CloudMessaging/getNotification.ts @@ -5,18 +5,23 @@ import { onMessage } from "firebase/messaging"; // フォアグラウンド時の通知を受信 if (typeof window !== "undefined" && messaging) { { - onMessage(messaging, (payload) => { - console.log("Received foreground message:", payload); - if (payload.notification) { - const { title, body } = payload.notification; - const notification = new Notification(title ?? "Default Title", { - body, - }); - notification.onclick = () => { - window.focus(); - }; - } - }); + try { + onMessage(messaging, (payload) => { + console.log("Received foreground message:", payload); + if (payload.data) { + const { title, body, icon } = payload.data; + const notification = new Notification(title ?? "Default Title", { + body, + icon, + }); + notification.onclick = () => { + window.focus(); + }; + } + }); + } catch (error) { + console.error("Error while receiving foreground message:", error); + } } }