Kosaku Kurino

Kosaku Kurino

【React Native】インスタグラム的なアプリを1日でサクッと実装してみた

はじめに

サーバー周りはFirebase、インターフェースはexpo(React Native)でアプリケーションを作ります。Firebase、React Nativeの基本的な知識は知っている前提で進めます。

capture

今回作るアプリのコアな機能

Facebook認証を利用したログイン機能 写真と文言をセットにした投稿機能 ユーザープロフィール編集機能

いいねとフォロー機能は今回実装しません。

Firebaseの設定

SNS認証について

別記事で解説してます。そちらを参照してください。 【React Native】ExpoでFirebase AuthenticationのSNS認証を利用する方法

データの保存先について

投稿内容とプロフィールのデータは基本的にFirestoreに保存する。例外としてプロフィールと投稿内容の画像ファイルはstorageに保存し、Firestoreに対応するURLを保存させる。

Firestoreの設定

UserFeedのコレクションを作る。 Userはユーザーごとにドキュメントが存在し、avatar(アバター画像のURL)name(ニックネーム)を保存させる。 Feedには投稿ごとにドキュメントが存在し、message(メッセージ)image(画像のURL)created_at(投稿日時)updated_at(更新日時)writer(投稿者)を保存させる。

本来であれば、Userにもcreated_atupdated_atをつけるべきだが今回は省略する。

それではFirestoreにルールを設定していきます。 閲覧権限に制限は授けていませんが、編集・投稿はユーザー固有のuidで制限をかけています。 これで、自分自身のプロフィールと自分自身が投稿したフィードのみ投稿・編集可能にできます

service cloud.firestore {
  match /databases/{database}/documents {
    match /User/{userID} {
        allow read;
      allow create, update: if request.auth.uid == userID;
    }
    match /Feed/{feedID} {
        allow read;
      allow create, update: if request.resource.data.writer == request.auth.uid;
    }
  }
}
Storageの設定

構造はUser/{userID}/Avatar/main.pngにプロフィール画像をUser/{userID}/Feed/{feedID}/main.pngに投稿内容の画像を保存する。

それではStorageにルールを設定していきます。 閲覧権限に制限は授けていませんが、画像保存はユーザー固有のuidで制限をかけています。 これで、自分自身のプロフィール画像と自分自身が投稿した内容の画像のみ保存可能にできます

service firebase.storage {
  match /b/{bucket}/o {
    match /User/{userID} {
      match /Avatar/main.png {
        allow read;
        allow write: if userID == request.auth.uid;
      }
      
      match /Feed/{novelID} {
        match /main.png {
            allow read;
          allow write: if userID == request.auth.uid;
        }
      }
    }
  }
}

ユーザー初回ログイン時の処理について

SNS認証でログインしたユーザーが初回ログインの場合、Firestoreにユーザーデータと自動で追加させるようにします。 Functionsでこれは実装します。

関数の発火トリガーをcreateAccountDocにすることで、初回ログイン時のみ実行させる関数ができます。

const admin = require('firebase-admin');
const functions = require('firebase-functions');

admin.initializeApp(functions.config().firebase)

exports.createAccountDoc = functions.auth.user().onCreate( async (user) => {
  const db = admin.firestore();
  const batch = db.batch();

  const userCollection = db.collection('User');
  const userRef = userCollection.doc(user.uid);

  try{
    await batch.set(userRef, { name: '未設定' });

    await batch.commit().then(() => {
    console.log('add user success.');
    })
  }
  catch(e) {
    console.log(`error occurs: ${e}`);
  }
});

これでサーバー周りの準備は終わりです。自分で実装するとかなり時間かかるし、セキュリティー設定はめんどくさいので小規模だったりプロト開発だったらFirebase使っとけばいい気がしてる。

アプリケーションの作成

準備

expo cliでアプリケーションを作成します。

npm install -g expo-cli
expo init instaApp
cd instaApp
npm install

使用するライブラリをインストールします。

npm install redux react-redux redux-actions redux-saga redux-logger --save
npm install native-base react-navigation --save
npm isntall firebase axios moment-timezone lodash --save

画面構成について

画面構成は、ホーム画面、詳細画面、投稿画面、プロフィール画面、プロフィール編集画面で作ります

