🍤

[PWA ( React + TypeScript )] 死んだ金魚を追悼するためのサイトを作った備忘録

2021/12/12に公開

はじめに

PWA(Progressive Web Apps) Advent Calendar 2021用の記事です

今年の悲しい出来事としては、飼っていた金魚が死んでしまいました・・・。
名前はベルちゃんで、とても可愛かったです😢
「ベルちゃんの追悼サイトを作って、いつでも手を合わせられるようにしよう!」と思ってサイトを作りました。
サイト自体は以下になります。

Bel-chan's grave

ただ、このサイトを作ったのはもう何ヶ月も前で、もう何やったか忘れてしまいました…。
無駄にプッシュ通知をつけたことは覚えています。
今コードを見返しても何やってるか思い出せないかもしれませんが、来年にはもっと忘れてしまうと思うので、完全に忘れる前に備忘録として残そうと思います。

PWAになっていて、インストールできるのとプッシュ通知はついている感じです。

技術関連

以下を使用しました。

  • React
  • TypeScript
  • firebase ・・・ プッシュ通知やデータの保持

フォルダ構成

フォルダ・ファイル構成としてはこんな感じです。

📁 .firebase ・・・ firebase入れたら自動的に作成された
📁 coverage ・・・ firebase入れたら自動的に作成された
📁 dist
  📁 css
    📄 style.css
  📁 img
    📄 icon192.png
    📄 icon512.png
    📄 iei.png
  📁 sound
    📄 chiiin.mp3
  📄 favicon.ico
  📄 firebase-messaging-sw.js
  📄 index.html
  📄 index.js
  📄 index.js.LICENSE.txt
  📄 manifest.json
📁 src
  📄 App.tsx
  📄 BelChan.tsx
  📄 Firebase.ts
  📄 index.tsx
📁 test
  📄 sample.test.ts
📄 .editorconfig
📄 .eslintrc.js
📄 .firebaserc ・・・ firebase入れたら自動的に作成された
📄 .gitignore
📄 .prettierrc.json
📄 README.md
📄 firebase.json ・・・ firebase入れたら自動的に作成された
📄 firestore.indexes.json ・・・ firebase入れたら自動的に作成された
📄 firestore.rules ・・・ firebase入れたら自動的に作成された
📄 jest.config.ts
📄 package-lock.json
📄 package.json
📄 tsconfig.json
📄 webpack.config.js
📄 yarn-error.log
📄 yarn.lock

firebaseについて

firebaseは以下の機能を使っています。

  • Cloud Firestore ・・・ ベルちゃんへのお祈り回数を保持する
  • Hosting ・・・ サイトに必要なファイルとかをホスティングする
  • Cloud Messaging ・・・ プッシュ通知
# こんな手順をやったと思います
$ npm install -g firebase-tools
$ firebase login
$ firebase init
$ firebase deploy

srcフォルダ内のソース

単に画像用のコンポーネントみたいです。

BelChan.tsx
import React from 'react';
import styled from 'styled-components';

const BelChan = () => {
  return (
    <Img src="./img/iei.png" />
  );
}

export default BelChan;

const Img = styled.img`
  width: 50%;
  max-width: 500px;
`;

firebaseの設定関連です。

Firebase.ts
import firebase from 'firebase/app';
import 'firebase/firestore';
import 'firebase/messaging';

// firebaseの「プロジェクト概要の右隣の歯車アイコン>全般タブ」…に書いてある内容です
const firebaseConfig = {
  apiKey: "XXXXXXXXX",
  authDomain: "bel-grave.firebaseapp.com",
  projectId: "bel-grave",
  storageBucket: "bel-grave.appspot.com",
  messagingSenderId: "XXXXXXXXX",
  appId: "XXXXXXXXX",
  measurementId: "XXXXXXXXX",
};

firebase.initializeApp(firebaseConfig);

