LIFFアプリの簡単な始め方について

## はじめに

この記事は、[ミロゴス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/milogos) 14日目の投稿です。
本記事では、LIFF開発をするにあたって必要な内容を紹介できればと思います。

## LIFFとは
LINE Front-end Framework（LIFF）は、LINEヤフー株式会社が提供するウェブアプリのプラットフォームです。
このプラットフォームで動作するウェブアプリを、LIFFアプリと呼びます。

LIFFアプリを使うと、LINEのユーザーIDなどをLINEプラットフォームから取得できます。
LIFFアプリではこれらを利用して、ユーザー情報を活用した機能を提供したり、ユーザーの代わりにメッセージを送信したりできます。

https://developers.line.biz/ja/docs/liff/overview/


## 内容

### 環境構築

この記事では、liffの他に下記二つのライブラリを使用します。

- nextjs
- chakraui

環境構築をこちらの記事で紹介しているので、ご参照下さい。

https://qiita.com/tk77/private/8f18d272437630b6d17c#%E7%92%B0%E5%A2%83%E6%A7%8B%E7%AF%89

#### パッケージの追加

- LIFF SDKのインストール
```sh
npm install --save @line/liff
```

### サーバーの準備

LIFFを始めるにあたって、サーバーを用意する必要があります。
そのため、今回はngorkというローカルサーバーを全世界に公開できる便利なツールを使用します。

#### ngrokの登録
- ngrokのサインアップ

https://ngrok.com/

- ngrokのインストール
```sh
brew install ngrok/ngrok/ngrok
```

- 認証トークンをデフォルトのngrok.yml 構成ファイルへ追加
```sh
ngrok config add-authtoken xxxxxxxxxx
```

- 設定ファイルの内容を表示
```sh
cat ~/Library/Application\ Support/ngrok/ngrok.yml
```

#### Webサーバーの公開
ローカルのポート3000で実行されているウェブサーバーを外部に公開します。

```sh
ngrok http 3000
```
```sh
ngrok                                                                                                                                                    (Ctrl+C to quit)
                                                                                                                                                                         
Build better APIs with ngrok. Early access: ngrok.com/early-access                                                                                                       
                                                                                                                                                                         
Session Status                online                                                                                                                                     
Account                       xxx@xxx.com (Plan: Free)                                                                                                 
Update                        update available (version 3.5.0, Ctrl-U to update)                                                                                         
Version                       3.3.4                                                                                                                                      
Region                        Japan (jp)                                                                                                                                 
Latency                       7ms                                                                                                                                        
Web Interface                 http://127.0.0.1:4040                                                                                                                      
Forwarding                    https://xxxxxxxxxx.ngrok-free.app -> http://localhost:3000                                                 
                                                                                                                                                                         
Connections                   ttl     opn     rt1     rt5     p50     p90                                                                                                
                              329     1       0.00    0.00    0.04    5.78                                                                                               
                                                                                                                                                                         
HTTP Requests                                                                                                                                                            
-------------       
```

### LIFFの作成
LIFFを始めるための必要な準備をします。

#### チャネルを作成

##### LINE Developersコンソールへログイン
必要であれば、新規アカウント作成をします。

https://developers.line.biz/ja/

##### 新規チャネルの作成
- 「プロバイダー」 > 「チャネル」から「新規チャネルの作成」を押下します。
- LINEログインを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/b8f8b4c0-f000-7f8a-93d4-6cf5d2ae6c17.png)
- チャネル内のLIFFに関する設定を入力します。
    - 必須項目を選択・入力し、LINE開発規約に同意チェックを入れます。
        - アプリタイプについては、ウェブアプリを選択します。

##### LIFFの追加
作成したチャネルにLIFFアプリを追加します。

- 作成したチャネルからLIFFタブを選択し、追加ボタンを押下します。
- 必須の項目を入力します。
    - エンドポイントURLには、ngrokで作成したURLを入力します。
    - 項目の詳しい解説につきましては、以下の公式ガイドで確認ください。