ホーム画面 ・・・ 投稿内容がカード形式で一覧表示 詳細画面 ・・・ 投稿内容の詳細が表示 投稿画面 ・・・ 画像と文言を入力し、投稿させる プロフィール画面 ・・・ プロフィール画像とニックネームが表示 プロフィール編集画面 ・・・ プロフィール画像とニックネームを入力し、アップロードさせる

今回、画面遷移の管理はreact-navigationにさせるので、各画面のコンポーネントファイルとAppNavigatorというファイルを作り画面遷移の制御設定をしていきます。

スクリーンファイルはcomponentsフォルダに適当に作って入れておいてください。

import React, { Component } from 'react'
import { StyleSheet, Text, View } from 'react-native'

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
})

class HomeScreen extends Component {
  render() {
    return (
      <View style={styles.container}>
        <Text>HomeScreen</Text>
      </View>
    )
  }
}

export default HomeScreen

AppNavigatorでは上手にスタックを組み合わせてナビゲーションの構造を作ります。 詳しくは「【React Native】react-navigation (version3.x)の手ほどき」の記事で解説している。

import React from 'react'
import { Platform } from 'react-native'
import { createStackNavigator, createBottomTabNavigator } from 'react-navigation'
import { Icon } from 'expo'
import HomeScreen from './components/HomeScreen'
import DetailScreen from './components/DetailScreen'
import FeedScreen from './components/FeedScreen'
import ProfileScreen from './components/ProfileScreen'
import ProfileEditScreen from './components/ProfileEditScreen'

const HomeStack = createStackNavigator(
  {
    Home: {
      screen: HomeScreen
    },
    Detail: {
      screen: DetailScreen
    },
  },
  {
    initialRouteName: 'Home',
    navigationOptions: ({ navigation }) => ({
      tabBarIcon: ({ focused }) => (
        <Icon.Ionicons
          name={
            Platform.OS === 'ios'
              ? 'ios-home'
              : 'md-home'
          }
          size={26}
          style={{ marginBottom: -3 }}
          color={focused ? 'black' : 'gray'}
        />
      ),
    }),
  }
)

const FeedStack = createStackNavigator(
  {
    Feed: {
      screen: FeedScreen
    },
    Detail: {
      screen: DetailScreen
    },
  },
  {
    initialRouteName: 'Feed',
    navigationOptions: {
      tabBarIcon: ({ focused }) => (
        <Icon.Ionicons
          name={
            Platform.OS === 'ios'
              ? 'ios-add'
              : 'md-add'
          }
          size={26}
          style={{ marginBottom: -3 }}
          color={focused ? 'black' : 'gray'}
        />
      ),
    },
  }
)

const ProfileStack = createStackNavigator(
  {
    Profile: {
      screen: ProfileScreen
    },
    Edit: {
      screen: ProfileEditScreen
    },
  },
  {
    initialRouteName: 'Profile',
    navigationOptions: {
      tabBarIcon: ({ focused }) => (
        <Icon.Ionicons
          name={
            Platform.OS === 'ios'
              ? 'ios-person'
              : 'md-person'
          }
          size={26}
          style={{ marginBottom: -3 }}
          color={focused ? 'black' : 'gray'}
        />
      ),
    },
  }
)

const TabNavigator = createBottomTabNavigator(
  {
    Home: HomeStack,
    Feed: FeedStack,
    Profile: ProfileStack,
  },
  {
    tabBarOptions: {
      activeTintColor: 'black',
      inactiveTintColor: 'gray',
    }
  }
)

export default TabNavigator

App.jsで表示させる物を、AppNavigatorにします。

import React from 'react'
import { createAppContainer } from 'react-navigation'
import AppNavigator from './AppNavigator'

const AppContainer = createAppContainer(AppNavigator)

export default class App extends React.Component {
  render() {
    return <AppContainer />
  }
}

ストア管理をReduxにさせる

ストア管理をReduxにさせるので、まずはユーザーのログイン情報を入れるreducerとactionを作成します。

import { handleActions } from 'redux-actions'
import actions from '../actions/user'

const initialState = { 
  uid: null,
  properties: {},
}

const reducer = handleActions({
  [actions.setUserUid]: (state, action) => ({
    ...state,
    uid: action.payload,
  }),
  [actions.setUserProperties]: (state, action) => ({
    ...state,
    properties: action.payload,
  })
}, initialState)

export default reducer
import { createActions } from 'redux-actions'