let messaging = null;
if (firebase.messaging.isSupported()) {
  messaging = firebase.messaging();
  // vapidKeyは「プロジェクト概要の右隣の歯車アイコン>Cloud Messagingタブ>ウェブプッシュ証明書>鍵ペア」を設定しています
  messaging.getToken({vapidKey: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'});
}

export default firebase;
export const db = firebase.firestore();
export const msg = messaging;

これがメインの処理が書いてあるファイルです。

App.tsx
import React from 'react';
import styled from 'styled-components';
import BelChan from './BelChan';
import { db } from './Firebase';

// state's type
type State = {
  prayersCount: number,
  prayDisabled: boolean,
};

export default class App extends React.Component<{}, State> {
  constructor(props: any) {
    super(props);
    this.state = {
      prayersCount: 0,
      prayDisabled: true,
    };
    this.calcPastDays = this.calcPastDays.bind(this);
    this.pray = this.pray.bind(this);
    this.requestNotification = this.requestNotification.bind(this);
    this.getPrayersCount();
  }

  /**
   * get current prayers count
   */
  async getPrayersCount(): Promise<void> {
    this.setState({
      prayDisabled: true,
    });
    const colRef = db.collection('prayers').limit(1);
    const snapshots = await colRef.get();
    const docs = snapshots.docs.map(doc => doc.data());
    const count: number = docs[0].numberOfPrayers;
    this.setState({prayersCount: count});
    this.setState({
      prayDisabled: false,
    });
  }

  /**
   * update prayers count
   */
  async updatePrayersCount(): Promise<void> {
    this.getPrayersCount();
    const prayersCount: number = this.state.prayersCount + 1;
    this.setState({
      prayersCount: prayersCount
    });
    const userRef = db.collection('prayers').doc('XXXXXXXXXXXX');
    await userRef.set({
      numberOfPrayers: prayersCount
    });
  }

  /**
   * get days since bel-chan died
   * @returns past days
   */
  calcPastDays(): string {
    // ベルちゃんが死んだの、7月6日だったなぁ・・・
    // ちょうど七夕の前日だった・・・
    const death: Date = new Date(2021, 6, 6);
    const today: Date = new Date();
    const pastDays: number = (today.getTime() - death.getTime()) / (60 * 60 * 24 * 1000);
    return parseInt(pastDays.toString()).toLocaleString();
  }

  /**
   * pray to Bel-chan
   */
  pray(): void {
    this.setState({
      prayDisabled: true,
    });
    const music = new Audio('./sound/chiiin.mp3');
    music.play();
    this.updatePrayersCount();
    this.setState({
      prayDisabled: false,
    });
  }

  /**
   * request notifications permission
   * @param e click event
   */
  requestNotification(e: React.MouseEvent<HTMLAnchorElement>): void {
    e.preventDefault();

    if (Notification.permission === 'granted') {
      alert('You can receive push notifications.');

    } else if(Notification.permission === 'denied') {
      alert('You denied notifications. Please click the address bar\'s lock icon, and grant notifications.');

    } else {
      Notification.requestPermission().then((permission) => {
        if (permission === 'granted') {
          const title = 'Thank you';
          const options = {
            body: 'You can get notifications about Bel-chan.',
            icon: './img/icon192.png',
          };
          const notification = new Notification(title, options);
        }
      });
    }
  }

  render() {
    return (
      <Container>
        <Title>Bel-chan's grave</Title>
        <Message>
          Bel-chan was a cute goldfish.<br />
          2021/07/06 is the anniversary of Bel-chan's death.<br />
          <br />
          It's been {this.calcPastDays()} days since Bel-chan died.<br />
          <br />
          <PrayButton onClick={() => this.pray()} disabled={this.state.prayDisabled}>🙏 Pray to Bel-chan</PrayButton>
          <PrayersCount>number of prayers: {this.state.prayersCount}</PrayersCount>
          <br />
          <a href="#" onClick={(e) => this.requestNotification(e)}>Do you receive push notifications about Bel-chan ?</a>
        </Message>
        <TopImage>
          <BelChan />
        </TopImage>
        <Copyright>
          sound by <a href="https://otologic.jp/">OtoLogic</a>
        </Copyright>
      </Container>
      );
  }
}

const Container = styled.div`
`;

const Title = styled.div`
  font-size: 1.2rem;
  font-weight: bold;
  text-align: center;
  background: black;
  color: #ffffff;
  line-height: 40px;
`;

const Message = styled.div`
  width: 50%;
  max-width: 500px;
  margin: 0 auto;
  text-align: center;
  background: #ffffff;
  border-radius: 5px;
  padding: 10px 10px 15px;
  margin-top: 20px;
  margin-bottom: 10px;
`;

const PrayButton = styled.button`
  cursor: pointer;
  line-height: 20px;
  font-weight: 700;
`;

const PrayersCount = styled.div`
`;

const TopImage = styled.div`
  text-align: center;
`;

const Copyright = styled.div`
  text-align: center;
`;

エントリーポイントとなるファイルです。

index.tsx
import React from 'react';
import { render } from 'react-dom';
import App from './App';

render(<App />, document.getElementById('app'));

distフォルダ内のソース

トップのHTMLはこんな感じです。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# website: http://ogp.me/ns/website#">
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Bel-chan's grave</title>
    <meta property="og:url" content="https://bel-grave.web.app/" />
    <meta property="og:type" content="website" />
    <meta property="og:title" content="Bel-chan's grave" />
    <meta property="og:description" content="You can pray to Bel-chan." />
    <meta property="og:site_name" content="Bel-chan's grave" />
    <meta property="og:image" content=" https://bel-grave.web.app/img/icon512.png" />

    <link rel="manifest" href="manifest.json" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Yomogi&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="css/style.css" />
    <script src="/__/firebase/8.7.1/firebase-app.js"></script>
    <script src="/__/firebase/8.7.1/firebase-analytics.js"></script>
    <script src="/__/firebase/8.7.1/firebase-messaging.js"></script>
    <script src="/__/firebase/init.js"></script>
    <script src="index.js" defer></script>

    <script async src="https://www.googletagmanager.com/gtag/js?id=G-08BDD4G8K6"></script>
    <script>
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());

      gtag('config', 'G-08BDD4G8K6');
    </script>
</script>
  </head>
  <body>
    <div id="app"></div>
    <script>
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker
          .register('firebase-messaging-sw.js')
          .then(function (registration) {
            navigator.serviceWorker.ready.then((reg) => {
	      // 通知を許可するかどうかのメッセージが出る
              reg.pushManager.subscribe({
	        // プッシュが送信されたときに通知を表示するかの設定らしい
                userVisibleOnly: true,
              });
            });
          })
          .catch(function (err) {
            console.log('service worker registration failed: ', err);
          });

        navigator.serviceWorker.ready.then(function (registration) {
	  // 既存のサブスクリプションを取得する
          registration.pushManager
            .getSubscription()
            .then(function (subscription) {})
            .catch(function (error) {
              console.error('Error occurred enabling push ', error);
            });
        });

        /**
         * Web Push (when foreground)
         */
        navigator.serviceWorker.onmessage = (payload) => {
          const title = payload.data.notification.title;
          const options = {
            body: payload.data.notification.body,
            icon: payload.data.notification.icon,
          };
          const notification = new Notification(title, options);
        };
      }
    </script>
  </body>
