Yaogang Lian

Muffin Tutorial: Build a Twitter Clone (Part 3)

In the last two parts of this tutorial, we have done a lot of hard work and implemented most of the app features. In this final part, we will finish things up and deploy the app to production.

Finish Things Up

Follow/unfollow

We have implemented the “Who to follow” card and it looks great. Now let’s make the “follow” button functional.

First, we need to implement the APIs on the server side. Let’s add a few routes to server/apps/main/router.coffee:

1
2
3
4
5
6
7
8
app.namespace '/users/:id', ->
# Following
app.get '/following', app.authenticate, FollowingController.index
app.post '/follow/:fid', app.authenticate, FollowingController.create
app.post '/unfollow/:fid', app.authenticate, FollowingController.destroy
# Followers
app.get '/followers', app.authenticate, FollowerController.index

Now create a new file server/apps/main/controllers/FollowingController.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
User = require '../../auth/models/User'
FollowingController =
# GET /users/:id/following
index: (req, res) ->
User
.findById(req.params.id)
.populate('following')
.exec (err, user) ->
if err
res.send(404)
else
res.send(user.following)
# POST /users/:id/follow/:fid
create: (req, res) ->
User.findById req.params.id, (err, user) ->
if user
User.findById req.params.fid, (err, friend) ->
if friend
user.following ?= []
user.following.push friend
user.followingCount += 1
user.save (err) ->
if err
res.send(err, 422)
else
friend.followers ?= []
friend.followers.push user
friend.followerCount += 1
friend.save (err) ->
if err then res.send(err, 422) else res.send({})
else
res.send(404)
else
res.send(404)
# POST /users/:id/unfollow/:fid
destroy: (req, res) ->
User.findById req.params.id, (err, user) ->
if user
User.findById req.params.fid, (err, friend) ->
if friend
user.following.remove(friend)
user.followingCount -= 1
user.save (err) ->
if err
res.send(err, 422)
else
friend.followers.remove(user)
friend.followerCount -= 1
friend.save (err) ->
if err then res.send(err, 422) else res.send({})
else
res.send(404)
else
res.send(404)
module.exports = FollowingController

This controller sets up the route handlers for “follow/unfollow” functionalities.

Create another controller server/apps/main/controllers/FollowerController.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
User = require '../models/User'
FollowerController =
# GET /users/:id/followers
index: (req, res) ->
User
.findById(req.params.id)
.populate('followers')
.exec (err, user) ->
if err
res.send(404)
else
res.send(user.followers)
module.exports = FollowerController

This controller is much simpler. It has just one route handler, which returns a list of a user’s followers. We will use responses from these two APIs to render the list of “following” and “followers” on the profile page.

Now let’s switch to the client side. Add the event handler for the “follow” button in WhoToFollowCard.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
events:
'click .user-to-follow-actions button': 'follow'
follow: (e) ->
$li = $(e.target).closest('.user-to-follow')
userId = $li.attr('data-id')
app.currentUser.follow userId, (err) =>
if err
console.log err
else
index = $li.index()
model = @collection.at(index)
@collection.remove model
$li.remove()
app.trigger 'follow:user'

Also add the following event handlers to UserListView.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
events:
'mouseenter .btn-unfollow': 'showUnfollow'
'mouseleave .btn-unfollow': 'showFollowing'
'click .btn-follow': 'follow'
'click .btn-unfollow': 'unfollow'
showUnfollow: (e) ->
$(e.target).text 'Unfollow'
showFollowing: (e) ->
$(e.target).text 'Following'
follow: (e) ->
$li = $(e.target).closest('.user')
userId = $li.attr('data-id')
app.currentUser.follow userId, (err) =>
if err
console.log err
else
app.trigger 'follow:user'
unfollow: (e) ->
$li = $(e.target).closest('.user')
userId = $li.attr('data-id')
app.currentUser.unfollow userId, (err) =>
if err
console.log err
else
app.trigger 'unfollow:user'