const actions = createActions(
  {
    SET_USER_UID: (args) => (args),
    SET_USER_PROPERTIES: (args) => (args),
  }
)

export default actions

configureStore.jsファイルを作成し、storeを作成する関数を定義します。

import { combineReducers, createStore, applyMiddleware } from 'redux'
import user from './reducers/user'

const middlewares = []

if(process.env.NODE_ENV !== 'production') {
  const { logger } = require('redux-logger')
  middlewares.push(logger)
}

const reducers = combineReducers({
  user,
})

const configureStore = initialState => {
  const store = createStore(reducers, initialState, applyMiddleware(...middlewares))
  return store
}

export default configureStore

AppNavigator.jsで読み込んでいるコンポーネントをコンテナーに変更します。

...
import HomeScreen from './containers/HomeScreen'
import DetailScreen from './containers/DetailScreen'
import FeedScreen from './containers/FeedScreen'
import ProfileScreen from './containers/ProfileScreen'
import ProfileEditScreen from './containers/ProfileEditScreen'
...

最後に、App.jsでReduxを利用するように設定します。

import React from 'react'
import { Provider } from 'react-redux'
import { createAppContainer } from 'react-navigation'
import AppNavigator from './AppNavigator'
import configureStore from './configureStore'

const store = configureStore()

const AppContainer = createAppContainer(AppNavigator)

export default class App extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <AppContainer />
      </Provider>
    )
  }
}

firebase apiをラップしたモジュールを作る

modules/firebaseフォルダを作成し、Facebook認証、画像アップロードを関数化したindex.jsを作成します。 config.jsに、firebaseのウェブ設定から取得できるconfigと、facebookのアプリIDを保存しておく。

import * as firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'
import 'firebase/storage'
import { config, FACEBOOK_APPID } from './config'
import * as Expo from 'expo'

firebase.initializeApp(config)

// auth

export const auth = firebase.auth()

export const getUid = () => {
  const user = firebase.auth().currentUser

  if (user) {
    return { uid: user.uid }
  }
  else {
    return { uid: null }
  }
}

export const authFacebook = async () => {
  try {
    const { type, token } = await Expo.Facebook.logInWithReadPermissionsAsync(
      FACEBOOK_APPID,
      { permissions: ['public_profile'] }
    )

    if (type === 'success') {
      const credential = firebase.auth.FacebookAuthProvider.credential(token)
      return firebase.auth().signInAndRetrieveDataWithCredential(credential).catch((error) => console.log(error))
    }
    else {
      return { cancelled: true }
    }
  }
  catch (e) {
    return { error: true }
  }
}

export const logout = () => {
  return firebase.auth().signOut()
}

// firestore

export const db = firebase.firestore()
export const userCollection = db.collection('User')
export const feedCollection = db.collection('Feed')

export const getNowDate = () => {
  return firebase.firestore.FieldValue.serverTimestamp()
}

export const getNewFeedDoc = () => {
  return feedCollection.doc()
} 

// storage

const storageRef = firebase.storage().ref()
export const userRef = storageRef.child('User')

export const uploadAvatar = async(uri) => {
  const { uid } = getUid()
  const avatarRef = userRef.child(`${uid}/Avatar/main.png`)

  const blob = await new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.onload = () => {
      resolve(xhr.response)
    }

    xhr.onerror = e => {
      console.log(e)
      reject(new TypeError('Network request failed'))
    }

    xhr.responseType = 'blob'
    xhr.open('GET', uri, true)
    xhr.send(null)
  })

  const snapshot = await avatarRef.put(blob)
  blob.close()
  return await snapshot.ref.getDownloadURL()
}

export const uploadFeedImage = async(uri, uuid) => {
  const { uid } = getUid()
  const feedImageRef = userRef.child(`${uid}/Feed/${uuid}/main.png`)

  const blob = await new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.onload = () => {
      resolve(xhr.response)
    }

    xhr.onerror = e => {
      console.log(e)
      reject(new TypeError('Network request failed'))
    }

    xhr.responseType = 'blob'
    xhr.open('GET', uri, true)
    xhr.send(null)
  })

  const snapshot = await feedImageRef.put(blob)
  blob.close()
  return await snapshot.ref.getDownloadURL()
}

sagaを導入し、常にログイン状態をチェックさせる

