Skip to content

Commit

Permalink
[Update] Firestoreとのデーターの同期フローを改善
Browse files Browse the repository at this point in the history
  • Loading branch information
FUGAMARU committed Oct 20, 2023
1 parent 3c0cfad commit 126bcae
Show file tree
Hide file tree
Showing 10 changed files with 129 additions and 85 deletions.
24 changes: 9 additions & 15 deletions app/callback/spotify/SpotifyApiCallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,25 @@
"use client"

import { useSearchParams, useRouter } from "next/navigation"
import { memo, useCallback, useEffect, useRef } from "react"
import { memo, useEffect, useRef } from "react"
import { useRecoilValue } from "recoil"
import { userDataAtom } from "@/atoms/userDataAtom"
import useErrorModal from "@/hooks/useErrorModal"
import useSpotifyToken from "@/hooks/useSpotifyToken"
import { isDefined } from "@/utils/isDefined"

const SpotifyApiCallbackPage = () => {
const router = useRouter()
const searchParams = useSearchParams()
const { getAccessToken } = useSpotifyToken({ initialize: false })
const hasApiCalledRef = useRef(false)
const { showError } = useErrorModal()

const handleGetAccessToken = useCallback(
async (code: string) => {
try {
await getAccessToken(code)
} catch (e) {
throw e
}
},
[getAccessToken]
)
const userData = useRecoilValue(userDataAtom)

useEffect(() => {
;(async () => {
if (hasApiCalledRef.current) return
if (hasApiCalledRef.current || !isDefined(userData)) return

hasApiCalledRef.current = true

try {
Expand All @@ -34,14 +28,14 @@ const SpotifyApiCallbackPage = () => {
const code = searchParams.get("code")
if (code === null) throw new Error("パラメーターが指定されていません")

await handleGetAccessToken(code)
await getAccessToken(code)
} catch (e) {
showError(e)
} finally {
router.push("/connect?provider=spotify")
}
})()
}, [searchParams, router, handleGetAccessToken, showError])
}, [searchParams, router, getAccessToken, showError, userData])

return null
}
Expand Down
7 changes: 7 additions & 0 deletions atoms/userDataAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { atom } from "recoil"
import { UserData } from "@/types/UserData"

export const userDataAtom = atom<UserData | undefined>({
key: "userDataAtom",
default: undefined
})
4 changes: 3 additions & 1 deletion hooks/useAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { FIRESTORE_USERDATA_COLLECTION_NAME } from "@/constants/Firestore"
import { db, auth } from "@/utils/firebase"

