🗣️

Next.js+Scaledroneでチャットアプリを作る

2023/12/31に公開

はじめに

今回はWebSocketをベースとしたリアルタイムメッセージサービス「Scaledrone」と、
Next.js(React)を使ってチャットアプリを作ってみたいと思います。

Scaledroneのチュートリアルに沿って、以下機能を作っていきます。

  • ログイン中のユーザーを一覧表示します。
  • ユーザー名は、自動で割り当てます。
  • メッセージを入力し、送信します。
  • メッセージをリアルタイムに受信し、表示します。

動作イメージは以下ページで確認できます。
https://scaledrone.github.io/react-chat-tutorial/

【1】Next.jsのアプリを作成する

Scaledroneのチュートリアル用リポジトリをもとに、Next.jsの新規アプリを作成します。

  1. Node.jsをインストールします。
  2. Next.jsをインストールします。
    npm install -g create-next-app
  3. 以下コマンドで新規アプリを作成します。
    npx create-next-app --example https://github.com/ScaleDrone/react-chat-tutorial/tree/starter my-react-chat
  4. 作成したフォルダを、エディタで開きます。
    cd my-react-chat

【2】メッセージ表示コンポーネントを作成する

受信したメッセージを表示するコンポーネントを作成します。

  • メッセージは、送信したユーザーの名前とアイコン色で識別できるようにします。
  • 自分の送信メッセージは画面右側、他ユーザーのメッセージは左側に表示します。
  • メッセージを受信したらuseEffectで、画面下へ自動でスクロールさせます。

Messages.jsの追加

「src/components/Messages.js」ファイルを作成し、以下コードを記述します。

Messages.js
import {useEffect, useRef} from 'react';
import React from 'react';
import styles from '@/styles/Home.module.css'

export default function Messages({messages, me}) {
  const bottomRef = useRef(null);
  useEffect(() => {
    if (bottomRef && bottomRef.current) {
      bottomRef.current.scrollIntoView({behavior: 'smooth'});
    }
  });
  return (
    <ul className={styles.messagesList}>
      {messages.map(m => Message(m, me))}
      <div ref={bottomRef}></div>
    </ul>
  );
}

function Message({member, data, id}, me) {
  const {username, color} = member.clientData;

  const messageFromMe = member.id === me.id;
  const className = messageFromMe ?
    `${styles.messagesMessage} ${styles.currentMember}` : styles.messagesMessage;

  return (
    <li key={id} className={className}>
      <span
        className={styles.avatar}
        style={{backgroundColor: color}}
      />
      <div className={styles.messageContent}>
        <div className={styles.username}>
          {username}
        </div>
        <div className={styles.text}>{data}</div>
      </div>
    </li>
  );
}

index.jsの修正

次に「src/pages/index.js」に、以下処理を追加します。

  • Messages.jsの読み込み処理を追加します。
  • ユーザーにユーザー名とアイコン色を、ランダムで割り当てる処理を追加します。
  • テスト用のメッセージを表示する処理を追加します。
index.js
import Head from 'next/head'
import styles from '@/styles/Home.module.css'
import Script from 'next/script';

import { useState, useEffect, useRef } from 'react';
import Messages from '@/components/Messages'

function randomName() {
  const adjectives = [
    'autumn', 'hidden', 'bitter', 'misty', 'silent', 'empty', 'dry', 'dark',
    'summer', 'icy', 'delicate', 'quiet', 'white', 'cool', 'spring', 'winter',
    'patient', 'twilight', 'dawn', 'crimson', 'wispy', 'weathered', 'blue',
    'billowing', 'broken', 'cold', 'damp', 'falling', 'frosty', 'green', 'long',
    'late', 'lingering', 'bold', 'little', 'morning', 'muddy', 'old', 'red',
    'rough', 'still', 'small', 'sparkling', 'shy', 'wandering',
    'withered', 'wild', 'black', 'young', 'holy', 'solitary', 'fragrant',
    'aged', 'snowy', 'proud', 'floral', 'restless', 'divine', 'polished',
    'ancient', 'purple', 'lively', 'nameless'
  ];
  const nouns = [
    'waterfall', 'river', 'breeze', 'moon', 'rain', 'wind', 'sea', 'morning',
    'snow', 'lake', 'sunset', 'pine', 'shadow', 'leaf', 'dawn', 'glitter',
    'forest', 'hill', 'cloud', 'meadow', 'sun', 'glade', 'bird', 'brook',
    'butterfly', 'bush', 'dew', 'dust', 'field', 'fire', 'flower', 'firefly',
    'feather', 'grass', 'haze', 'mountain', 'night', 'pond', 'darkness',
    'snowflake', 'silence', 'sound', 'sky', 'shape', 'surf', 'thunder',
    'violet', 'water', 'wildflower', 'wave', 'water', 'resonance', 'sun',
    'wood', 'dream', 'cherry', 'tree', 'fog', 'frost', 'voice', 'paper', 'frog',
    'smoke', 'star'
  ];
  const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
  const noun = nouns[Math.floor(Math.random() * nouns.length)];
  return adjective + noun;
}