sagasフォルダを作成し、user.jsで常にログイン状態をチェックさせるチャネルを作成する。 詳しくは「【React】Firebaseの認証状態チェックコードはどこに書くべきなのか」の記事で解説している。

import { take, put, call } from 'redux-saga/effects'
import { eventChannel } from 'redux-saga'
import { auth } from '../modules/firebase'

const data = (type ,payload) => {
  const _data = {
    type: type,
    payload: payload,
  }

  return _data
}

const authChannel = () => {
  const channel = eventChannel(emit => {
    const unsubscribe = auth.onAuthStateChanged(
      user => emit({ user }),
      error => emit({ error })
    )
    return unsubscribe
  })
  return channel
}

function* checkUserStateSaga() {
  const channel = yield call(authChannel)
  while (true) {
    const { user, error } = yield take(channel)

    if ( user && !error ) {
      yield put(data('SET_USER_UID', user.uid))
    }
    else {
      yield put(data('SET_USER_UID', null))
    }
  }
}

const sagas = [
  checkUserStateSaga(),
]

export default sagas

index.jsでsagaをまとめる。

今回はauth.jsのみだが、増えたとき簡単に束ねなれるように。

import { all } from 'redux-saga/effects'
import auth from './auth'

export default function* rootSaga() {
  yield all([
    ...auth,
  ])
}

configureStore.jsでsagaがちゃんと動くように設定する。

