Electron+React.jsで作るチャットアプリ

Electron + React.jsで作るチャットアプリケーション

自分はElectronの初心者です。 Electronで本を購入しチャットアプリを写経して自分の中で理解を深めるためにここに残しておきます。

  • 作成物

チャットアプリケーション

f:id:jesus9387:20171024182312g:plain

Github

github.com

  • 購入した本
  • 感想

Electronについて概要からしっかり学べた

## Electron本体をインストール

mkdir electron_chat

cd electron_chat

  • プロジェクト作成

npm init -y

(npmが使えなかった場合はインストールしてくる)

  • Electronをインストール
npm install electron@1.6.1 --save-dev
  • photonKitのインストール

photonKitとはmacOSネイティブアプリケーションのようなUIを簡単に実装できるFrameworkです。

npm install connors/photon --save-dev
  • Reactのインストール
npm instal react@15.4.2 react-dom@15.4.2 react-router@3.0.0 --save
  • babel関連モジュールのインストール
npm install babel-cli@6.18.0 babel-preset-es2015@6.18.0 babel-preset-react@6.16.0 --save-dev

実装

  • index.js
import { app } from "electron";
import createWindow from "./createWindow";
import setAppMenu from "./setAppMenu"

app.on("ready",() => {
  //アプリが起動したとき
  // 上でimportしたcreateWindowクラスのcreateWindow()メソッドを呼ぶ
  createWindow();
});

app.on("window-all-closed", () => {
  //mac以外の場合はウインドウを閉じた時にアプリを終了する
  if (process.platform !== "darwin") {
    app.quit();
  }
});

app.on("activate", (_e, hasVisibleWindows) => {
  if (!hasVisibleWindows) {
    createWindow();
  }
});

createWindow.js

import { BrowserWindow } from 'electron';

let win;

function createWindow() {
  win = new BrowserWindow();
  //BrowserWindowクラスを使ってHTMLファイルを読み込み画面に表示
  win.loadURL(`file://${__dirname}/../../index.html`);
  win.on("close", () => {
    win = null;
  });
}

export default createWindow;
  • index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Electron Chat</title>
    <link rel="stylesheet" href="node_modules/photon/dist/css/photon.css"/>
  </head>
  <body>
    <div class="window">
      <div id="app" class="window-content"></div>
    </div>
    <script>require("./.tmp/renderer/app.js")</script>
  </body>
</html>
  • app.jsx

Railsでいうrouteみたいなファイル。この中にFirebaseの設定も書き込む

import React from "react";
import {render} from "react-dom";
import { Router, Route, hashHistory } from "react-router";
import Login from "./Login";
import Signup from "./Signup";
import Rooms from "./Rooms";
import Room from "./Room";

import firebase from "firebase/firebase-browser"

// Routingの定義
const appRouting = (
  <Router history={hashHistory}>
    //親のpathから繋げていく
    <Route path="/">
     //例えば "/login"を設定するとLoginクラスが呼ばれる
      <Route path="login" component={Login} />
      <Route path="signup" component={Signup} />
      <Route path="rooms" component={Rooms} >
        <Route path=":roomId" component={Room} />
      </Route>
    </Route>
  </Router>
);


//Routingの初期化
if (!location.hash.length) {
  location.hash = "#/login";
}

  // Firebaseの初期化
  //ここはFirebaseからこぴぺ
  const config = {
    apiKey: "*******************",
    authDomain: "*******************",
    databaseURL: "*******************",
    projectId: "*******************",
    storageBucket: "",
    messagingSenderId: "*******************"
  };
  firebase.initializeApp(config);


// Applicationの描画
render(appRouting, document.getElementById("app"));
  • .babellc

Reactをjavascriptに変換するためのファイル

{
  "preset": ["es2015,"react]
}
  • Login.js

ログイン画面の実装

import React from "react";
import { Link, hashHistory } from "react-router";
import Errors from "./Errors";
import firebase from "firebase/firebase-browser";

const FORM_STYLE = {
  margin:"0 auto",
  padding:30
};

const SIGNUP_LINK_STYLE = {
  display:"inline-block",
  marginLeft: 10
};

export default class Login extends React.Component {