function randomColor() {
  return '#' + Math.floor(Math.random() * 0xFFFFFF).toString(16);
}

export default function Home() {
  const [messages, setMessages] = useState([{
    id: '1',
    data: 'This is a test message!',
    member: {
      id: '1',
      clientData: {
        color: 'blue',
        username: 'bluemoon',
      },
    },  
  }]);

  const [me, setMe] = useState({
    username: randomName(),
    color: randomColor(),
  });

  return (
    <>
      <Head>
        <title>Scaledrone Chat App</title>
        <meta name='description' content='Your brand-new chat app!' />
        <meta name='viewport' content='width=device-width, initial-scale=1' />
        <link rel='icon' href='/favicon.ico' />
      </Head>
      <main className={styles.app}>
        <div className={styles.appContent}>
          <Messages messages={messages} me={me}/>
        </div>
      </main>
    </>
  )
}

作成したMessageコンポーネントが動作するか、確認してみましょう。
npm run devでアプリを起動し、http://localhost:3000/にアクセスします。


「This is a test message!」と画面に表示されたら、成功です!

【3】メッセージ入力コンポーネントを作成する

メッセージの入力欄と送信ボタンを表示するコンポーネントを作成します。

  • メッセージを入力するテキストエリアを配置します。
  • メッセージ送信用のSendボタンを配置します。
  • preventDefault()でデフォルトのsubmit処理は無効化します。

Input.jsの追加

「src/components/Input.js」ファイルを作成し、以下コードを記述します。

Input.js
import React from 'react';
import { useEffect, useState } from 'react';
import styles from '@/styles/Home.module.css'

export default function Input({onSendMessage}) {
  const [text, setText] = useState('');

  function onChange(e) {
    const text = e.target.value;
    setText(text);
  }

  function onSubmit(e) {
    e.preventDefault();
    setText('');
    onSendMessage(text);
  }
  
  return (
    <div className={styles.input}>
      <form onSubmit={e => onSubmit(e)}>
      <input
          onChange={e => onChange(e)}
          value={text}
          type='text'
          placeholder='Enter your message and press ENTER'
          autoFocus
      />
      <button>Send</button>
      </form>
    </div>
  );
}

index.jsの修正

次に「src/pages/index.js」に、以下処理を追加します。

  • Input.jsの読み込み処理を追加します。
  • メッセージ入力欄の表示処理を追加します。
  • メッセージの送信処理(ローカル用の仮実装)を追加します。

チュートリアルonSendMessage()はclientData参照エラーが発生するため修正しました。
また、onSendMessage()は後で、Scaledroneのサーバーに送信する処理に変更します。

index.js
import Input from '@/components/Input'
  // Home関数内に追加
  function onSendMessage(message) {
    const newMessage = {
      data: message,
      member: {
        clientData: me,
      },
    };
    setMessages([...messages, newMessage]);
  }
  // <Messages>の下に追加
  <Input
    onSendMessage={onSendMessage}
  />

作成したInputコンポーネントが動作するか、確認してみましょう。
メッセージを入力し、送信してみます。


送信メッセージが画面右側に表示されたら、成功です!

【4】オンラインユーザー表示コンポーネントを作成する

チャット画面を開いているユーザーを一覧表示するコンポーネントを作成します。

  • オンラインユーザー数の表示欄を生成します。
  • ユーザー名の一覧表示欄を生成します。
  • 自分のユーザー名の後ろには「(you)」を付加します。

Members.jsの追加

「src/components/Members.js」ファイルを作成し、以下コードを記述します。

Members.js
import React from 'react';
import styles from '@/styles/Home.module.css'