import { combineReducers, createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import rootSaga from './sagas'
import user from './reducers/user'

const sagaMiddleware = createSagaMiddleware()
const middlewares = [sagaMiddleware]

if(process.env.NODE_ENV !== 'production') {
  const { logger } = require('redux-logger')
  middlewares.push(logger)
}

const reducers = combineReducers({
  user,
})

const configureStore = initialState => {
  const store = createStore(reducers, initialState, applyMiddleware(...middlewares))
  sagaMiddleware.run(rootSaga)
  return store
}

export default configureStore

次から、やっと画面作成に入れます。

ProfileScreenの作成

アバターの画像と、ニックネームを表示させます。 ログインしていなかったら、ログインボタンを表示させます。 componentWillMount()では、Userの情報をfirestoreから取ってきて、ローカルストアに保持させています。

import React, { Component } from 'react'
import { StyleSheet, View, Text, Dimensions } from 'react-native'
import { Container, Content, Thumbnail, Button } from 'native-base'
import { authFacebook, logout, userCollection } from '../modules/firebase'

class ProfileScreen extends Component {
  static navigationOptions = ({ navigation }) => ({
    title: 'Instagram',
  })

  componentWillMount() {
    this.unsubscribe = userCollection.doc(this.props.user.uid || '_').onSnapshot(doc => {
      const properties = doc.data()
      if(properties) {
        this.props.handleSetUserProperties(Object.assign({
          avatar: null,
          name: null,
        }, properties))
      }
      else {
        this.props.handleSetUserProperties({
          avatar: null,
          name: null,
        })
      }
     console.log(properties)
    })
  }

  componentWillUnmount() {
    this.unsubscribe()
  }

  render () {
    if(this.props.user.uid) {

      const tempAvatar = 'https://firebasestorage.googleapis.com/v0/b/novels-a5884.appspot.com/o/temp%2Ftemp.png?alt=media&token=a4d36af6-f5e8-49ad-b9c0-8b5d4d899c0d'

      return (
        <Container style={styles.container}>
          <Content>
            <View style={styles.content}>
              <View style={styles.profileSection}>
                <View style={styles.profileMain}>
                  <Thumbnail
                    large
                    source={{uri: this.props.user.properties.avatar? this.props.user.properties.avatar : tempAvatar}}
                    style={styles.avatar}
                  />
                  <Text style={styles.name}>{this.props.user.properties.name? this.props.user.properties.name : '未設定'}</Text>
                </View>
                <Button
                  style={styles.editButton}
                  transparent
                  dark
                  onPress={() => this.props.navigation.navigate('Edit')}
                >
                  <Text style={styles.buttonText}>プロフィール編集</Text>
                </Button>
                <Button
                  style={styles.logoutButton}
                  dark
                  rounded
                  onPress={logout}
                >
                  <Text style={styles.buttonText}>ログアウト</Text>
                </Button>
              </View>
            </View>
          </Content>
        </Container>
      )
    }
    else {
      return (
        <View style={styles.notLoginContainer}>
          <Button
            style={styles.loginButton}
            dark
            rounded
            onPress={authFacebook}
            >
              <Text style={styles.buttonText}>Login with Facebook</Text>
            </Button>
        </View>
      )
    }
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  notLoginContainer: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  container: {
    flex: 1,
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  profileSection: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    position: 'relative',
    width: width,
    height: height/3,
    padding: 10,
    backgroundColor: 'black',
  },
  avatar: {
    width: height/5,
    height: height/5,
    borderRadius: height/10,
    marginBottom: 15,
  },
  name: {
    fontSize: 12,
    fontWeight: 'bold',
    color: 'white',
    textAlign: 'center',
  },
  editButton: {
    position: 'absolute',
    padding: 10,
    bottom: 10,
    right: 10,
  },
  logoutButton: {
    position: 'absolute',
    padding: 10,
    bottom: 10,
    left: 10,
  },
  loginButton: {
    padding: 10,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
  buttonText: {
    fontSize: 10.5,
    color: 'white',
  },
})

export default ProfileScreen
import { connect } from 'react-redux'
import userActions from '../actions/user'
import ProfileScreen from '../components/ProfileScreen'

const mapStateToProps = state => {
  return state
}

const mapDispatchToProps = dispatch => {
  return {
    handleSetUserProperties: (properties) => dispatch(userActions.setUserProperties(properties)),
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(ProfileScreen)

ProfileEditScreenの作成

この画面では、アバターの画像を登録させるため、カメラロールへのアクセスを許可しなければいけません。こちらはexpo側でAPIを用意してくれているのでそれを利用します。カメラロールから写真を取ってくる機能もexpo側で用意してくれているのでそれを利用します。また、データとして送信用のストアが必要ですがこの画面でしか使わないデータなのでsteteで管理させています。

本当はRedux側で送信用データも管理させた方が綺麗かもしれません、、、

import React, { Component } from 'react'
import { Platform, StyleSheet, View, Text, Dimensions } from 'react-native'
import { Container, Content, Header, Left, Thumbnail, Button, Item, Input, Badge } from 'native-base'
import { Icon, Permissions ,ImagePicker } from 'expo'
import { userCollection, uploadAvatar, db } from '../modules/firebase'

class ProfileEditScreen extends Component {
  constructor(props) {
    super(props)
    this.state={
      name: null,
      avatar: null,
      uploading: false,
    }
  }

  static navigationOptions = ({ navigation }) => ({
    header: null,
  })

  pickImage = async () => {
    let isAccepted = true

    const permission = await Permissions.getAsync(Permissions.CAMERA_ROLL)
    
    if(permission.status !== 'granted') {
      const newPermission = await Permissions.askAsync(Permissions.CAMERA_ROLL)
      if (newPermission.status !== 'granted') {
        isAccepted = false
      }
    }

    if(isAccepted) {
      let result = await ImagePicker.launchImageLibraryAsync({
        allowsEditing: true,
        aspect: [9, 9]
      })

      if (!result.cancelled) {
        this.setState({ avatar: result.uri })
        console.log(result.uri)
      }
    }
  }

  updateProfile = async (properties) => {
    try{
      this.setState({ uploading: true })

      let downloadUrl = null
      if (this.state.avatar) {
        downloadUrl = await uploadAvatar(this.state.avatar)
      }

      const batch = db.batch()
      const userRef = userCollection.doc(this.props.user.uid)

      await batch.set(userRef, { name: properties.name, avatar: downloadUrl })
      await batch.commit().then(() => {
        console.log('edit user success.')
      })

      this.setState({
        name: null,
        avatar: null,
      })

      this.props.navigation.goBack()
    }
    catch(e) {
      console.log(e)
      alert('Upload avatar image failed, sorry :(')
    }
    finally {
      this.setState({ uploading: false })
    }
  }

  render () {
    if(this.props.user.uid) {

      const tempAvatar = 'https://firebasestorage.googleapis.com/v0/b/novels-a5884.appspot.com/o/temp%2Ftemp.png?alt=media&token=a4d36af6-f5e8-49ad-b9c0-8b5d4d899c0d'

      return (
        <Container style={styles.container}>
          <Header transparent>
            <Left>
              <Button
                transparent
                onPress={() => this.props.navigation.goBack()}
              >
                <Icon.Ionicons
                  name={
                    Platform.OS === 'ios'
                    ? 'ios-arrow-back'
                    : 'md-arrow-back'
                  }
                  size={24}
                  style={styles.backButton}
                  color='black'
                />
              </Button>
            </Left>
          </Header>
          
          <Content>            
            <View style={styles.content}>
              {this.state.avatar? (
                <Thumbnail
                  large
                  source={{ uri: this.state.avatar? this.state.avatar : tempAvatar }}
                  style={styles.avatar}
                />
              ) : (
                <Thumbnail
                  large
                  source={{ uri: this.props.user.properties.avatar? this.props.user.properties.avatar : tempAvatar }}
                  style={styles.avatar}
                />
              )}

              <Badge style={styles.iconButton}>
                <Icon.AntDesign
                  name='plus'
                  size={50}
                  color='white'
                  onPress={this.pickImage}
                />
              </Badge>

              <Item style={styles.name} rounded>
                <Input
                  placeholder={this.props.user.properties.name}
                  onChangeText={name => this.setState({ name })}
                />
              </Item>

              <Button
                style={styles.button}
                dark
                rounded
                onPress={() => this.updateProfile(this.state)}
                disabled={this.state.uploading}
              >
                <Text style={styles.buttonText}>プロフィールを保存</Text>
              </Button>
            </View>
          </Content>
        </Container>
      )
    }
    else {
      return (
        <View style={styles.notLoginContainer}>
          <Text>Error</Text>
        </View>
      )
    }
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  notLoginContainer: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  avatar: {
    position: 'relative',
    width: width*2/3,
    height: width*2/3,
    borderRadius: width/3,
    margin: 20,
  },
  iconButton: {
    position: 'absolute',
    top: width*6/11,
    right: width/7,
    width: 64,
    height: 64,
    borderRadius: 32,
  },
  name: {
    width: width*2/3,
    marginBottom: 20,
  },
  button: {
    padding: 10,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
  buttonText: {
    fontSize: 12,
    color: 'white',
  }
})

export default ProfileEditScreen

あとは投稿機能とそれをみられるようにすれば、完成です。長いですがラストスパートがんばりましょう。

HomeScreenの作成

投稿内容を表示するHomeScreenを先に作ります。 Feedを全て取得し、created_atでソートしてstateでデータを管理させています。

こちらもReduxで一元管理した方が綺麗になると思います。

import React, { Component } from 'react'
import { StyleSheet, Image, Dimensions } from 'react-native'
import { Container, Content, Card, CardItem, Body, Text } from 'native-base'
import moment from 'moment-timezone'
import { feedCollection } from '../modules/firebase'

class HomeScreen extends Component {
  constructor(props) {
    super(props)
    this.state={
      feeds: null,
    }
  }

  static navigationOptions = ({ navigation }) => ({
    title: 'Instagram',
  })

  componentWillMount () {
    this.unsubscribe = feedCollection.orderBy('created_at').onSnapshot(querySnapshot => {
      const feeds = []
      querySnapshot.forEach(doc => {
        feeds.push({ uuid: doc.id, ...doc.data() })
      })
      feeds.reverse()
      this.setState({ feeds })
    })
  }

  componentWillUnmount() {
    this.unsubscribe()
  }

  render () {
    return (
      <Container style={styles.container}>
        <Content>
          {this.state.feeds && this.state.feeds.map(element => {
            let date
            try {
              date = moment.unix(element.created_at.seconds).format('YYYY/MM/DD HH:mm:ss')
            }
            catch (e) {
              console.log(e)
              date = '投稿日不明'
            }

            return (
              <Card style={styles.card} key={element.uuid}>
                <CardItem cardBody button onPress={() => this.props.navigation.navigate('Detail', { uuid: element.uuid })}>
                  <Image
                    style={styles.image}
                    source={{uri: element.image}}
                  />
                </CardItem>
                <CardItem style={styles.inner} button onPress={() => this.props.navigation.navigate('Detail', { uuid: element.uuid })}>
                  <Body>
                    <Text>{element.message}</Text>
                    <Text style={styles.date}>{date}</Text>
                  </Body>
                </CardItem>
              </Card>
            )
          })}
        </Content>
      </Container>
    )
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  card: {
    width: width,
    height: 300,
  },
  image: {
    width: width,
    height: 200,
    overflow: 'hidden',
  },
  date: {
    position: 'absolute',
    top: 50,
    left: 0,
    color: 'gray',
    fontSize: 10.5,
  },
})

export default HomeScreen

FeedScreenの作成

次、肝となる投稿機能を持つ画面です。 ProfileEditScreenと仕組みは同じだが、画像保存の際、feedIDが必要になるので先に空のドキュメントを作成しfeedIDを先に取得しているcreated_atupdated_atはfirebaseのapiでサーバーの時間を保存させるように命令することができるので、それで解決している

import React, { Component } from 'react'
import { StyleSheet, View, Text, Dimensions } from 'react-native'
import { Container, Content, Button, Thumbnail, Badge, Textarea } from 'native-base'
import { Icon, Permissions, ImagePicker } from 'expo'
import { getNewFeedDoc, uploadFeedImage, getUid, getNowDate, authFacebook, db } from '../modules/firebase'

class FeedScreen extends Component {
  constructor(props) {
    super(props)
    this.state={
      message: null,
      image: null,
      uploading: false,
    }
  }

  static navigationOptions = ({ navigation }) => ({
    title: 'Instagram',
  })

  pickImage = async () => {
    const isAccepted = true

    const permission = await Permissions.getAsync(Permissions.CAMERA_ROLL)
    
    if(permission.status !== 'granted') {
      const newPermission = await Permissions.askAsync(Permissions.CAMERA_ROLL)
      if (newPermission.status !== 'granted') {
        isAccepted = false
      }
    }

    if(isAccepted) {
      let result = await ImagePicker.launchImageLibraryAsync({
        allowsEditing: true,
        aspect: [9, 9]
      })

      if (!result.cancelled) {
        this.setState({ image: result.uri })
        console.log(result.uri)
      }
    }
  }

  postFeed = async (properties) => {
    try{
      this.setState({ uploading: true })

      const feedRef = getNewFeedDoc()
      const uuid = feedRef.id

      let downloadUrl = null
      if (this.state.image) {
        downloadUrl = await uploadFeedImage(this.state.image, uuid)
      }

      const { uid } = getUid()

      const batch = db.batch()

      await batch.set(feedRef, {
        message: properties.message,
        image: downloadUrl,
        writer: uid,
        created_at: getNowDate(),
        updated_at: getNowDate(),
      })
      await batch.commit().then(() => {
        console.log('post feed success.')
      })

      this.setState({
        message: null,
        image: null,
      })
      this.props.navigation.navigate('Detail', { uuid })
    }
    catch(e) {
      console.log(e)
    }
    finally {
      this.setState({ uploading: false })
    }
  }

  render () {
    if(this.props.user.uid) {
      return (
        <Container style={styles.container}>
          <Content>
            <View style={styles.content}>
              <View style={styles.imageSection}>
                {this.state.image? (
                  <Thumbnail
                    large
                    square
                    source={{ uri: this.state.image }}
                    style={styles.image}
                  />
                ) : null}

                <Badge style={styles.iconButton}>
                  <Icon.AntDesign
                    name='plus'
                    size={50}
                    color='white'
                    onPress={this.pickImage}
                  />
                </Badge>
              </View>
              
              <View style={styles.textSection}>
                <Textarea
                  style={styles.description}
                  rowSpan={10}
                  bordered
                  placeholder='メッセージ'
                  onChangeText={message => this.setState({ message })}
                />
              </View>

              <Button
                style={styles.button}
                dark
                rounded
                onPress={() => this.postFeed(this.state)}
                disabled={this.state.uploading}
              >
                <Text style={styles.buttonText}>投稿</Text>  
              </Button>
            </View>
          </Content>
        </Container>
      )
    }
    else {
      return (
        <View style={styles.notLoginContainer}>
          <Button
            style={styles.loginButton}
            dark
            rounded
            onPress={authFacebook}
            >
              <Text style={styles.buttonText}>Login with Facebook</Text>
            </Button>
        </View>
      )
    }
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  notLoginContainer: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  imageSection: {
    position: 'relative',
    width: width,
    height: width,
    backgroundColor: 'black',
    marginBottom: 20,
  },
  image: {
    width: width,
    height: width,
  },
  iconButton: {
    position: 'absolute',
    bottom: -32,
    right: width/20,
    width: 64,
    height: 64,
    borderRadius: 32,
  },
  textSection: {
    padding: 10,
  },
  title: {
    width: width*9/10,
    marginBottom: 20,
  },
  description: {
    width: width*9/10,
    marginBottom: 20,
  },
  button: {
    padding: 10,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
  buttonText: {
    fontSize: 12,
    color: 'white',
  },
  loginButton: {
    padding: 10,
    marginLeft: 'auto',
    marginRight: 'auto',
  },
})

export default FeedScreen

DetailScreenの作成

最後の画面です。 前のスクリーンからuuidを取得し、uuidFeedの情報を取得して表示させています。また、FeedwriterからUserの情報を取得して表示させています。

import React, { Component } from 'react'
import { Platform, StyleSheet, Dimensions, View, Text, Image } from 'react-native'
import { Container, Content, Header, Left, Button, Thumbnail } from 'native-base'
import moment from 'moment-timezone'
import { Icon } from 'expo'
import { feedCollection, userCollection } from '../modules/firebase'

class DetailScreen extends Component {
  constructor(props) {
    super(props)
    this.state={
      feed: null,
      writer: null,
    }
  }

  static navigationOptions = ({ navigation }) => ({
    header: null,
  })

  componentWillMount() {
    const uuid = this.props.navigation.getParam('uuid', null)

    if(uuid) {
      this.unsubscribe = feedCollection.doc(uuid).onSnapshot(doc => {
        const feed = doc.data()

        let date
        try {
          date = moment.unix(feed.updated_at.seconds).format('YYYY/MM/DD HH:mm:ss')
        }
        catch (e) {
          console.log(e)
          date = '投稿日不明'
        }

        this.setState({
          feed : {
            image: feed.image,
            message: feed.message,
            writer: feed.writer,
            updated_at: date,
          }
        })

        userCollection.doc(feed.writer).get()
        .then(_doc => {
          if(_doc.exists) {
            const user = _doc.data()
            this.setState({
              user: {
                name: user.name,
                avatar: user.avatar,
              }
            })
          }
          else {
            this.setState({
              user: {
                name: null,
                avatar: null,
              }
            })
          }
        })
        .catch(error => {
          this.setState({
            user: {
              name: null,
              avatar: null,
            }
          })
          console.log(error)
        })
      })
    }
  }

  render () {
    if (!this.state.feed) {
      return (
        <View style={styles.notLoginContainer}>
          <Text>Error</Text>
        </View>
      )
    }
    
    return (
      <Container style={styles.container}>
        <Header transparent>
          <Left>
            <Button
              transparent
              onPress={() => this.props.navigation.goBack()}
            >
              <Icon.Ionicons
                name={
                  Platform.OS === 'ios'
                  ? 'ios-arrow-back'
                  : 'md-arrow-back'
                }
                size={24}
                style={styles.backBtn}
                color='black'
              />
            </Button>
          </Left>
        </Header>

        {this.state.feed &&
          <Content style={styles.content}>
            <Image
              source={{uri: this.state.feed.image}}
              style={styles.image}
            />
            <View style={styles.words}>
              {this.state.user &&
                <View style={styles.writer}>
                  <Thumbnail small source={{uri: this.state.user.avatar}} style={styles.avatar} />
                  <View>
                    <Text style={styles.writerName}>{this.state.user.name}</Text>
                    <Text style={styles.date}>{this.state.feed.updated_at} にこの記事は更新されています。</Text>
                  </View>
                </View>
              }
                
              <View style={styles.divider} />
              
              <Text style={styles.description}>{this.state.feed.message}</Text>
            </View>
          </Content>
        }
      </Container>
    )
  }
}

const { width, height } = Dimensions.get('window')

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  notLoginContainer: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  content: {
    position: 'absolute',
    top: 0,
    zIndex: -1,
  },
  image: {
    // square !!
    width: width,
    height: width,
  },
  words: {
    flex: 1,
    padding: 10,
  },
  title: {
    fontWeight: 'bold',
    fontSize: 25,
  },
  avatar: {
    marginRight: 5,
  },
  writer: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  writerName: {
    fontSize: 10.5,
  },
  date: {
    fontSize: 10.5,
    color: 'gray',
  },
  description: {
    fontSize: 20,
  },
  divider: {
    height: 10,
  },
  dividerHalf: {
    height: 5,
  }
})

export default DetailScreen

完成品はこのようになります。

capture capture

最後に

ソースコードあげておきました。 https://github.com/kousaku-maron/insta-sample