  constructor(props) {
    super(props);

    //props:親から渡される情報
    //state:自Componentの状態
    this.state = {
      email: localStorage.userEmail || "",
      password: localStorage.userPassword || "",
      errors:[],
    };

    //それぞれのメソッドをbindする
    //bindすることでフロントの状態変化を検知できる
    this.handleOnChangeEmail = this.handleOnChangeEmail.bind(this);
    this.handleOnChangePassword = this.handleOnChangePassword.bind(this);
    this.handleOnSubmit = this.handleOnSubmit.bind(this);
  }

  // 下のinputの状態が変化したら呼ばれる
  handleOnChangeEmail(e){
    this.setState({ email: e.target.value});
  }

  // 下のinputの状態が変化したら呼ばれる
  handleOnChangePassword(e){
    this.setState({ password: e.target.value });
  }

  //ログイン処理
  handleOnSubmit(e){
    // 二つのinputタグの状態を取得する
    const { email, password } = this.state;
    const errors = [];
    let isValid = true;
    e.preventDefault();

    if (!email.length) {
      //emailに何もデータが入っていない場合
      isValid = false;
      errors.push("Email cannot blank.");
    }

    if (!password.length) {
      //passwordに何もデータが入っていない場合
      isValid = false;
      errors.push("Password cannot be blank.");
    }

    if (!isValid) {
      //必須入力チェックに該当した場合はエラーを表示する
      this.setState({ errors });
      return;
    }

    //Firebaseのログイン処理
    //この辺はリファレンスを見る
    firebase.auth().signInWithEmailAndPassword(email,password).then(() => {
      //次回ログインし簡略化のため、localStorageに値を保存
      localStorage.userEmail = email;
      localStorage.userPassword = password;
      //チャットルーム一覧画面へ遷移
      hashHistory.push("/rooms");
    }).catch(() => {
      // Firebaseでログインエラーになった場合
      this.setState({errors: ["Incorrect email or password."] })
    });
  }

  //フロントのレンダリング
  render(){
    return (
      <form style={FORM_STYLE} onSubmit={this.handleOnSubmit}>
        <Errors errorMessages={this.state.errors} />
        <div className="form-group">
          <label>Email address</label>
          <input
            type="email"
            className="form-control"
            placeholder="email"
            onChange={this.handleOnChangeEmail}
            value={this.state.email}
          />
        </div>
        <div className="form-group">
          <label>Password</label>
          <input
            type="password"
            className="form-control"
            placeholder="password"
            onChange={this.handleOnChangePassword}
            value={this.state.password}
          />
        </div>
        <div className="form-group">
          <button className="btn btn-large btn-default">Login</button>
          <div style={SIGNUP_LINK_STYLE}>
            <Link to="/signup">create new account</Link>
          </div>
        </div>
      </form>
    );
  }
}

  • signup.js

サインアップページ

import React from "react";
import { Link, hashHistory } from "react-router";
import Errors from "./Errors";
import firebase from "firebase/firebase-browser";

const SIGNUP_FORM_STYLE = {
  margin: "0 auto",
  padding: 30
};

const CANCEL_BUTTON_STYLE = {
  marginLeft: 10
};

export default class Signup extends React.Component{

  constructor(props){
    super(props);
    //状態の初期化
    this.state = {
      email:"",
      password:"",
      name:"",
      photoURL:"",
      errors:[]
    };

    //各種bindする
    this.handleOnChangeEmail = this.handleOnChangeEmail.bind(this);
    this.handleOnChangePassword = this.handleOnChangePassword.bind(this);
    this.handleOnChangeName = this.handleOnChangeName.bind(this);
    this.handleOnChangePhotoURL = this.handleOnChangePhotoURL.bind(this);
    this.handleOnChangeSubmit = this.handleOnChangeSubmit.bind(this);
  }

  handleOnChangeEmail(e){
    this.setState({ email: e.target.value});
  }

  handleOnChangePassword(e){
    this.setState({ password:e.target.value });
  }

  handleOnChangeName(e){
    this.setState({ name: e.target.value});
  }

  handleOnChangePhotoURL(e){
    this.setState({photoURL: e.target.value });
  }