https://developers.line.biz/ja/docs/liff/registering-liff-apps/#registering-liff-app


### LIFFの使い方

#### 全体構成
今回、使用する全体の構成になります。
ソースコードにつきましては、以下の記事で作成したものを少し改変したものとなります。

https://qiita.com/tk77/items/dc73f51745c2a4c8af08

<details>
<summary>ソースコード一覧</summary>
<div>

##### ディレクトリ構成
```
.
├── README.md
├── components
│   └── Index
│       ├── MainContent.tsx
│       └── constants.ts
├── constants
│   └── index.ts
├── hooks
│   └── useLiffInitialization.ts
├── next-env.d.ts
├── node_modules
├── package-lock.json
├── package.json
├── pages
│   ├── _app.tsx
│   └── index.tsx
├── public
│   └── images
│       ├── image_1.png
│       ├── image_2.png
│       └── image_3.png
├── tsconfig.json
├── types
│   └── index.ts
├── utils
│   └── sample-data.ts
└── .env
```

##### ソースコード

```ts:components/Index/constants.ts
export const TITLE_FIRST = 'タイトル1';
export const TITLE_SECOND = 'タイトル2';
export const TITLE_THIRD = 'タイトル3';
export const IMAGE_PATH_FIRST = '/images/image_1.png';
export const IMAGE_PATH_SECOND = '/images/image_2.png';
export const IMAGE_PATH_THIRD = '/images/image_3.png';
export const IMAGE_ID_VALIDATION_MESSAGE = '画像を選択してください。';
export const MESSAGE_FIELD_VALIDATION_MESSAGE =
  'メッセージを入力してください。';
export const MESSAGE_FIELD_MAX_LENGTH = 100;
export const MESSAGE_FIELD_MAX_LENGTH_VALIDATION_MESSAGE =
  'メッセージは100文字以内で入力してください。';
export const SUBMIT_BUTTON_TEXT = '送信';
```