const useAuth = () => {
const { createNewHashedPassword, createNewUserDocument } = useStorage()
const { createNewHashedPassword, createNewUserDocument } = useStorage({
initialize: false
})

const checkUserExists = useCallback(async (email: string) => {
// fetchSignInMethodsForEmailを使っても空配列しか返ってこないのでFirestoreから該当UUIDのコレクションがあるかどうかで判定する
Expand Down
12 changes: 4 additions & 8 deletions hooks/useInitializer.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
import { useEffect } from "react"
import { useAuthState } from "react-firebase-hooks/auth"
import { useSetRecoilState } from "recoil"
import useSetSpotifySettingState from "./useSetSpotifySettingState"
import useSpotifyApi from "./useSpotifyApi"
import useSpotifyToken from "./useSpotifyToken"
import useStorage from "./useStorage"
import useSetWebDAVSettingState from "./useWebDAVSettingState"
import { faviconIndexAtom } from "@/atoms/faviconIndexAtom"
import { LOCAL_STORAGE_KEYS } from "@/constants/LocalStorageKeys"
import { auth } from "@/utils/firebase"
import { generateRandomNumber } from "@/utils/randomNumberGenerator"

const useInitializer = () => {
/** MixJuiceを開いた時に実行したい、かつ表示に直接関係ない処理はこのフック内で行う TODO: ログインチェックなど */

const [user, loading] = useAuthState(auth)

useSpotifyApi({ initialize: true })
useSpotifyToken({ initialize: true })
useStorage({ initialize: true })

useSetSpotifySettingState({ isLoadingUser: loading })
useSetWebDAVSettingState({ isLoadingUser: loading })
useSetSpotifySettingState()
useSetWebDAVSettingState()

const setFaviconIndex = useSetRecoilState(faviconIndexAtom)
useEffect(() => {
Expand All @@ -39,8 +37,6 @@ const useInitializer = () => {
)
}
}, [])

return { user, isLoadingUser: loading } as const
}

export default useInitializer
13 changes: 6 additions & 7 deletions hooks/useSetSpotifySettingState.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { useEffect } from "react"
import { useAuthState } from "react-firebase-hooks/auth"
import { useRecoilValue, useSetRecoilState } from "recoil"
import useStorage from "./useStorage"
import { selectedSpotifyPlaylistsAtom } from "@/atoms/selectedSpotifyPlaylistsAtom"
import { spotifySettingStateAtom } from "@/atoms/spotifySettingStateAtom"
import { FIRESTORE_DOCUMENT_KEYS } from "@/constants/Firestore"
import { LOCAL_STORAGE_KEYS } from "@/constants/LocalStorageKeys"
import { LocalStorageSpotifySelectedPlaylists } from "@/types/LocalStorageSpotifySelectedPlaylists"
import { auth } from "@/utils/firebase"
import { isDefined } from "@/utils/isDefined"

type Args = {
isLoadingUser: boolean
}

const useSetSpotifySettingState = ({ isLoadingUser }: Args) => {
const useSetSpotifySettingState = () => {
const selectedPlaylists = useRecoilValue(selectedSpotifyPlaylistsAtom)
const { getUserData } = useStorage()
const { getUserData } = useStorage({ initialize: false })
const [, isLoadingUser] = useAuthState(auth)

/**
* done: Spotifyのログイン・プレイリストの選択が完了している
Expand All @@ -26,7 +25,7 @@ const useSetSpotifySettingState = ({ isLoadingUser }: Args) => {
;(async () => {
if (isLoadingUser) return

const refreshToken = await getUserData(
const refreshToken = getUserData(
FIRESTORE_DOCUMENT_KEYS.SPOTIFY_REFRESH_TOKEN
)

Expand Down
8 changes: 5 additions & 3 deletions hooks/useSpotifyToken.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ const useSpotifyToken = ({ initialize }: Props) => {
/** 参考: https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow */

const [accessToken, setAccessToken] = useRecoilState(spotifyAccessTokenAtom) // useStateを使うとSpotifyの設定画面を離れた場合にアクセストークンが消えるのでRecoilを使う
const { getUserData, updateUserData, deleteUserData } = useStorage()
const { getUserData, updateUserData, deleteUserData } = useStorage({
initialize: false
})

/** 現在のアドレスからコールバック用のリダイレクトURIを求める */
const [redirectUri, setRedirectUri] = useState("")
Expand Down Expand Up @@ -145,7 +147,7 @@ const useSpotifyToken = ({ initialize }: Props) => {
console.log("🟦DEBUG: Spotify APIのアクセストークンを更新します")

const clientId = localStorage.getItem(LOCAL_STORAGE_KEYS.SPOTIFY_CLIENT_ID)
const refreshToken = await getUserData(
const refreshToken = getUserData(
FIRESTORE_DOCUMENT_KEYS.SPOTIFY_REFRESH_TOKEN
)

Expand Down Expand Up @@ -198,7 +200,7 @@ const useSpotifyToken = ({ initialize }: Props) => {
"Spotify APIのアクセストークンの更新に失敗しました。Spotifyに再ログインしてください。"
)
}
}, [setAccessToken, deleteAuthConfig, getUserData, updateUserData])
}, [setAccessToken, deleteAuthConfig, updateUserData, getUserData])

/* useMemoにすると、Date.nowがaccessTokenの取得が完了した時点で固定されるのでuseCallbackにする必要がある */
const hasValidAccessTokenState = useCallback(() => {
Expand Down
134 changes: 89 additions & 45 deletions hooks/useStorage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import CryptoJS from "crypto-js"
import { setDoc, doc, getDoc, deleteField, updateDoc } from "firebase/firestore"
import { useCallback, useMemo } from "react"
import { useCallback, useEffect, useMemo } from "react"

import { useAuthState } from "react-firebase-hooks/auth"
import { useRecoilState } from "recoil"
import useErrorModal from "./useErrorModal"
import { userDataAtom } from "@/atoms/userDataAtom"
import {
FIRESTORE_USERDATA_COLLECTION_NAME,
FIRESTORE_DOCUMENT_KEYS
Expand All @@ -13,9 +15,12 @@ import { UserData, UserDataKey } from "@/types/UserData"
import { auth, db } from "@/utils/firebase"
import { isDefined } from "@/utils/isDefined"

const useStorage = () => {
type Args = { initialize: boolean }

const useStorage = ({ initialize }: Args) => {
const { showError } = useErrorModal()
const [user] = useAuthState(auth)
const [userData, setUserData] = useRecoilState(userDataAtom)
const [user, isLoadingUser] = useAuthState(auth)

const decryptionVerifyString = useMemo(
() => process.env.NEXT_PUBLIC_DECRYPTION_VERIFY_STRING,
Expand Down Expand Up @@ -68,51 +73,15 @@ const useStorage = () => {
[encryptText, decryptionVerifyString]
)

/** getUserDataやupdateUserなどの関数は、その関数の使用箇所で関数自体をtry/catchでラップするのが正しいのだろうが、コードが汚くなる気がするのでここで処理してしまうことにする */

/** ユーザーデーターを扱う時はupdateUserDataと同じような書き方で統一したいのであえてクラスのGetterっぽくしている */
const getUserData = useCallback(
async (key: UserDataKey) => {
try {
const email = user?.email
if (!isDefined(email))
throw new Error(
"ログイン中ユーザーのメールアドレスを取得できませんでした"
)

if (!isDefined(decryptionVerifyString))
throw new Error(
"データーの復号化検証に必要な環境変数 NEXT_PUBLIC_DECRYPTION_VERIFY_STRING が設定されていません。サーバー管理者にお問い合わせください。"
)

const userDataDocument = await getDoc(
doc(db, FIRESTORE_USERDATA_COLLECTION_NAME, email)
)

if (!userDataDocument.exists())
throw new Error("ユーザーデーターが存在しません")

if (!userDataDocument.data()?.[key]) return undefined // keyで指定されたフィールドが存在しない場合はundefinedを返す

const encryptedUserData = userDataDocument.data() as UserData // TODO: withConverter使って型に強くしたい
const decryptedVerifyString = decryptText(
encryptedUserData[FIRESTORE_DOCUMENT_KEYS.DECRYPTION_VERIFY_STRING]
)

if (decryptionVerifyString !== decryptedVerifyString)
throw new Error(
"データーの復号化検証に失敗しました。再ログインしてください。"
) // TODO: モーダルのボタン押したらログインフォームに飛ばされる独自例外に置き換える

return decryptText(encryptedUserData[key] as string)
} catch (e) {
showError(e)
}
},
[showError, decryptText, user, decryptionVerifyString]
(key: UserDataKey) => userData?.[key],
[userData]
)

const updateUserData = useCallback(
async (key: UserDataKey, value: string) => {
/** updateUserDataの使用箇所でupdateUserData自体をtry/catchしてしまうのが正しい実装なのだろうが、如何せん使用箇所が多くいちいちtry/cathcを書いているとコードが汚くなる気がするので例外処理はここで捌いてしまう */
try {
const email = user?.email
if (!isDefined(email))
Expand All @@ -133,11 +102,23 @@ const useStorage = () => {
doc(db, FIRESTORE_USERDATA_COLLECTION_NAME, email),
data
)

if (!isDefined(userData))
throw new Error("ユーザーデーターがundefinedです")
const updatedUserData = { ...userData, [key]: value }
setUserData(updatedUserData)
} catch (e) {
showError(e)
}
},
[encryptText, showError, user, decryptionVerifyString]
[
encryptText,
showError,
user,
decryptionVerifyString,
userData,
setUserData
]
)

const deleteUserData = useCallback(
Expand All @@ -152,13 +133,76 @@ const useStorage = () => {
await updateDoc(doc(db, FIRESTORE_USERDATA_COLLECTION_NAME, email), {
[key]: deleteField()
})

if (!isDefined(userData))
throw new Error("ユーザーデーターがundefinedです")
const updatedUserData = { ...userData }
delete updatedUserData[key]
setUserData(updatedUserData)
} catch (e) {
showError(e)
}
},
[showError, user]
[showError, user, userData, setUserData]
)

/** MixJuiceを起動した時にFirestoreのデーターをローカルのRecoilStateに取り込む */
useEffect(() => {
if (!initialize || !isDefined(user) || isLoadingUser) return
;(async () => {
try {
const email = user?.email
if (!isDefined(email))
throw new Error(
"ログイン中ユーザーのメールアドレスを取得できませんでした"
)

if (!isDefined(decryptionVerifyString))
throw new Error(
"データーの復号化検証に必要な環境変数 NEXT_PUBLIC_DECRYPTION_VERIFY_STRING が設定されていません。サーバー管理者にお問い合わせください。"
)

const userDataDocument = await getDoc(
doc(db, FIRESTORE_USERDATA_COLLECTION_NAME, email)
)

if (!userDataDocument.exists())
throw new Error("ユーザーデーターが存在しません")

const encryptedUserData = userDataDocument.data() as UserData // TODO: withConverter使って型に強くしたい
const decryptedUserData = Object.fromEntries(
Object.entries(encryptedUserData).map(([key, value]) => [
key,
decryptText(value as string)
])
) as unknown as UserData

const decryptedVerifyString =
decryptedUserData[FIRESTORE_DOCUMENT_KEYS.DECRYPTION_VERIFY_STRING]

if (decryptionVerifyString !== decryptedVerifyString)
throw new Error(
"データーの復号化検証に失敗しました。再ログインしてください。"
) // TODO: モーダルのボタン押したらログインフォームに飛ばされる独自例外に置き換える

setUserData(decryptedUserData)
console.log(
"🟩DEBUG: Firestore上のユーザーデータをRecoilStateに取り込みました"
)
} catch (e) {
showError(e)
}
})()
}, [
initialize,
user,
isLoadingUser,
setUserData,
showError,
decryptText,
decryptionVerifyString
])

return {
createNewHashedPassword,
createNewUserDocument,
Expand Down
8 changes: 4 additions & 4 deletions hooks/useWebDAVSettingState.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { useEffect } from "react"
import { useAuthState } from "react-firebase-hooks/auth"
import { useRecoilState, useSetRecoilState } from "recoil"
import { selectedWebDAVFoldersAtom } from "@/atoms/selectedWebDAVFoldersAtom"
import { webDAVAuthenticatedAtom } from "@/atoms/webDAVAuthenticatedAtom"
import { webDAVSettingStateAtom } from "@/atoms/webDAVSettingStateAtom"
import { LOCAL_STORAGE_KEYS } from "@/constants/LocalStorageKeys"
import { auth } from "@/utils/firebase"

type Args = {
isLoadingUser: boolean
}
const useSetWebDAVSettingState = () => {
const [, isLoadingUser] = useAuthState(auth)

const useSetWebDAVSettingState = ({ isLoadingUser }: Args) => {
/**
* done: WebDAVサーバーへのログイン・フォルダーの指定が完了している
* setting: WebDAVサーバーへのログインは完了しているが、フォルダーの指定が完了していない
Expand Down
2 changes: 1 addition & 1 deletion types/UserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FIRESTORE_DOCUMENT_KEYS } from "@/constants/Firestore"

export interface UserData {
[FIRESTORE_DOCUMENT_KEYS.DECRYPTION_VERIFY_STRING]: string
[FIRESTORE_DOCUMENT_KEYS.SPOTIFY_REFRESH_TOKEN]?: string
[FIRESTORE_DOCUMENT_KEYS.SPOTIFY_REFRESH_TOKEN]?: string | undefined
// TODO: フィールド追加する
}

Expand Down
2 changes: 1 addition & 1 deletion 開発メモ.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@

## usePlayerで扱っているステートを全てグローバルステート化してpropsによるバケツリレーをやめることができない理由

メインページ以外でusePlayerを利用する際、イベントリスナーが重複することによる二重処理などが行われないようにするため、引数には`{initialize: false}`を渡すことになるが、`initialize``false`の場合、usePlayer配下のフック(例: useSpotifyPlayer)の引数も`{initialize: false}`になるので、usePlayer配下のフックのuseEffectも作動しなくなり、再生位置の更新などがされなくなる。
メインページ以外でusePlayerを利用する際、イベントリスナーが重複することによる二重処理などが行われないようにするため、引数には`{initialize: false}`を渡すことになるが、`initialize``false`の場合、usePlayer配下のフック(例: useSpotifyPlayer)の引数も`{initialize: false}`になるので、usePlayer配下のフックのuseEffectも作動しなくなり、再生位置の更新などがされなくなる。

0 comments on commit 126bcae

Please sign in to comment.