  handleOnChangeSubmit(e){
    const {email, password, name,photoURL } = this.state;
    const errors = [];
    let isValid = true;
    e.preventDefault();

    if (!email.length) {
      isValid = false;
      errors.push("Email address cannt be blank.");
    }

    if (!password.length) {
      isValid = false;
      errors.push("Password cannot be blank");
    }

    if (!name.length) {
      isValid = false;
      errors.push("Name cannot be blank.");
    }

    if (!isValid) {
      this.setState({errors});
      return;
    }

    // Firebaseの新規アカウント作成処理
    firebase.auth().createUserWithEmailAndPassword(email,password).then(newUser => {
      //ユーザー情報を更新する
      return newUser.updateProfile({
        displayName:name,photoURL
      });
    }).then(() => {
      //チャットルーム一覧画面へ遷移
      hashHistory.push("/rooms");
    }).catch(err => {
      // Firebaseでエラーになった場合
      this.setState({errors: [err.message] })
    });
  }

  render(){
    return (
      <form style={SIGNUP_FORM_STYLE} onSubmit={this.handleOnChangeSubmit}>
        <Errors errorMessages={this.state.errors} />
        <div className="form-group">
          <label>Email address *</label>
          <input
            type="email"
            className="form-control"
            placeholder="email"
            value={this.state.email}
            onChange={this.handleOnChangeEmail}
          />
        </div>
        <div className="form-group">
          <label>Password *</label>
          <input
            type="password"
            className="form-control"
            placeholder="password"
            value={this.state.password}
            onChange={this.handleOnChangePassword}
          />
        </div>
        <div className="form-group">
          <label>User name *</label>
          <input
            type="text"
            className="form-control"
            placeholder="user_name"
            value={this.state.name}
            onChange={this.handleOnChangeName}
          />
        </div>
        <div className="form-group">
          <label>Photo URL</label>
          <input
            type="text"
            className="form-control"
            placeholder="photo url"
            value={this.state.photoURL}
            onChange={this.handleOnChangePhotoURL}
          />
        </div>
        <div className="form-group">
          <button className="btn btn-large btn-primary">Create new account</button>
          <Link to="/login">
            <button
              type="button"
              style={CANCEL_BUTTON_STYLE}
              className="btn btn-large btn-default"
              >
              cencel
              </button>
          </Link>
        </div>
      </form>
    );
  }
}

  • Rooms.jsx

左ペインにRoom一覧を表示し、右ペインにはチャットルームを表示する

import React from "react";
import { hashHistory } from "react-router";
import RoomItem from "./RoomItem";
import firebase from "firebase/firebase-browser";

const ICON_CHAT_STYLE = {
  fontSize: 120,
  color:"#DDD"
};

const FORM_STYLE = {
  display: "flex"
};

const BUTTON_STYLE = {
  marginLeft: 10
};

export default class Rooms extends React.Component {
  constructor(props){
    super(props);
    this.state = {
      roomName: "",
      rooms:[]
    };

    // Firebaseデータベースを初期化
    this.db = firebase.database();
    this.handleOnChangeRoomName = this.handleOnChangeRoomName.bind(this);
    this.handleOnSubmit = this.handleOnSubmit.bind(this);
  }

  componentDidMount(){
    //コンポーメントの初期化時にチャットルーム一覧を取得する
    this.fetchRooms();
  }

  handleOnChangeRoomName(e){
    this.setState({
      roomName: e.target.value
    });
  }

  handleOnSubmit(e){
    const { roomName } = this.state;
    e.preventDefault();
    if (!roomName.length) {
      return;
    }

    //Firebaseデータベースに新規チャットルームのデータ作成
    const newRoomRef = this.db.ref("/chatrooms").push();
    const newRoom = {
      description: roomName
    };

    //作成したチャットルームのdescriptionを更新する
    newRoomRef.update(newRoom).then(() => {
      //状態を再初期化する
      this.setState({ roomName:"" })
      //チャットルーム一覧を再取得
      return this.fetchRooms().then(() => {
        //右ペインを作成した詳細画面に遷移させる
        hashHistory.push(`/rooms/${newRoomRef.key}`);
      });
    });
  }

  //チャットルーム一覧の取得
  fetchRooms(){
    //Firebaseデータベースからチャットルームを20件取得
    return this.db.ref("/chatrooms").limitToLast(20).once("value").then(snapshot => {
      const rooms = [];
      snapshot.forEach(item => {
        //データベースから取得したデータオブジェクトとして取り出す
        rooms.push(Object.assign({key:item.key}, item.val()));
      });
      //取得したオブジェクトの配列をコンポーメントのstateにセット
      this.setState({rooms});
    });
  }