</html>

gcm_sender_id はfirebaseの設定のmessagingSenderIdを同じ値を設定しているっぽいです。

manifest.json
{
  "short_name": "Bel-chan's grave",
  "name": "This is Bel-chan's grave.",
  "icons": [
    {
      "src": "/img/icon192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/img/icon512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "scope": "/",
  "description": "Bel-chan is forever.",
  "gcm_sender_id": "XXXXXXXXX"
}

サービスワーカーの中身。
notificationclick は通知をクリックしたときの処理らしいです。

firebase-messaging-sw.js
importScripts('https://www.gstatic.com/firebasejs/8.7.1/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/8.7.1/firebase-messaging.js');

firebase.initializeApp({
  apiKey: 'XXXXXXXXX',
  authDomain: 'bel-grave.firebaseapp.com',
  projectId: 'bel-grave',
  storageBucket: 'bel-grave.appspot.com',
  messagingSenderId: 'XXXXXXXXX',
  appId: 'XXXXXXXXX',
  measurementId: 'XXXXXXXXX',
});

const messaging = firebase.messaging();

const CACHE_NAME = 'belchan-grave';
const urlsToCache = [
  '/bel-grave.web.app/',
  '/bel-grave.web.app/img/',
  '/bel-grave.web.app/img/iei.png',
  '/bel-grave.web.app/img/icon192.png',
  '/bel-grave.web.app/img/icon512.png',
  '/bel-grave.web.app/sound/',
  '/bel-grave.web.app/sound/chiin.mp3',
  '/bel-grave.web.app/index.js',
  '/bel-grave.web.app/css/',
  '/bel-grave.web.app/css/style.css',
  '/bel-grave.web.app/serviceWorker.js'
];

/**
 * install
 */
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches
      .open(CACHE_NAME)
      .then((cache) => {
        return cache.addAll(urlsToCache);
      })
  );
});