```ts:components/Index/MainContent.tsx
import { Liff } from '@/types';
import { CheckIcon } from "@chakra-ui/icons";
import {
    Box,
    Button,
    Flex,
    FormControl,
    FormErrorMessage,
    Heading,
    Image,
    Text,
    Textarea,
    VStack,
} from "@chakra-ui/react";
import React, { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import * as FormConstants from "./constants";

export interface MainContentProps {
    liffObject: Liff;
}

const MainContent = (props: MainContentProps) => {
    const { liffObject } = props;
    const images = [
        {
            id: 1,
            title: FormConstants.TITLE_FIRST,
            src: FormConstants.IMAGE_PATH_FIRST,
        },
        {
            id: 2,
            title: FormConstants.TITLE_SECOND,
            src: FormConstants.IMAGE_PATH_SECOND,
        },
        {
            id: 3,
            title: FormConstants.TITLE_THIRD,
            src: FormConstants.IMAGE_PATH_THIRD,
        },
    ];
    const [selectedImageId, setSelectedImageId] = useState(null as number | null);
    console.log("選択した画像ID", selectedImageId);

    const {
        register,
        handleSubmit,
        control,
        setValue,
        formState: { errors },
    } = useForm();

    useEffect(() => {
        setValue("imageId", selectedImageId);
    }, [selectedImageId, setValue]);

    const onSubmit = (data) => {
        const imageId = data.imageId;
        const message = data.message;

        console.log("取得した画像ID", imageId);
        console.log("取得したメッセージ", message);
    };

    return (
        <>
            <Flex alignItems="center" justifyContent="center">
                <VStack spacing="4" align="start" padding="0">
                    <VStack align="start">
                        <Heading size="lg">画像の選択</Heading>
                        <Heading fontSize="14px">
                            画像をクリックすると対象の画像IDを取得できます。
                        </Heading>
                    </VStack>
                    <FormControl isInvalid={!!errors.imageId}>
                        <Controller
                            name="imageId"
                            control={control}
                            defaultValue=""
                            rules={{
                                required: FormConstants.IMAGE_ID_VALIDATION_MESSAGE,
                            }}
                            render={({ field }) => (
                                <input
                                    type="hidden"
                                    value={field.value || ""}
                                    onChange={field.onChange}
                                />
                            )}
                        />
                        {typeof errors.imageId?.message === "string" && (
                            <FormErrorMessage>{errors.imageId.message}</FormErrorMessage>
                        )}
                    </FormControl>
                    {images.map((image, index) => (
                        <React.Fragment key={index}>
                            <Text>{image.title}</Text>
                            <Box
                                position="relative"
                                key={image.id}
                                borderRadius="md"
                                onClick={() => {
                                    setSelectedImageId(image.id);
                                }}
                                marginBottom={index === images.length - 1 ? "30px" : "0"}
                            >
                                <Image
                                    src={image.src}
                                    alt={`Image ${image.id}`}
                                    width="100%"
                                    opacity={selectedImageId === image.id ? 1 : 0.6}
                                />{" "}
                                {selectedImageId === image.id && (
                                    <CheckIcon
                                        position="absolute"
                                        top="5px"
                                        right="5px"
                                        w={6}
                                        h={6}
                                        color="green.400"
                                    />
                                )}{" "}
                            </Box>
                        </React.Fragment>
                    ))}
                </VStack>
            </Flex>
            <Flex alignItems="center" justifyContent="center">
                <form onSubmit={handleSubmit(onSubmit)}>
                    <FormControl isInvalid={!!errors.message} width="500px">
                        {typeof errors.message?.message === "string" && (
                            <FormErrorMessage>{errors.message.message}</FormErrorMessage>
                        )}
                        <VStack align="start" marginBottom="20px">
                            <Heading size="lg">メッセージの入力</Heading>
                            <Heading fontSize="14px">入力された内容を取得します。</Heading>
                        </VStack>
                        <Textarea
                            {...register("message", {
                                required: FormConstants.MESSAGE_FIELD_VALIDATION_MESSAGE,
                                maxLength: {
                                    value: FormConstants.MESSAGE_FIELD_MAX_LENGTH,
                                    message:
                                        FormConstants.MESSAGE_FIELD_MAX_LENGTH_VALIDATION_MESSAGE,
                                },
                            })}
                            height="200px"
                            border="2px solid black"
                            marginBottom="20px"
                        />
                        <Button
                            type="submit"
                            width="100%"
                            backgroundColor="black"
                            color="white"
                        >
                            {FormConstants.SUBMIT_BUTTON_TEXT}
                        </Button>
                    </FormControl>
                </form>
            </Flex>
        </>
    );
};

export default MainContent;
```

```ts:hooks/useLiffInitialization.ts
import { Liff } from '@/types/index';
import { useEffect, useState } from 'react';

// LIFFの初期化に必要なプロパティを定義した型
export interface LiffInitializationProps {
  // LINE Developers Consoleで取得したアプリのLIFF ID
  liffId: string;
}

// LIFFの初期化結果を表す型
export interface LiffInitialization {
  liffObject: Liff | null;
  isLiffError: boolean;
}

const useLiffInitialization = (
  props: LiffInitializationProps
): LiffInitialization => {
  const { liffId } = props;
  const [liffObject, setLiffObject] = useState<Liff | null>(null);
  const [isLiffError, setIsLiffError] = useState<boolean>(false);

  // useEffectを使って初回レンダリング時やliffIdが変更されたときに実行される処理
  useEffect(() => {
    const initializeLiff = async () => {
      try {
        const liffModule = await import('@line/liff').then(
          (module) => module.default
        );
        console.log('LIFF init...');
        await liffModule.init({ liffId: liffId }); // LIFFの初期化を行う
        console.log('LIFF init success.');

        // ログインしていない場合、ログイン処理を実行
        if (!liffModule.isLoggedIn()) {
          console.log('User not yet logged in.');
          liffModule.login();
        }

        await liffModule.ready; // LIFFの準備完了を待つ
        console.log('LIFF ready.');
        setLiffObject(liffModule); // LIFFオブジェクトをReactステートにセット
      } catch (error) {
        console.log('LIFF init failed.');
        setIsLiffError(true);
      }
    };

    initializeLiff(); // 上記の初期化処理を実行
  }, [liffId]); // liffIdが変更されたときに再実行するように指定

  // レンダリング結果としてLIFFオブジェクトとエラーフラグを返す
  return { liffObject, isLiffError };
};

export default useLiffInitialization;
```