The “follow” and “unfollow” API calls are implemented in client/apps/auth/models/User.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Backbone = require 'Backbone'
class User extends Backbone.Model
urlRoot: "<?= settings.baseURL ?>/users"
follow: (userId, callback) ->
$.ajax
type: 'POST'
url: "<?= settings.baseURL ?>/users/#{@id}/follow/#{userId}"
contentType: 'application/json'
data: JSON.stringify({})
dataType: 'json'
success: (data, status, xhr) =>
logging.info "Did follow user #{userId}"
callback(null)
error: (xhr, status, error) =>
logging.error "Failed to follow user #{userId}"
callback(error)
unfollow: (userId, callback) ->
$.ajax
type: 'POST'
url: "<?= settings.baseURL ?>/users/#{@id}/unfollow/#{userId}"
contentType: 'application/json'
data: JSON.stringify({})
dataType: 'json'
success: (data, status, xhr) =>
logging.info "Did unfollow user #{userId}"
callback(null)
error: (xhr, status, error) =>
logging.error "Failed to unfollow user #{userId}"
callback(error)
module.exports = User

Try it in the browser. The “follow” button should be working.



There are a few more bug fixes and cleanups for the code in this section. Make sure you check git commit history.

Profile picture

It would be nice if we can get the user’s profile picture from Google, however, it’s not part of the user data sent back from Google during user authentication. There is an easy workaround —- we can use the Gravatar associated with the user’s email address.

The Gravatar image URL is based on the MD5 hash of the email address, so let’s create the hash with Node.js’s built-in crypto library. Add the following to server/apps/auth/models/User.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
crypto = require 'crypto'
setEmail = (email) ->
# Derive username from email
email = email.toLowerCase()
@username = email.split('@')[0]
hash = crypto.createHash('md5').update(email).digest('hex')
@profileImageUrl = 'http://www.gravatar.com/avatar/' + hash
email
UserSchema = new Schema
email: {type: String, required: true, unique: true, set: setEmail}
username: {type: String, required: true}
profileImageUrl: String

We have overridden the email setter to create a Gravatar image URL and set it as the user’s profileImageUrl. Refresh the browser and you should see the Gravatar next to the tweets:



And this is how it looks on mobile devices:



Great! The app really starts to take shape.

Profile summary

We have one final feature to implement on the profile page —- a profile summary view. This is a very simple view showing just the user’s profile picture, username, full name, and a few statistics. We already have all the data we need, so let’s create the view.

1
$ muffin generate view ProfileSummaryView

Add the following to ProfileSummaryView.jade:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.profile-container
.profile
img.profile-avatar(src!="<%- user.profileImageUrl || 'images/default_avatar.jpeg' %>", alt!="<%- user.name %>")
.profile-full-name <%- user.name || 'Unknown User' %>
.profile-username @<%- user.username || '' %>
.stats-container
ul.stats
li.stat-tweets
strong <%- user.tweetsCount || 0 %>
| tweets
li.stat-following
strong <%- user.followingCount || 0 %>
| following
li.stat-followers
strong <%- user.followersCount || 0 %>
| followers

And add the following to ProfileSummaryView.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Backbone = require 'Backbone'
class ProfileSummaryView extends Backbone.View
className: 'profile-view'
template: _.tpl(require '../templates/ProfileSummaryView.html')
events: {}
initialize: -> {}
render: =>
data = {user: @model.toJSON()}
@$el.html @template(data)
@
module.exports = ProfileSummaryView

Now, in ProfilePage.coffee, we need to add the profile summary view to the content area.

1
2
3
4
5
initialize: ->
# ...
# Add profile summary view
@profileSummaryView = new ProfileSummaryView {model: app.currentUser}
@$('#content-area').prepend @profileSummaryView.render().el