/**
 * fetch
 */
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches
      .match(event.request)
      .then((response) => {
        return response ? response : fetch(event.request);
      })
  );
});

/**
 * background
 */
messaging.setBackgroundMessageHandler((payload) => {
  let title = payload.data.notification.title;
  let options = {
    body: payload.data.notification.body,
    icon: payload.data.notification.icon,
  };

  return self.registration.showNotification(title, options);
});

/**
 * push
 */
self.addEventListener('push', async (event) => {
  const payload = await event.data.json();
  event.waitUntil(
    self.registration.showNotification(
      payload.notification.title,
      {
        body: payload.notification.body,
        icon: payload.notification.icon,
      }
    )
  );
});

/**
 * notificationclick
 */
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const url = 'https://bel-grave.web.app/';

  event.waitUntil(clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  }).then(function(clientList) {
    console.log(clientList);
    for (const client of clientList) {
      if (client.url === url && 'focus' in client)
        return client.focus();
    }
    if (clients.openWindow)
      return clients.openWindow(url);
  }));
});

設定ファイル関連

webpackの設定。

webpack.config.js
const path = require('path');
const environment = process.env.NODE_ENV || 'development';

module.exports = {
  mode: environment, // 'production' | 'development' | 'none'

  // エントリーポイント
  entry: './src/index.tsx',

  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'index.js',
  },

  module: {
    rules: [
      // TypeScriptの場合の設定
      {
        test: /\.(ts|tsx)$/,
        use: 'ts-loader',
      },
    ],
  },
  // importの対処
  resolve: {
    modules: [
      'node_modules', // node_modules内も対象にする
    ],
    extensions: [
      '.ts',
      '.tsx',
      '.js',
    ],
  },
  devServer: {
    contentBase: path.join(__dirname, 'dist')
  }
};

yarn run build でリリース用のビルド。

package.json
{
  "name": "bel-chan-grave",
  "version": "1.0.0",
  "description": "This is Bel-chan's grave.",
  "main": "index.js",
  "author": "manycicadas",
  "license": "MIT",
  "private": false,
  "scripts": {
    "build": "cross-env NODE_ENV=\"production\" webpack",
    "start": "cross-env NODE_ENV=\"development\" webpack serve",
    "lint": "eslint --fix 'src/**/*.{js,jsx,ts,tsx}'",
    "test": "jest"
  },
  "devDependencies": {
    省略
  },
  "dependencies": {
    省略
  }
}

おわりに

プログラムを読んでいると、あー、こんなの書いたなぁという感じになりました。
プッシュ通知は、もともとはベルちゃんの命日になったら通知を飛ばすなどをやろうと思っていました。
でもそこまで作り込みはせず、firebaseの管理コンソールからプッシュ通知が送れたら満足しちゃいました。

この記事を書いている時点で、ベルちゃんが死んでから158日経ったようです。
ベルちゃん・・・かわいかったなぁ・・・😭

Discussion