export default function Members({members, me}) {
  return (
    <div className={styles.members}>
      <div className={styles.membersCount}>
        {members.length} user{members.length === 1 ? '' : 's'} online
      </div>
      <div className={styles.membersList}>
        {members.map(m => Member(m, m.id === me.id))}
      </div>
    </div>);
}

function Member({id, clientData}, isMe) {
  const {username, color} = clientData;
  return (
    <div key={id} className={styles.member}>
      <div className={styles.avatar} style={{backgroundColor: color}}/>
      <div className={styles.username}>{username} {isMe ? ' (you)' : ''}</div>
    </div>
  );
}

index.jsの修正

次に「src/pages/index.js」に、以下処理を追加します。

  • Members.jsの読み込み処理を追加します。
  • オンラインユーザーの表示処理を追加します。
  • テスト用ユーザーをオンライン中として表示する処理を追加します。
index.js
import Members from '@/components/Members'
  // Home関数内に追加
  const [members, setMembers] = useState([{
    id: "1",
    clientData: {
      color: 'blue',
      username: 'bluemoon',
    },
  }]);
  // <Messages>の上に追加
  <Members members={members} me={me}/>

作成したMembersコンポーネントが動作するか、確認してみましょう。
チャット画面の上部を確認します。


「1 user online」と、テスト用のユーザー名「bluemoon」が表示されたら、成功です!

【5】Scaledroneのチャンネルを作成する

Scaledroneのアカウントを作成し、メッセージ中継用のチャンネルを作成します。

  1. scaledrone.comにアクセスします。
  2. CREATE ACCOUNT」からアカウントを作成します。
  3. ログインし、Scaledroneのダッシュボードにアクセスします。
  4. 「+Create channel」ボタンをクリックします。
  5. 以下を入力して、チャンネルを作成します。
    • チャンネル名(Choose a name): 任意
    • 認証(Authentication): 不要(Never require authentication)
    • メッセージ履歴(Message history): 無効(Disable message history)
  6. 「Channel overview」画面に表示されるチャンネルIDを、コピーして控えます。

【6】Scaledroneに接続し、メッセージを送信する

