In this tutorial we will be implementing a technological stack have been playing with. We are gonna build a simple chat application with the very popular react native + redux and a realtime communication platform Pusher.
React native makes it easy to build mobile apps with native UI and high performance by just wrapping Javascript codes over native modules, you can read more here and Pusher allows you to send messages realtime via their infrastructure to as many listeners as possible;
I would like to assume you have react native set up already on your computer and that you have created an app on Pusher already.Thou its simple, just create an account here and from there create an app
So enough talk, lets get started
$ cd into_working_directory
$ react-native init PusherChatApp
$ cd PusherChatApp
Update your package.json to
{
"name": "PusherChat",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "node node_modules/react-native/local-cli/cli.js start"
},
"dependencies": {
"axios": "^0.11.0",
"moment": "^2.13.0",
"pusher-js": "git+https://github.com/pusher-community/pusher-websocket-js-iso.git",
"react": "^0.14.8",
"react-native": "^0.24.1",
"react-native-keyboard-spacer": "^0.1.6",
"react-native-router-flux": "^3.22.23",
"react-native-swipeout": "^2.0.12",
"react-redux": "^4.4.5",
"redux": "^3.5.2",
"redux-logger": "^2.6.1",
"redux-thunk": "^2.0.1"
}
}
$ npm install
Since we will be using redux lets go ahead and create the neccessary folders as seen below
At the end of the tutorial we want to have two screens as such:
Coversations Screen:
Coversation Screen:
So lets go ahead and write some codes, just go ahead and edit the following files:
import React, {
AppRegistry,
Component,
StyleSheet,
Text,
View
} from 'react-native';
import PusherChatApp from './src/app'
AppRegistry.registerComponent('PusherChat', () => PusherChatApp);
import React, { Component, StyleSheet, Dimensions} from 'react-native';
import { Provider } from 'react-redux'
import configureStore from './store/configureStore'
const store = configureStore();
import {Actions, Scene, Router, TabBar, Modal} from 'react-native-router-flux';
import SplashScreen from './screens/splash-screen'
import ConversationsScreen from './screens/conversations-screen';
import ConversationScreen from './screens/conversation-screen';
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
tabBarStyle: {
flex: 1,
flexDirection: "row",
backgroundColor: "#95a5a6",
padding: 0,
height: 45
},
sceneStyle: {
flex: 1,
backgroundColor: "#fff",
flexDirection: "column",
paddingTop:20
}
})
export default class PusherChatApp extends Component {
render() {
return (
<Provider store={store}>
<Router style={styles.container} sceneStyle={styles.sceneStyle}>
<Scene key="root">
<Scene key="conversations_screen" component={ConversationsScreen} hideNavBar={true}/>
<Scene key="splash_screen" component={SplashScreen} hideNavBar={true}/>
<Scene key="conversation_screen" component={ConversationScreen} hideNavBar={true} />
</Scene>
</Router>
</Provider>
)
}
}
So lets start by creating our Conversations screen component; So how this work?
When the Component is mounted it dispatches an action to get all the chats
stored on the devices AsyncStorage (one could also ajax load all the messages that have been sent to the user since when he was offline) and the chats are passed as props to the Component.
The component uses react-natives in built ListView component to render the list of conversations a user has.
Also in this component we can start a new conversation with a user by clicking an add button that opens a Modal which contains a form to add the username and a start converation message
import React, {
Component,
View,
Text,
StyleSheet,
Image,
ListView,
TouchableHighlight,
Modal,
TextInput,
AsyncStorage
} from 'react-native';
import Button from './../components/button/button'
import { Actions } from 'react-native-router-flux'
import _ from 'lodash';
import {connect} from 'react-redux';
import { startConvo, apiGetChats, newMesage } from './../actions/';
import moment from 'moment'
const styles = StyleSheet.create({
container: {
flex: 1
},
main_text: {
fontSize: 16,
textAlign: "center",
alignSelf: "center",
color: "#42C0FB",
marginLeft: 5
},
row: {
flexDirection: "row",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: "#42C0FB",
marginBottom: 10
},
listRow: {
flexDirection: "row",
paddingLeft: 10,
paddingRight: 10,
marginBottom: 12
},
column: {
flex: 1,
flexDirection: "column",
marginLeft: 8,
borderBottomWidth: 1,
borderBottomColor: "#42C0FB"
},
headerImg: {
height: 40,
width: 40
},
dp: {
height: 50,
width: 50,
borderRadius: 25
},
last_msg: {
color: "#666",
fontSize: 13,
marginTop: 5
},
time: {
color: "#42C0FB",
fontSize: 12
},
innerRow: {
flexDirection: "row",
justifyContent: "space-between"
},
add_text: {
fontSize: 16,
textAlign: "right",
alignSelf: "center",
color: "#42C0FB",
marginRight: 10
},
modalContainer: {
backgroundColor: '#42C0FB',
flex: 1,
flexDirection: "column",
paddingTop: 30
},
rowRight: {
flexDirection: "row",
justifyContent: "flex-end",
borderBottomWidth: 1,
borderBottomColor: "#42C0FB",
marginBottom: 10
},
close_btn: {
alignSelf: "flex-end",
borderWidth: 1,
borderColor: "#fff",
width: 50,
height: 30,
justifyContent: "center",
marginRight: 15,
},
input:{
borderWidth: 1,
borderColor: "#fff",
height:50,
marginBottom:10,
marginLeft:8,
marginRight:8,
paddingLeft:5
},
text: {
textAlign: "center",
color:"#fff"
},
submit_btn: {
alignSelf: "center",
borderWidth: 1,
borderColor: "#fff",
width: 100,
height: 40,
justifyContent: "center",
marginRight: 15,
}
});
const username = 'ponty96';
function mapStateToProps(state) {
return {
Chats: state.Chats,
dispatch: state.dispatch
}
};
class ConversationsScreen extends Component {
constructor(props) {
super(props);
const ds = new ListView.DataSource({rowHasChanged: (r1, r2) => r1 != r2});
this.state = {
conversations: ds,
modalVisible: false,
contact_name: "",
new_contact_message: ""
}
}
componentDidMount(){
const {dispatch, Chats} = this.props;
const process_status = Chats.process_status;
if(process_status != "isFetching"){
dispatch(apiGetChats())
}
// start pusher websocket client to listen to new websocket events
newMesage(dispatch)
}
componentWillReceiveProps(nextProps){
const {dispatch, Chats} = nextProps;
const process_status = Chats.process_status;
if(process_status === "completed"){
// sorting convos by time
let chats = Chats.chats;
chats.sort((a,b)=>{
return moment(b.sent_at).valueOf() - moment(a.sent_at).valueOf();
});
const convos = _.uniq(chats, 'convo_id');
this.setState({
conversations: this.state.conversations.cloneWithRows(convos)
})
}
}
closeModal = () => {
this.setState({modalVisible: false})
}
openModal = () => {
this.setState({modalVisible: true})
}
addContact = (name,message) => {
const {dispatch, Chats} = this.props;
dispatch(startConvo(name,message));
this.closeModal();
}
renderModal = () => {
// function that returns a modal view for the user to start a new conversation
const animated = true;
const transparent = false;
return (
<Modal animated={animated} transparent={transparent} visible={this.state.modalVisible}
onRequestClose={() => {this.closeModal()}}>
<View style={styles.modalContainer}>
<View style={styles.rowRight}>
<Button
style={styles.close_btn}
onPress={this.closeModal}>
<Text style={styles.text}>Close</Text>
</Button>
</View>
<TextInput
style={styles.input}
placeholder="Contact Name"
onChangeText={(text) => this.setState({contact_name:text})}/>
<TextInput
style={styles.input}
placeholder="Say Hello to Contact"
onChangeText={(text) => this.setState({new_contact_message:text})}/>
<Button
style={styles.submit_btn}
onPress={() => this.addContact(this.state.contact_name, this.state.new_contact_message)}>
<Text style={styles.text}>Submit</Text>
</Button>
</View>
</Modal>
)
}
renderRow = (rowData) => {
// this function returns the view for each row data in the list view
return (
<Button onPress={() => Actions.conversation_screen({convo_id:rowData.convo_id})}>
<View style={styles.listRow}>
<Image source={{uri:username != rowData.sender ? rowData.sender_dp : rowData.receiver_dp}}
style={styles.dp}/>
<View style={styles.column}>
<View style={styles.innerRow}>
<Text>{ username != rowData.sender ? rowData.sender : rowData.receiver}</Text>
<Text style={styles.time}>{rowData.time}</Text>
</View>
<Text style={styles.last_msg}>{rowData.message}</Text>
</View>
</View>
</Button>
)
}
render() {
const modalVisible = this.state.modalVisible;
return (
<View style={styles.container}>
<View style={styles.row}>
<Image source={require('./../assets/icon.png')} style={styles.headerImg}/>
<Text style={styles.main_text}>Conversations</Text>
<TouchableHighlight
style={{marginTop:10}}
onPress={this.openModal}
underlayColor="transparent">
<Text style={styles.add_text}>Add</Text>
</TouchableHighlight>
</View>
<View>
{ modalVisible ? this.renderModal() : <View></View>}
</View>
<ListView
renderRow={this.renderRow}
dataSource={this.state.conversations}/>
</View>
)
}
}
export default connect(mapStateToProps)(ConversationsScreen)
Now we create our Conversation Screen where all the chat is done, when a row is clicked in the Conversations Screen component we dispatch an Action to navigate to our Conversation Screen where we pass the convo_id as a props.
The Converation Screen is also connected to redux and when its mounted it filters all the chat by the convod_id, sorts them based on time and renders them via the ListView component. It also contains a TextInput Component and a button to send the message which dispatches an action when clicked.
import React, { Component, View, Text, StyleSheet, Image, ListView, TextInput, Dimensions} from 'react-native';
import Button from './../components/button/button';
import { Actions } from 'react-native-router-flux';
import KeyboardSpacer from 'react-native-keyboard-spacer';
import { connect } from 'react-redux';
import _ from 'lodash';
import moment from 'moment';
import { apiSendChat, newMesage } from './../actions/';
const { width, height } = Dimensions.get('window');
const styles = StyleSheet.create({
container: {
flex: 1
},
main_text: {
fontSize: 16,
textAlign: "center",
alignSelf: "center",
color: "#42C0FB",
marginLeft: 5
},
row: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: "#42C0FB",
marginBottom: 10,
padding:5
},
back_img: {
marginTop: 8,
marginLeft: 8,
height: 20,
width: 20
},
innerRow: {
flexDirection: "row",
justifyContent: "space-between"
},
back_btn: {},
dp: {
height: 35,
width: 35,
borderRadius: 17.5,
marginLeft:5,
marginRight:5
},
messageBlock: {
flexDirection: "column",
borderWidth: 1,
borderColor: "#42C0FB",
padding: 5,
marginLeft: 5,
marginRight: 5,
justifyContent: "center",
alignSelf: "flex-start",
borderRadius: 6,
marginBottom: 5
},
messageBlockRight: {
flexDirection: "column",
backgroundColor: "#fff",
padding: 5,
marginLeft: 5,
marginRight: 5,
justifyContent: "flex-end",
alignSelf: "flex-end",
borderRadius: 6,
marginBottom: 5
},
text: {
color: "#5c5c5c",
alignSelf: "flex-start"
},
time: {
alignSelf: "flex-start",
color: "#5c5c5c",
marginTop:5
},
timeRight: {
alignSelf: "flex-end",
color: "#42C0FB",
marginTop:5
},
textRight: {
color: "#42C0FB",
alignSelf: "flex-end",
textAlign: "right"
},
input:{
borderTopColor:"#e5e5e5",
borderTopWidth:1,
padding:10,
flexDirection:"row",
justifyContent:"space-between"
},
textInput:{
height:50,
width:(width * 0.85),
color:"#e8e8e8",
},
msgAction:{
height:29,
width:29,
marginTop:13
}
});
const username = 'ponty96';
function mapStateToProps(state) {
return {
Chats: state.Chats,
dispatch: state.dispatch
}
}
class ConversationScreen extends Component {
constructor(props) {
super(props);
const ds = new ListView.DataSource({rowHasChanged: (r1, r2) => r1 != r2});
this.state = {
conversation: ds,
text:""
}
}
componentDidMount(){
const {dispatch, Chats} = this.props;
const process_status = Chats.process_status;
const convo_id = this.props.convo_id;
if(process_status === "completed"){
const convos = Chats.chats.filter((val) => {return val.convo_id == convo_id});
convos.sort((a,b)=>{
return moment(a.sent_at).valueOf() - moment(b.sent_at).valueOf();
});
this.setState({
conversation: this.state.conversation.cloneWithRows(convos)
})
}
}
componentWillReceiveProps(nextProps) {
const {dispatch, Chats} = nextProps;
const process_status = Chats.process_status;
const convo_id = this.props.convo_id;
if(process_status === "completed"){
const convos = Chats.chats.filter((val) => {return val.convo_id == convo_id})
convos.sort((a,b)=>{
return moment(a.sent_at).valueOf() - moment(b.sent_at).valueOf();
});
this.setState({
conversation: this.state.conversation.cloneWithRows(convos)
})
}
}
renderSenderUserBlock(data){
return (
<View style={styles.messageBlockRight}>
<Text style={styles.textRight}>
{data.message}
</Text>
<Text style={styles.timeRight}>{moment(data.time).calendar()}</Text>
</View>
)
}
renderReceiverUserBlock(data){
return (
<View style={styles.messageBlock}>
<Text style={styles.text}>
{data.message}
</Text>
<Text style={styles.time}>{moment(data.time).calendar()}</Text>
</View>
)
}
renderRow = (rowData) => {
return (
<View>
{rowData.sender == username ? this.renderSenderUserBlock(rowData) : this.renderReceiverUserBlock(rowData)}
</View>
)
}
sendMessage = () => {
const convo_id = this.props.convo_id;
const receiver = convo_id.substr(0, convo_id.indexOf(username));
const message = this.state.text;
const {dispatch, Chats} = this.props;
dispatch(apiSendChat(receiver,message))
}
render() {
const convo_id = this.props.convo_id;
return (
<View style={styles.container}>
<View style={styles.row}>
<Button
style={styles.back_btn}
onPress={() => Actions.pop()}>
<Image source={require('./../assets/back_chevron.png')} style={styles.back_img}/>
</Button>
<View style={styles.innerRow}>
<Image source={{uri:"https://avatars3.githubusercontent.com/u/11190968?v=3&s=460"}} style={styles.dp}/>
<Text style={styles.main_text}>{ convo_id.substr(0, convo_id.indexOf(username)) }</Text>
</View>
</View>
<ListView
renderRow={this.renderRow}
dataSource={this.state.conversation}/>
<View style={styles.input}>
<TextInput
style={styles.textInput}
onChangeText={(text) => this.setState({text:text})}
placeholder="Type a message"/>
<Button
onPress={this.sendMessage}>
<Image source={require('./../assets/phone.png')} style={styles.msgAction}/>
</Button>
</View>
<KeyboardSpacer/>
</View>
)
}
}
export default connect(mapStateToProps)(ConversationScreen)
lets create out redux actions
import axios from 'axios'
import { AsyncStorage } from 'react-native'
import moment from 'moment'
import Pusher from 'pusher-js/react-native';
export const SEND_CHAT = "SEND_CHAT";
export const GET_ALL_CHATS = "GET_ALL_CHATS";
export const GET_CONVERSATION = "GET_CONVERSATION";
export const IS_FETCHING = "IS_FETCHING";
export const IS_LOADING = "IS_LOADING";
export const IS_ERROR = "IS_ERROR";
export const NEW_MESSAGE = "NEW_MESSAGE";
const add_to_storage = (data) => {
AsyncStorage.setItem(data.convo_id+data.sent_at, JSON.stringify(data), () => {})
}
const sendChat = (payload) => {
return {
type:SEND_CHAT,
payload:payload
};
};
const getChats = (payload) => {
return {
type:GET_ALL_CHATS,
payload:payload
};
};
const newChat = (payload) => {
return {
type:NEW_MESSAGE,
payload:payload
};
};
const getConv = (payload) => {
return {
type:GET_CONVERSATION,
payload:payload
};
};
function isLoading(){
return {
type:IS_LOADING
};
};
function isFetching(){
return {
type:IS_FETCHING
};
};
function isError(){
return {
type:IS_ERROR
};
};
const genConvoId = (sender,receiver) =>{
if(sender < receiver){
return sender+receiver
}
return receiver+sender
}
export function newMesage(dispatch){
var socket = new Pusher("3c01f41582a45afcd689");
const channel = socket.subscribe('chat_channel');
channel.bind('new-message',
(data) => {
add_to_storage(data.chat);
dispatch(newChat(data.chat))
}
);
}
export function startConvo(receiver,message){
const sent_at = moment().format();
const sender = "ponty96";
const convo_id = genConvoId(sender,receiver);
const chat = {sender:sender, receiver:receiver, message:message, convo_id:convo_id,sent_at:sent_at};
return dispatch => {
dispatch(isLoading());
return axios.get(`http://localhost:5000/chat/${JSON.stringify(chat)}`).then(response =>{
}).catch(response =>{
dispatch(isError())
});
};
}
export function apiSendChat(receiver,message){
const sent_at = moment().format();
const sender = "ponty96";
const convo_id = genConvoId(sender,receiver);
const chat = {sender:sender, receiver:receiver, message:message, convo_id:convo_id,sent_at:sent_at};
return dispatch => {
dispatch(isLoading());
return axios.get(`http://localhost:5000/chat/${JSON.stringify(chat)}`).then(response =>{
}).catch(response =>{
dispatch(isError())
});
};
};
export function apiGetChats(){
//get from async storage and not api
return dispatch => {
dispatch(isFetching());
return AsyncStorage.getAllKeys((err, keys) => {
AsyncStorage.multiGet(keys, (err, stores) => {
let chats = [];
stores.map((result, i, store) => {
// get at each store's key/value so you can work with it
chats.push(JSON.parse(store[i][1]))
});
dispatch(getChats(chats))
});
});
};
}
lets create our reducer
import { combineReducers } from 'redux';
import { SEND_CHAT, GET_ALL_CHATS, GET_CONVERSATION, IS_FETCHING, IS_LOADING, IS_ERROR, NEW_MESSAGE} from './../actions'
const Chats = (state = {process_status:"", chats:[]}, actions) => {
switch(actions.type){
case IS_LOADING:
return Object.assign({}, state, {
process_status:"isLoading"
});
case IS_FETCHING:
return Object.assign({}, state, {
process_status:"isFetching"
});
case GET_ALL_CHATS:
return Object.assign({}, state, {
process_status:"completed",
chats:state.chats.concat(actions.payload)
});
case GET_CONVERSATION:
return Object.assign({}, state, {
process_status:"completed",
chats:state.chats.concat(actions.payload)
});
case SEND_CHAT:
case NEW_MESSAGE:
return Object.assign({}, state, {
process_status:"completed",
chats:[...state.chats,actions.payload]
});
default:
return state;
}
};
const rootReducer = combineReducers({
Chats
})
export default rootReducer;
and finally our store
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import createLogger from 'redux-logger'
import rootReducer from '../reducers'
const createStoreWithMiddleware = applyMiddleware(
thunkMiddleware,
createLogger()
)(createStore)
export default function configureStore(initialState) {
const store = createStoreWithMiddleware(rootReducer, initialState)
return store
}