Finally, add the following styles to main.less:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
.profile-view {
margin: 0 0 1em 0;
background: #f9f9f9;
.border-radius(5px);
.profile-container {
background: #333;
.border-radius(5px 5px 0 0);
.profile {
padding: 20px 0 40px 0;
color: white;
text-align: center;
.profile-avatar {
display: block;
margin: 0 auto;
width: 80px;
height: 80px;
border: 5px solid white;
.border-radius(5px);
}
.profile-full-name {
font-size: 24px;
font-weight: bold;
line-height: 1.8;
}
.profile-username {
font-size: 18px;
line-height: 0.8;
}
}
}
}
.stats {
margin: 0;
padding: 0 12px;
overflow: hidden;
list-style: none;
border: solid #e8e8e8;
border-width: 1px 0 1px 0;
.border-radius(0 0 5px 5px);
> li {
float: left;
padding: 7px 12px;
font-size: 10px;
line-height: 16px;
text-transform: uppercase;
color: @gray;
border: solid #e8e8e8;
border-width: 0 1px 0 0;
&:first-child {
padding-left: 0;
}
&:last-child {
border-right: 0;
}
strong {
display: block;
font-size: 14px;
color: @lightblack;
}
}
}

Refresh your browser and you should see the profile summary view on the profile page.



It looks great on mobile devices, too.



Let’s also add the profile summary to the sidebar on the index page. Since the markup is slightly different, let’s create a new view for it.

1
$ muffin generate view ProfileSummaryCard

Add the following to ProfileSummaryCard.jade:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.account-summary-container
.account-summary
img.account-summary-avatar.avatar.size32(src!="<%- user.profileImageUrl || 'images/default_avatar.jpeg' %>", alt!="<%- user.name %>")
.account-summary-content
strong.account-summary-full-name <%- user.name || 'Unknown User' %>
small.account-summary-metadata
a(href='#me') View my profile page
.stats-container
ul.stats
li.stat-tweets
strong <%- user.tweetsCount || 0 %>
| tweets
li.stat-following
strong <%- user.followingCount || 0 %>
| following
li.stat-followers
strong <%- user.followersCount || 0 %>
| followers

And add the following to ProfileSummaryCard.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Backbone = require 'Backbone'
class ProfileSummaryCard extends Backbone.View
className: 'card'
template: _.tpl(require '../templates/ProfileSummaryCard.html')
events: {}
initialize: -> {}
render: =>
data = {user: @model.toJSON()}
@$el.html @template(data)
@
module.exports = ProfileSummaryCard

Then add the profile summary card to the sidebar on the index page:

1
2
3
4
5
initialize: ->
# ...
# Show profile summary card in the sidebar
@profileSummaryCard = new ProfileSummaryCard {model: app.currentUser}
@$('aside').append @profileSummaryCard.render().el

Finally, add the following styles to main.less:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.account-summary-container {
padding: 12px;
.account-summary {
position: relative;
padding-bottom: 0;
.account-summary-avatar {
top: 0;
left: 0;
}
.account-summary-content {
margin-left: 42px;
}
.account-summary-full-name {
display: block;
font-size: 14px;
}
.account-summary-metadata {
font-size: 11px;
color: @gray;
}
}
}

Now refresh your browser and go to the index page. You should see the profile summary card in the left sidebar.



Timeline

There is one thing we forgot to test on the index page —- the tweet timeline should include tweets from both me and people I follow. To test this, let’s add some fake tweets with the mongo shell.

1
2
3
4
5
6
$ mongo
> use muffin_development
> db.tweets.insert({text: "Learn from yesterday, live for today, hope for tomorrow. The important thing is not to stop questioning.", creator: ObjectId("52473da3e74749514ed2c205")})
> db.tweets.insert({text: "Everything we call real is made of things that cannot be regarded as real.", creator: ObjectId("52473da7e74749514ed2c206")})
> db.tweets.insert({text: "If quantum mechanics hasn't profoundly shocked you, you haven't understood it yet.", creator: ObjectId("52473da7e74749514ed2c206")})
> db.tweets.insert({text: "The world is a dangerous place to live; not because of the people who are evil, but because of the people who don't do anything about it.", creator: ObjectId("52473da3e74749514ed2c205")})