Scaledroneのサーバーを介して、オンラインでリアルタイムチャットできるようにします。

  • テスト用に追加していた、ユーザーとメッセージの表示処理を削除します。
  • scaledrone.min.js(ScaledroneのCDN)の読み込み処理を追加します。
  • Scaledroneに接続する処理connectToScaledrone()を追加します。
  • 前の章で取得したチャンネルIDを'YOUR-CHANNEL-ID'に設定します。
    drone = new window.Scaledrone('YOUR-CHANNEL-ID', { ...
  • useRef()で最新のユーザー情報、メッセージ情報を取得する処理を追加します。
  • Scaledroneへ情報が送信されるよう、onSendMessage()を変更します。

index.jsの修正

「src/pages/index.js」を、以下コードに書き換えます。

index.js
import Head from 'next/head'
import styles from '@/styles/Home.module.css'
import Script from 'next/script';

import { useState, useEffect, useRef } from 'react';
import Messages from '@/components/Messages'
import Input from '@/components/Input'
import Members from '@/components/Members'

function randomName() {
  const adjectives = [
    'autumn', 'hidden', 'bitter', 'misty', 'silent', 'empty', 'dry', 'dark',
    'summer', 'icy', 'delicate', 'quiet', 'white', 'cool', 'spring', 'winter',
    'patient', 'twilight', 'dawn', 'crimson', 'wispy', 'weathered', 'blue',
    'billowing', 'broken', 'cold', 'damp', 'falling', 'frosty', 'green', 'long',
    'late', 'lingering', 'bold', 'little', 'morning', 'muddy', 'old', 'red',
    'rough', 'still', 'small', 'sparkling', 'shy', 'wandering',
    'withered', 'wild', 'black', 'young', 'holy', 'solitary', 'fragrant',
    'aged', 'snowy', 'proud', 'floral', 'restless', 'divine', 'polished',
    'ancient', 'purple', 'lively', 'nameless'
  ];
  const nouns = [
    'waterfall', 'river', 'breeze', 'moon', 'rain', 'wind', 'sea', 'morning',
    'snow', 'lake', 'sunset', 'pine', 'shadow', 'leaf', 'dawn', 'glitter',
    'forest', 'hill', 'cloud', 'meadow', 'sun', 'glade', 'bird', 'brook',
    'butterfly', 'bush', 'dew', 'dust', 'field', 'fire', 'flower', 'firefly',
    'feather', 'grass', 'haze', 'mountain', 'night', 'pond', 'darkness',
    'snowflake', 'silence', 'sound', 'sky', 'shape', 'surf', 'thunder',
    'violet', 'water', 'wildflower', 'wave', 'water', 'resonance', 'sun',
    'wood', 'dream', 'cherry', 'tree', 'fog', 'frost', 'voice', 'paper', 'frog',
    'smoke', 'star'
  ];
  const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
  const noun = nouns[Math.floor(Math.random() * nouns.length)];
  return adjective + noun;
}

function randomColor() {
  return '#' + Math.floor(Math.random() * 0xFFFFFF).toString(16);
}

function onSendMessage(message) {
  const newMessage = {
    data: message,
    member: me
  }
  setMessages([...messages, newMessage])
}

let drone = null;

export default function Home() {
  const [messages, setMessages] = useState([]);
  const [members, setMembers] = useState([]);
  const [me, setMe] = useState({
    username: randomName(),
    color: randomColor(),
  });

  const messagesRef = useRef();
  messagesRef.current = messages;
  const membersRef = useRef();
  membersRef.current = members;
  const meRef = useRef();
  meRef.current = me;

  function connectToScaledrone() {
    drone = new window.Scaledrone('YOUR-CHANNEL-ID', {
      data: meRef.current,
    });
    drone.on('open', error => {
      if (error) {
        return console.error(error);
      }
      meRef.current.id = drone.clientId;
      setMe(meRef.current);
    });

    const room = drone.subscribe('observable-room');

    room.on('message', message => {
      const {data, member} = message;
      setMessages([...messagesRef.current, message]);
    });

    room.on('members', members => {
      setMembers(members);
    });

    room.on('member_join', member => {
      setMembers([...membersRef.current, member]);
    });

    room.on('member_leave', ({id}) => {
      const index = membersRef.current.findIndex(m => m.id === id);
      const newMembers = [...membersRef.current];
      newMembers.splice(index, 1);
      setMembers(newMembers);
    });
  }

  useEffect(() => {
    if (drone === null) {
      connectToScaledrone();
    }
  }, []);

  function onSendMessage(message) {
    drone.publish({
      room: 'observable-room',
      message
    });
  }

  return (
    <>
      <Head>
        <title>Scaledrone Chat App</title>
        <meta name='description' content='Your brand-new chat app!' />
        <meta name='viewport' content='width=device-width, initial-scale=1' />
        <script type='text/javascript' src='https://cdn.scaledrone.com/scaledrone.min.js' />
        <link rel='icon' href='/favicon.ico' />
      </Head>
      <main className={styles.app}>
        <div className={styles.appContent}>
          <Members members={members} me={me}/>
          <Messages messages={messages} me={me}/>
          <Input
            onSendMessage={onSendMessage}
          />
        </div>
      </main>
    </>
  )
}

複数のブラウザでhttp://localhost:3000/にアクセスし、
チャットができるか確認してみましょう。


リアルタイムにメッセージが送受信できたら、成功です!!

おわりに

WebSocket用のサーバーを自分で用意せず、簡単にチャットアプリを構築できました🎊

ですが、ユーザー名やアバター画像を任意に設定できるようにしたり、
ファイルを添付できるようにしたり、まだまだ拡張できる要素はありますね。
チュートリアルには、タイピング中の状況を表示する機能の追加手順も紹介されています。

WebSocketによるリアルタイムサービスは、チャット以外での利用も面白そうです!

Scaledroneの価格表

フリープランだと同時接続数が最大20で、1日のイベント数にも制限があります。
詳細は料金プランのページを参照ください。

※2023年12月時点

プラン 月額料金 同時接続数 1日あたりのイベント数 優先サポート
FREE 無料 20 10万 いいえ
HOBBY $19 100 20万 いいえ
STARTUP $29 500 100万 いいえ
PRO $49 2,000 400万 いいえ
BUSINESS $149 5,000 1,000万 はい

追記

関連情報として、JavaScriptでのWebSocket実装例を記載した記事
JavaScript+ScaledroneでWebSocketの同期処理を作る」を公開しました。
https://zenn.dev/collabostyle/articles/8c3cefe439fc5a

コラボスタイル Developers

Discussion