  //左ペイン(チャットルーム一覧)の描写処理
  renderRoomList(){
    const { roomId } = this.props.params;
    const { rooms, roomName } = this.state;
    return (
      <div className="list-group">
        //ルーム一覧をfetchRoomsから取得したデータ分だけ表示する
        //selectしたらRoomItemにroomIdを設定する
        // RoomItemタグを使っているといことはRoomItemクラスを呼んでいる
        {rooms.map(r => <RoomItem room={r} key={r.key} selected={r.key === roomId} /> )}
        //新しいチャットルームを作成するためのinputエリア
        <div className="list-group-header">
          <form style={FORM_STYLE} onSubmit={this.handleOnSubmit}>
            <input
             type="text"
             className="form-control"
             placeholder="New room"
             onChange={this.handleOnChangeRoomName}
             value={roomName}
             />
             <button className="btn btn-default" style={BUTTON_STYLE}>
                <span className="icon icon-plus" />
             </button>
          </form>
        </div>
      </div>
    );
  }

  //右ペイン(チャットルーム詳細)の描写処理
  renderRoom(){
    if (this.props.children) {
      // propsにデータが設定されている = Roomが設定されている
      return this.props.children;
    } else {
      return (
        <div className="text-center">
          <div style={ICON_CHAT_STYLE}>
            <span className="icon icon-chat" />
          </div>
          <p>
            Join a chat room from the sidebar or create your chat room.
          </p>
        </div>
      );
    }
  }

  render(){
    return (
      <div className="pane-group">
        //左ペイン
        <div className="pane-sm sidebar">{this.renderRoomList()}</div>
        //右ペイン
        <div className="pane">{this.renderRoom()}</div>
      </div>
    );
  }
}
  • RoomItem.jsx

Room一覧ののひとつひとつのアイテム

import React from "react"
import { Link } from "react-router"


const LINK_STYLE = {
  color:"inherit",
  textDecoration:"none"
};

export default function RoomItem(props) {
  //propsからアイテムひとつひとつを設定する
  const { selected } = props;
  const { description, key } = props.room;
  return (
    <div className={selected ? "list-group-item selected":'list-group-item'}>
      //`/rooms/${key}`の設定でroom.jsxが呼ばれて設定される
      <Link to={`/rooms/${key}`} style={LINK_STYLE}>
        <div className="media-body">
          <strong>{description}</strong>
        </div>
      </Link>
    </div>
  );
}
  • Room.jsx

選択されているチャットルームでチャット処理を実装する

import React from "react";
import Message from "./Message";
import NewMessage from "./NewMessage";
import firebase from "firebase/firebase-browser";

const ROOM_STYLE = {
  padding: "10px 30px"
};

export default class Room extends React.Component {

  constructor(props){
    super(props);
    this.state = {
      description: "",
      messages:[],
    };

    this.db = firebase.database();
    this.handleOnMessagePost = this.handleOnMessagePost.bind(this);
  }

  componentDidMount(){
    const { roomId } = this.props.params;
    //コンポーメントの初期化時にチャットルームの詳細情報を取得する
    this.fetchRoom(roomId);
  }

  componentWillReceiveProps(nextProps){
    const { roomId } = nextProps.params;
    if (roomId === this.props.params.roomId) {
      //チャットルームのIDいん変更がなければなにもしない
      return;
    }

    if (this.stream) {
      //メッセージの監視解除
      this.stream.off();
    }

    // stateの再初期化
    this.setState({ messages:[] });
    //チャットルーム詳細の再取得
    this.fetchRoom(roomId);
  }

  componentDidUpdate(){
    setTimeout(() => {
      //画面下端へスクロール
      this.room.parentNode.scrollTop = this.room.parentNode.scrollHight;
    }, 0);
  }

  componentWillUnmount(){
    if (this.stream) {
      //メッセージ監視を解除
      this.stream.off();
    }
  }

