【React(TypeScript)】React + Firebase Cloud MessagingでWebプッシュを受け取る

# 概要
WebアプリでPush通知を使ってお知らせを表示したいけど、Reactではどう実装すればいいか分からない方に向けてマイベストプラクティスを共有させていただきたいと思います。

# 制約
iOSで通知を受け取る場合、ブラウザではWebプッシュを受け取ることができない。Webサイトをブラウザで開き「ホーム画面を追加」操作を行なってPWA化する必要がある。
＊PWA = Progressive Web Apps。Webサイトをアプリのように動作させる仕組み

# 通知を受け取った時の挙動
Webアプリがフォアグラウンド（アクティブ状態）の時に通知を受け取った場合は、ReactのonMessageメソッドが呼ばれるので、引数のpayloadを読み取りalert等を使って通知内容を自由に表示させることができます。
ブラウザがバッググラウンド時に通知を受け取った時の挙動はOS、ブラウザによって変わるので注意が必要です。
|OS |ブラウザ |別のWebサイトを開いている時の挙動 |ブラウザを閉じている時の挙動 |
|---|---|---|---|
|Windows |Chrome |すぐにOSの通知が表示される |すぐに通知は行われず、次にブラウザを起動した時にまとめてOSの通知が表示される|
|Windows |Edge |すぐにOSの通知が表示される |すぐにOSの通知が表示される|
|Mac  |Chrome  |すぐにOSの通知が表示される  |すぐに通知は行われず、次にブラウザを起動した時にまとめてOSの通知が表示される|

# FCMの設定
1. Firebaseにアカウントを作成して、Firebaseコンソールで任意のプロジェクトを作成します。
2. ウェブ（<<//>のアイコン）のアプリを作成します。(Firebase Hostingは不要)
3. 「Firebase SDK の追加」で「npmを使用する」を選択します。
4. 「プロジェクトの設定」の「全般」「Cloud Messaging」「サービスアカウント」等を参照します。

# クライアント側(React)
Webサイトがフォアグラウンドではない時に通知を受け取るためにService Workerが必要となります。App.tsxにService Workerの登録処理、及びフォアグラウンド時の通知受信処理を書きます。
（参考）https://firebase.google.com/docs/cloud-messaging/js/receive?hl=ja

```jsx
// Firebaseの「プロジェクトの設定」の「全般」の「SDKの設定と構成」に記載されているConfig情報
const firebaseConfig = {
  apiKey: 'XXXXXXXXXXXXXX',
  authDomain: 'XXXXXXXXXfirebaseapp.com',
  projectId: 'XXXXXXXXXX',
  storageBucket: 'XXXXXXXXXXX.appspot.com',
  messagingSenderId: 'XXXXXXXXXXXXX',
  appId: '1:XXXXXXXX:web:XXXXXXXXXXXXX',
  measurementId: 'XXXXXXXXXXXX',
}

const firebaseApp = initializeApp(firebaseConfig)
const messaging = getMessaging(firebaseApp)

// exportしている理由はiOSの場合だとuseEffectでこの処理を呼んでも通知許可のダイアログが出ないため。
// iOSの場合はボタンクリックなどの明示的なユーザー操作イベントでこのメソッドを呼ぶようにする。
export function confirmNotification() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker
      .register(`${process.env.REACT_APP_BASE_URL}/firebase-messaging-sw.js`)
      .then((registration) => {
        console.log('Service Worker registered with scope:', registration.scope)

        Notification.requestPermission()
          .then(async (permission) => {
            if (permission === 'granted') {
              console.log('Notification permission granted.')
              if (getFcmTokenFromStorage() != null) {
                return
              }
              return await getToken(messaging, {
                vapidKey:
                  'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', // Firebaseの「Cloud Messaging」の「ウェブプッシュ証明書」に記載されている鍵ペア
                serviceWorkerRegistration: registration, // .register()で指定したサービスワーカーを使用する。これを指定しないとルート直下にあるfirebase-messaging-sw.jsを探して使おうとする。
              })
            } else {
              console.error('Unable to get permission to notify.')
            }
          })
          .then((token) => {
            if (token != null) {
              console.log('FCM Token:', token)
              // TODO: FCMトークンをDBに保存する
            }
          })
          .catch((error) => {
            console.error('An error occurred while retrieving token.', error)
          })
      })
      .catch((error) => {
        console.error('Service Worker registration failed:', error)
      })
  }
}

const App = () => {
  useEffect(() => {
    confirmNotification()

    onMessage(messaging, (payload) => {
      console.log('Message received. ', payload)
      // TODO:フォアグラウンドメッセージの処理を行う
    })
  }, [])

  return 
    省略
}
```

firebase-messaging-sw.js
(参考）https://firebase.google.com/docs/cloud-messaging/js/receive?hl=ja#setting_notification_options_in_the_service_worker

``` javascript
importScripts('https://www.gstatic.com/firebasejs/9.6.1/firebase-app-compat.js')
importScripts('https://www.gstatic.com/firebasejs/9.6.1/firebase-messaging-compat.js')

// Firebaseの「プロジェクトの設定」の「全般」の「SDKの設定と構成」に記載されているConfig情報
const firebaseConfig = {
  apiKey: 'XXXXXXXXXXXXXX',
  authDomain: 'XXXXXXXXXfirebaseapp.com',
  projectId: 'XXXXXXXXXX',
  storageBucket: 'XXXXXXXXXXX.appspot.com',
  messagingSenderId: 'XXXXXXXXXXXXX',
  appId: '1:XXXXXXXX:web:XXXXXXXXXXXXX',
  measurementId: 'XXXXXXXXXXXX',
}

firebase.initializeApp(firebaseConfig)

const messaging = firebase.messaging()

// onBackgroundMessageはWebプッシュ送信時にdataを指定する場合に必要。notificationだけなら不要。
//
// messaging.onBackgroundMessage((payload) => {
//   console.log('[firebase-messaging-sw.js] Received background message ', payload)
//   const notificationTitle = payload.notification.title
//   const notificationOptions = {
//     body: payload.notification.body,
//     icon: '/firebase-logo.png',
//   }

//   self.registration.showNotification(notificationTitle, notificationOptions)
// })

```

# サーバー側
簡単にWebプッシュのテストを行いたい場合はfirebase_adminを使用すると良いです。
この例ではmessaging.Notification()でnotificationを指定していますが、
カスタマイズが必要な場合はdataを指定します。
（参考）https://firebase.google.com/docs/cloud-messaging/js/send-with-console?hl=ja

pythonの実装例
``` python
import firebase_admin
from firebase_admin import credentials
from firebase_admin import messaging

# Firebaseの「プロジェクトの設定」の「サービスアカウント」で作成したserviceAccountKey.json
cred = credentials.Certificate("serviceAccountKey.json")
firebase_admin.initialize_app(cred)

# This registration token comes from the client FCM SDKs.
registration_token = 'ブラウザで通知を許可した時に取得したFCMトークン'

# See documentation on defining a message payload.
message = messaging.Message(
    notification=messaging.Notification(
        title='テスト',
        body='テストメッセージです。',
    ),
    token=registration_token,
)

# Send a message to the device corresponding to the provided
# registration token.
response = messaging.send(message)
# Response is a message ID string.
print('Successfully sent message:', response)

```