```ts:constants/index.ts
export const LIFF_ID = process.env.NEXT_PUBLIC_LIFF_ID || '';
```
```ts:pages/index.tsx
import MainContent from '@/components/Index/MainContent';
import { LIFF_ID } from '@/constants/index';
import useLiffInitialization from '@/hooks/useLiffInitialization';
import { Box, Flex, Text } from '@chakra-ui/react';


const IndexPage = () => {
  const liffId: string = LIFF_ID;
  const { liffObject, isLiffError } = useLiffInitialization({ liffId }); // LIFFの初期化を行うカスタムフックから結果を受け取る

  // もし初期化中にエラーが発生した場合、エラーメッセージを表示
  if (isLiffError) {
    return (
      <Flex align="center" justify="center" height="100vh">
        <Box>
          <Text>エラーが発生しました</Text>
        </Box>
      </Flex>
    );
  }

  // もしLIFFオブジェクトがまだ取得できていない場合、何も表示せずに終了
  if (!liffObject) {
    return null;
  }

  return <MainContent liffObject={liffObject} />;
};

export default IndexPage;
```

```ts:types/index.ts
import liff from '@line/liff';

export type Liff = typeof liff;
```

```ts:.env
NEXT_PUBLIC_LIFF_ID=(作成したLIFF ID)
```
</div></details>

#### LIFF IDのセット
作成したチャネルのLIFF IDを呼び出せるように設定します。

- env ファイルへLIFF_IDを追加
```:.env
NEXT_PUBLIC_LIFF_ID=(作成したLIFF ID)
```
.env ファイル内で NEXT_PUBLIC_LIFF_ID という環境変数に LIFF ID を設定しています。
この設定は、環境変数としてアプリケーション内で使えるようになります。

- NEXT_PUBLIC_LIFF_ID を LIFF_ID として出力
```ts:constants/index.ts
export const LIFF_ID = process.env.NEXT_PUBLIC_LIFF_ID || '';
```
環境変数 NEXT_PUBLIC_LIFF_ID を LIFF_ID としてエクスポートしています。
デフォルト値として空の文字列を使用しています。

#### LIFFの初期化についてのReactフック
このReactフックは、LIFFの初期化を行うための処理を提供します。
以下は、このフックが行っている主な処理です。

##### useLiffInitialization.ts

- 初期化プロパティの型定義

```ts
export interface LiffInitializationProps {
  liffId: string; // LINE Developers Consoleで取得したアプリのLIFF ID
}
```
フックのプロパティとして、LINE Developers Consoleで取得したアプリのLIFF ID が必要です。

- 初期化結果の型定義
```ts
export interface LiffInitialization {
  liffObject: Liff | null; // 初期化されたLIFFオブジェクト
  isLiffError: boolean; // 初期化中にエラーが発生したかどうかを示すフラグ
}
```
フックはLIFFの初期化結果を提供し、初期化されたLIFFオブジェクトとエラーフラグが含まれます。

