(追記)2.0に対応しました。(2016/09/15)
今回のGitHubリポジトリ→ovrmrw/friendlychat-ng2
先日Firebaseの公式サイトでWebアプリのチュートリアルを発見しまして、これをやってみたらDatabaseだけじゃなくAuthとStorageの使い方の勉強にもなりました。これはWebアプリ初心者なら一度はやっておくべきチュートリアルだなと思ったのですが、JavaScriptファイルがVanillaJSで書かれていて個人的に解釈するのがとても大変でした。
そこで「これはよろしくない」と思い、多くの人が容易に理解可能なAngular2スタイルに書き直さなければならないと思い立ちまして、今回のエントリーに至った次第です。
Firebase Hostingにもデプロイしてありますのでよろしければどうぞ。
大体オリジナルをコピーできているかなと思います。(完全ではない)
Firebaseを1ファイルでコントロールする
Firebaseを直接叩いているのは下記のコードです。ComponentはServiceを経由してここにアクセスします。
ちなみにオリジナルのJSコードはこちら→main.js
オリジナルを時にはほぼ忠実に、時には適当に書き換えて作りました。
import firebase from 'firebase';
import { Observable, Subject, BehaviorSubject, ReplaySubject } from 'rxjs/Rx';
import lodash from 'lodash';
import { MessageType } from '../types';
const config = {
apiKey: 'AIzaSyDUGgHesSALxeaKYH_qyt-NxPndMgD6MVI',
authDomain: 'friendlychat-d014b.firebaseapp.com',
databaseURL: 'https://friendlychat-d014b.firebaseio.com',
storageBucket: 'friendlychat-d014b.appspot.com',
};
const LOADING_IMAGE_URL = 'https://www.google.com/images/spin-32.gif';
export class FirebaseController {
private auth: firebase.auth.Auth;
private database: firebase.database.Database;
private storage: firebase.storage.Storage;
private messagesRef: firebase.database.Reference;
private authSubject$: Subject<firebase.User | null>;
private messages: MessageType[] = [];
private messagesSubject$: Subject<MessageType[]>;
private informStableSubject$: Subject<boolean>;
constructor() {
firebase.initializeApp(config);
this.authSubject$ = new BehaviorSubject<firebase.User | null>(null);
this.messagesSubject$ = new ReplaySubject<MessageType[]>();
this.informStableSubject$ = new ReplaySubject<boolean>();
this.auth = firebase.auth();
this.database = firebase.database();
this.storage = firebase.storage();
// サインインかサインアウトをするとその都度この処理が走る。
this.auth.onAuthStateChanged((user: firebase.User) => {
if (user) { // User is signed in!
this.authSubject$.next(user);
} else { // User is signed out!
this.authSubject$.next(null);
}
});
this.messagesRef = this.database.ref('messages');
}
signIn() {
// Sign in Firebase using popup auth and Google as the identity provider.
const provider = new firebase.auth.GoogleAuthProvider();
this.auth.signInWithPopup(provider);
}
signOut() {
// Sign out of Firebase.
this.auth.signOut();
}
saveMessage(message: string) {
if (message) {
const currentUser = this.auth.currentUser;
if (currentUser) {
// Add a new message entry to the Firebase Database.
this.messagesRef.push({
name: currentUser.displayName,
text: message,
photoUrl: currentUser.photoURL || '/images/profile_placeholder.png'
}).then(() => {
this.informStableSubject$.next(true);
}).catch((error) => {
console.error('Error writing new message to Firebase Database', error);
});
}
}
}
saveImageMessage(file: File) {
if (file) {
// We add a message with a loading icon that will get updated with the shared image.
const currentUser = this.auth.currentUser;
if (currentUser) {
this.messagesRef.push({
name: currentUser.displayName,
imageUrl: LOADING_IMAGE_URL,
photoUrl: currentUser.photoURL || '/images/profile_placeholder.png'
}).then((data) => {
// Upload the image to Firebase Storage.
const uploadTask = this.storage.ref(currentUser.uid + '/' + Date.now() + '/' + file.name)
// .put(file, { 'contentType': file.type });
.put(file);
// Listen for upload completion.
uploadTask.on('state_changed', null, (error) => {
console.error('There was an error uploading a file to Firebase Storage:', error);
}, () => {
// Get the file's Storage URI and update the chat message placeholder.
const filePath = uploadTask.snapshot.metadata.fullPath;
data.update({ imageUrl: this.storage.ref(filePath).toString() });
});
});
}
}
}
setImageUrl(imageUrl: string): Observable<string> {
if (!imageUrl.startsWith('gs://')) { // 変数imageUrlがFirebase Storageのものではない場合、そのまま返す。
return Observable.of(imageUrl);
}
// If the image is a Firebase Storage URI we fetch the URL.
const subject = new BehaviorSubject<string>(LOADING_IMAGE_URL); // とりあえずグルグルを表示させる。
this.storage.refFromURL(imageUrl).getMetadata().then((metadata) => {
subject.next(metadata.downloadURLs[0]); // imageファイルの本来のURLを取得したら差し替える。
subject.complete(); // 適切にcompleteしないとメモリリークの原因になる。
});
return subject.asObservable();
}
loadMessages() {
this.messages = [];
// Make sure we remove all previous listeners.
this.messagesRef.off();
// Loads the last 12 messages and listen for new ones.
this.messagesRef.limitToLast(12).on('child_added', (snapshot: firebase.database.DataSnapshot) => {
const message = lodash.defaultsDeep(snapshot.val(), { key: snapshot.key }) as MessageType;
this.messages.push(message);
this.messagesSubject$.next(this.messages);
this.informStableSubject$.next(true);
});
this.messagesRef.limitToLast(12).on('child_changed', (snapshot: firebase.database.DataSnapshot) => {
const message = lodash.defaultsDeep(snapshot.val(), { key: snapshot.key }) as MessageType;
this.messages = lodash.reject(this.messages, { key: snapshot.key });
this.messages.push(message);
this.messagesSubject$.next(this.messages);
this.informStableSubject$.next(true);
});
}
get currentUser$() { return this.authSubject$.asObservable(); }
get messages$() { return this.messagesSubject$.asObservable(); }
get informStable$() { return this.informStableSubject$.asObservable(); }
}
サインイン、サインアウトの状態をrxjsで制御する
当然のことながらサインイン、サインアウトをしたときにアプリはその状態を反映させなければなりません。
このサンプルでは、rxjsのSubjectを使って
FirebaseController → AppService → AppComponent
の順でcurrentUser$
ストリームを流しています。
AppComponentではrxjsのSubscriptionがメモリリークを起こさないようにngOnDestroy()
でdisposeしています。この辺りはrxjsを扱うときの基本とも言えます。
// 抜粋
@Injectable()
export class AppService {
constructor(
private fc: FirebaseController
) { }
get currentUser$() { return this.fc.currentUser$; }
}
// 抜粋
ngOnInit() {
this.disposable = this.service.currentUser$.subscribe(user => {
if (user) { // Firebaseからサインインの状態を受け取った。
this.isAuthed = true;
} else { // Firebaseからサインアウトの状態を受け取った。
this.isAuthed = false;
}
this.cd.markForCheck();
});
}
ngOnDestroy() {
this.disposeSubscriptions(); // Subscriptionをまとめて破棄する。適切なメモリ管理はrxjsの基本。
}
またAppComponentはAngular2のtemplateを通じて子Componentに isAuthed
の値を渡しています。
<!-- 抜粋 -->
<chat-header [isAuthed]="isAuthed"></chat-header>
<chat-main [isAuthed]="isAuthed"></chat-main>
こうやってコンポーネントツリー全体にサインイン、サインアウトの状態を通知しているわけですね。
SnackBarをrxjsで制御する
Firebaseチュートリアルで使われるCSSフレームワークはMaterial Design Liteというもので、その機能の一つとして SnackBarというものがあります。Toastrみたいな通知機能です。
今回のサンプルではこれもrxjsのSubjectで制御しています。
// 抜粋
snackbarText$ = new Subject<string>();
onSubmitMessage() {
if (this.isAuthed) { // サインインしている。
this.service.send(this.text);
} else { // サインインしていない。
this.snackbarText$.next('You must sign-in first');
}
}
onSelectImageFile(event) { // ファイルを選択した。
if (event.target && event.target.files && event.target.files.length) {
const file = event.target.files[0] as File;
console.log(file);
if (file.type.match('image.*')) { // imageファイルを選択した。
this.service.saveImage(file);
} else { // imageファイルではないものを選択した。
this.snackbarText$.next('You can only share images');
this.resetForm();
}
}
}
上記コードの中で snackbarText$.next(...)
と書いてある部分がそうなのですが、表示させたい文字列を渡すとtemplateを通じてChatSnackbarComponentのsnackbarText
(Observable)のsubscribe()
に流れます。
その結果next()
する度にSnackBarが画面に表示される、というわけです。
<!-- 抜粋 -->
<chat-snackbar [snackbarText]="snackbarText$"></chat-snackbar>
// 抜粋
export class ChatSnackbarComponent extends ParentComponent implements OnInit, OnDestroy {
@Input() snackbarText: Observable<string>; // ChatMainComponentから.next()で新しい値を送り込む。
constructor(
private cd: ChangeDetectorRef,
private el: ElementRef
) {
super();
}
ngOnInit() {
this.disposable = this.snackbarText.subscribe(text => {
if (text) {
const data = {
message: text,
timeout: 2000
};
const element = (<HTMLElement>this.el.nativeElement).querySelector('#must-signin-snackbar') as any;
element.MaterialSnackbar.showSnackbar(data); // SnackBarを表示する。
this.cd.markForCheck();
}
});
}
ngOnDestroy() {
this.disposeSubscriptions();
}
}
画像のURLを時間差で差し替える(rxjsで)
実際の動きを見てもらえればわかりますが、画像ファイルを表示する場合はまず最初にグルグルを表示させて、その後Firebase Storageから本来のURLを取得次第差し替えをしています。これもrxjsのSubjectです。
下記はFirebaseControllerのコード。
// 抜粋
setImageUrl(imageUrl: string): Observable<string> {
if (!imageUrl.startsWith('gs://')) { // 変数imageUrlがFirebase Storageのものではない場合、そのまま返す。
return Observable.of(imageUrl);
}
// If the image is a Firebase Storage URI we fetch the URL.
const subject = new BehaviorSubject<string>(LOADING_IMAGE_URL); // とりあえずグルグルを表示させる。
this.storage.refFromURL(imageUrl).getMetadata().then((metadata) => {
subject.next(metadata.downloadURLs[0]); // imageファイルの本来のURLを取得したら差し替える。
subject.complete(); // 適切にcompleteしないとメモリリークの原因になる。
});
return subject.asObservable();
}
これをSerivceを通じてChatMessageComponentが受け取ります。
// 抜粋
export class ChatMessageService {
constructor(
private fc: FirebaseController
) { }
resolveImageSrc(imageUrl: string) {
return this.fc.setImageUrl(imageUrl);
}
}
// 抜粋
export class ChatMessageComponent implements OnChanges {
@Input() imageUrl: string;
imageSrc: Observable<string>;
constructor(
private service: ChatMessageService,
private cd: ChangeDetectorRef
) { }
ngOnChanges() {
if (this.imageUrl) {
this.imageSrc = this.service.resolveImageSrc(this.imageUrl); // 時間差でimageファイルのURLを差し替える。
}
}
}
そしてこのimageSrc
をtemplateにAsyncPipe付きで渡しています。こうすることで時間差でimg
タグのsrc
アトリビュートを書き換えることができます。
<!-- 抜粋 -->
<div *ngIf="imageUrl" class="message"><img (load)="onLoadImage()" [src]="imageSrc | async"></div>
File APIを使って画像ファイルを選択する
JavaScriptには元々File APIというものがあり、ローカルのファイルを選択することができます。(その後Firebase Storageにアップロードする)
この流れはまずtemplateから始まり、最終的にFirebaseControllerのsaveImageMessage()
関数に到達します。
-
button
タグをクリックして(click)="mediaCapture.click()"
が発火することによりファイル選択ウインドウを表示させる。(input
タグに#mediaCapture
を書いているのでbutton
タグ内でinput
タグを参照できる) - 画像ファイルを選択する。
-
input
タグの(change)="onSelectImageFile($event)"
が発火。 - Componentの
onSelectImageFile()
関数を実行。 - Serviceの
saveImage()
関数を実行。 - FirebaseControllerの
saveImageMessage()
関数を実行。
<!-- 抜粋 -->
<form id="image-form" action="#">
<input id="mediaCapture" #mediaCapture (change)="onSelectImageFile($event)" type="file" accept="image/*,capture=camera">
<button id="submitImage" (click)="mediaCapture.click()" title="Add an image" class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-color--amber-400 mdl-color-text--white">
<i class="material-icons">image</i>
</button>
</form>
// 抜粋
onSelectImageFile(event) { // ファイルを選択した。
if (event.target && event.target.files && event.target.files.length) {
const file = event.target.files[0] as File;
console.log(file);
if (file.type.match('image.*')) { // imageファイルを選択した。
this.service.saveImage(file);
} else { // imageファイルではないものを選択した。
this.snackbarText$.next('You can only share images');
this.resetForm();
}
}
}
// 抜粋
export class ChatMainService {
constructor(
private fc: FirebaseController
) { }
saveImage(file: File) {
this.fc.saveImageMessage(file);
}
}
// 抜粋
saveImageMessage(file: File) {
if (file) {
// We add a message with a loading icon that will get updated with the shared image.
// 省略
}
}
最後に
とりあえずパッと思い付いた解説はこんなところです。後はソースコードを読んでください。(手抜き)
今回VanillaJSで書かれたコードをAngular2に書き直してみてすごく勉強になりました。
僕のようにWebアプリを書いたことがない初心者には特にオススメなので、是非公式チュートリアルをやってみてから、自分流にリメイクしてみてください。