  //メッセージ投稿処理
  handleOnMessagePost(message){
    const newItemRef = this.fbChatRoomRef.child("messages").push();
    ///Firebaseにログインしているユーザーを投稿ユーザーとして利用
    this.user = this.user || firebase.auth().currentUser;
    return newItemRef.update({
      writtenBy:{
        uid:this.user.uid,
        displayName: this.user.displayName,
        photoURL:this.user.photoURL,
      },
      time:Date.now(),
      text:message,
    });
  }

  fetchRoom(roomId){
    //Firebaseデータベースからチャットルーム詳細データの参照を取得
    this.fbChatRoomRef = this.db.ref("/chatrooms/" + roomId);
    this.fbChatRoomRef.once("value").then(snapshot => {
      const { description } = snapshot.val();
      this.setState({ description });
      window.document.title = description;
    });

    this.stream = this.fbChatRoomRef.child("messages").limitToLast(10);
    //チャットルームのメッサージ追加を監視
    this.stream.on("child_added",item => {
      const { messages } = this.state || [];
      // 追加されたメッセージをstateにセット
      messages.push(Object.assign({key:item.key},item.val()));
      this.setState({ messages });
    });
  }

  render(){
    const { messages } = this.state;
    return (
      <div style={ROOM_STYLE} ref={room => this.room = room} >
        <div className="list-group">
          //fetchRoomで取得したデータ分だけ画面に表示
          {messages.map(m => <Message key={m.key} message={m} />)}
        </div>
        <NewMessage onMessagePost={this.handleOnMessagePost} />
      </div>
    );
  }
}
  • Message.jsx

1メッセージのオブジェクト

import React from "react";
import Avatar from "./Avatar";

const MEDIA_BODY_STYLE = {
  color: "#888",
  fontSize:".9em"
};

const TIME_STYLE = {
  marginLeft: 5
};

const TEXT_STYLE = {
  whiteSpace:"normal",
  wordBreak:"break-word"
};

export default function Message(props){
  const { text, time, writtenBy } = props.message;
  const localeString = new Date(time).toLocaleString();
  return (
    <div className="list-group-item">
      <div className="media-object pull-left">
        <Avatar user={writtenBy} />
      </div>
      <div className="media-body">
        <div style={MEDIA_BODY_STYLE}>
          <span>{writtenBy.displayName}</span>
          <span style={TIME_STYLE}>{localeString}</span>
        </div>
        <p style={TEXT_STYLE}> {text} </p>
      </div>
    </div>
  );
}
  • NewMessage.jsx

投稿するinputタグ

import React from "react"

const FORM_STYLE = {
  display: "flex"
};

const BUTTON_STYLE = {
  marginLeft: 10
};

export default class NewMessage extends React.Component{
  constructor(props){
    super(props);
    this.state = {message: ""};
    this.handleOnChange = this.handleOnChange.bind(this);
    this.handleOnSubmit = this.handleOnSubmit.bind(this);
  }

  handleOnChange(e){
    this.setState({message:e.target.value});
  }

  handleOnSubmit(e){
    const { onMessagePost } = this.props;
    if (!onMessagePost || !this.state.message.length) {
      return;
    }
    onMessagePost(this.state.message);
    this.state({ message: ""});
    e.preventDefault();
  }

  render(){
    return(
      <form style={FORM_STYLE} onSubmit={this.handleOnSubmit}>
        <input
          type="text"
          className="form-control"
          onChange={this.handleOnChange}
          value={this.state.message}
          />
          <button className="btn btn-large btn-primary" style={BUTTON_STYLE}>POST</button>
      </form>
    );
  }

}
import React from "react"

const AVATAR_STYLE = {
  width:32,
  textAlign:"center",
  fontSize:24
};


export default function Avatar(props){
  const { photoURL } = props.user;
  if(photoURL){
    // photoURLURLが設定されている場合img要素を表示
    return <img className="img-rounded" src={photoURL} style={AVATAR_STYLE} />;
  } else {
    //photoURLが設定されていない場合代替えとしてiconを表示
    return (
      <div style={AVATAR_STYLE}>
        <span className="icon icon-user" />
      </div>
    );
  }
}

実行

./node_modules/.bin/babel --out-dir tmp src
  • 実行
./node_modules/.bin/electron .

すると、実行できる

最後に

ただのメモ書きになってしましたが、 この本を勉強してかなり理解が深まったのでおすすめです。