- Reactフックの実装
```ts
const useLiffInitialization = (
  props: LiffInitializationProps
): LiffInitialization => {
  // ...（省略）

  useEffect(() => {
    const initializeLiff = async () => {
      try {
        // LIFFモジュールの動的インポート
        const liffModule = await import('@line/liff').then(
          (module) => module.default
        );

        // LIFFの初期化
        await liffModule.init({ liffId: liffId });

        // ログインしていない場合、ログイン処理を実行
        if (!liffModule.isLoggedIn()) {
          liffModule.login();
        }

        // LIFFの準備完了を待つ
        await liffModule.ready;

        // LIFFオブジェクトをReactステートにセット
        setLiffObject(liffModule);
      } catch (error) {
        // 初期化中にエラーが発生した場合
        setIsLiffError(true);
      }
    };

    initializeLiff(); // 初期化処理を実行
  }, [liffId]); // liffIdが変更されたときに再実行するように指定

  // レンダリング結果としてLIFFオブジェクトとエラーフラグを返す
  return { liffObject, isLiffError };
};
```
フックはuseEffectを使用して、初回レンダリング時やLIFF IDが変更されたときに LIFF を初期化します。
初期化が成功したら準備完了まで待ち、LIFFオブジェクトをReactステートにセットします。
エラーが発生した場合は、エラーフラグがセットされます。

このフックを使用することで、LIFFを簡単かつ効果的にReactアプリケーションに統合できます。


#### LIFFオブジェクトのセットとメインページの表示について
LIFF（LINE Frontend Framework）の初期化が成功した後に、liffObjectを使用してメインコンテンツを表示するReactページを示しています。

- LIFF IDの取得
```ts
const liffId: string = LIFF_ID;
```
LINE Developers Consoleで取得したアプリのLIFF ID が LIFF_ID として定義され、それを liffId に代入しています。

- LIFFの初期化
```ts
const { liffObject, isLiffError } = useLiffInitialization({ liffId });
```
カスタムフック useLiffInitialization を使用して、LIFFの初期化を行います。
liffObject は初期化されたLIFFオブジェクトで、isLiffError は初期化中にエラーが発生したかどうかを示すフラグです。

- エラーの表示
```ts
if (isLiffError) {
  // エラーメッセージを表示
  return (
    <Flex align="center" justify="center" height="100vh">
      <Box>
        <Text>エラーが発生しました</Text>
      </Box>
    </Flex>
  );
}
```
もし初期化中にエラーが発生した場合、エラーメッセージを表示します。

- LIFFオブジェクトが取得できない場合の処理
```ts
if (!liffObject) {
  // LIFFオブジェクトがまだ取得できていない場合は何も表示せずに終了
  return null;
}
```
もし LIFFオブジェクトがまだ取得できていない場合は、何も表示せずに終了します。

- メインコンテンツの表示
```ts
return <MainContent liffObject={liffObject} />;
```
LIFFオブジェクトが取得できた場合は、MainContent コンポーネントに liffObject を渡してメインのコンテンツを表示します。

### 画面表示
以下の画面が正常に表示されればOKです。

- 作成したチャネルのLIFF URLへ遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/73e65b94-dc9f-890b-5072-fdc4b7d323a7.png)

- ngrokの確認画面が表示されるため、「visit Site」を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/c1423d0d-486f-7303-74d2-ee4cd914dc3f.png)

- LINEログイン画面が表示されているため、ログインします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/c6101837-5919-1c6f-d111-3d134fde80ac.png)

- 作成したチャネルのエンドポイントURLで設定したURLへ遷移されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/3371b74c-59b4-3b80-9f9d-0d05efa79e2b.png)

## さいごに

LIFFは、LINEプラットフォームを活用した柔軟なアプリケーションの開発を可能にするツールです。
このツールを通じて、開発者はLINEユーザーとの対話を強化し、新しい機能やサービスを提供することができます。
機能面については数多くあるため、別の機会で紹介できればと思います。