Now refresh the browser and you should see tweets in the timeline from people you follow.



Logout

One last loose end we need to tie is the logout function. We have already created a “Logout” button for the desktop layout, but not for the mobile layout. For simplicity, let’s add a logout button to the navigation bar in the mobile layout.

Open LayoutView.jade and change the mobile navigation bar to:

1
2
3
4
5
6
7
8
9
nav.navbar.navbar-inverse.navbar-fixed-top.visible-xs
ul.nav.navbar-nav.pull-left
li
button.btn-logout
span.glyphicon.glyphicon-log-out
ul.nav.navbar-nav.pull-right
li
button.btn-compose
span.glyphicon.glyphicon-edit

And add the following styles to main.less:

1
2
3
4
5
6
7
8
9
button.btn-logout {
margin: 6px 16px 6px 10px;
padding: 2px 8px 3px 10px;
font-size: 20px;
line-height: 20px;
color: white;
border: none;
background: none;
}

This is how it looks on mobile devices:



That looks pretty good, so let’s move on and make the logout button functional. Add the event handler to LayoutView.coffee:

1
2
3
4
5
events:
'click .btn-logout': 'logout'
logout: (e) ->
apps.auth.logout()

This event handler simply delegates the real work to apps.auth. Open client/apps/auth/app.coffee, and add the following method:

1
2
3
4
5
6
7
8
9
10
logout: ->
@session.destroy
success: (model, response) =>
@clearSession()
# Reload the app to rectify any memory leaks or caching issues.
@router.navigate ''
window.location.reload()
error: (model, xhr) ->
logging.error "Error signing out"

On the server side, we need to add another route handler to server/apps/auth/router.coffee:

1
2
3
app.delete '/session', (req, res) ->
req.logout()
res.send {}

It simply calls Passport’s logout function to clear the session on the server side. The client then reloads the page and will be redirected to the login page. Give it a try.

Optimizations

Now that all the app features have been implemented, let’s make a production build:

1
$ muffin minify

The production build compresses and concatenates the JavaScript files, so the app loads much faster. To test, run the server without watching files:

1
$ muffin server

Refresh your browser and you should see only two scripts loaded when the app starts.



Now the app is ready to be deployed to production.

Deployment

Muffin lets you easily deploy your Node.js/MongoDB app to Heroku or Nodejitsu. Let’s use Heroku as an example. Since Heroku only supports hosted MongoDB databases, we will use an addon from MongoLab.

Create the app:

1
2
3
4
5
$ heroku login
$ heroku create
$ heroku config:add NODE_ENV=production
$ heroku addons:add mongolab
$ git branch deployment

Deploy the app:

1
$ muffin deploy heroku

Now open the app in your browser:

1
$ heroku open


Great! We have just deployed our Parrot app to Heroku.

However, when you press the signin button, the app redirects you to http://localhost:4000/api/v1/verify. We need to change the authentication return URL to the production URL. Open server/apps/auth/router.coffee, replace localhost:4000 with your production app’s URL, commit the changes, and deploy again.

The End

This wasn’t an easy tutorial to write. We have done a lot of hard work and implemented a gorgeous Twitter clone. Now let’s take a look at the amount of code we wrote.



There are 512 lines of CoffeeScript in client/apps.



And there are 231 lines of CoffeeScript in server/apps.

Overall, we wrote less than 800 lines of CoffeeScript for this Twitter clone. Considering the amount of details we have implemented, this is quite good. Here are the final screenshots just to celebrate.











Yaogang Lian

An iOS, Mac and web developer. Focusing on building productivity and educational apps.