Yaogang Lian

Muffin Tutorial: Build a Twitter Clone (Part 2)

In Part 1 we created a working prototype showing a list of tweets. The responsive layout adjusts nicely on both desktop and mobile devices. In this part we are going to continue the hard work, and get most features implemented by the end of this post.

Sign in with Google Account

We are going to let users sign in to our app with their Google Accounts, so that we don’t have to implement yet another user registration system or deal with the underlying security and privacy concerns.

Login page

First, we need to create a login page. Twitter’s login page is quite simple, with just a full screen image as the background and a login form. Let’s create something similar but tailored to our needs.

By now you should be familiar with the multi-app architecture used in all Muffin apps. For example, there are two apps inside client/apps: apps/base handles logging and other basic functions, and apps/main is the main application. Separating a monolithic site into several smaller apps makes the project easier to manage. Following this design principle, we are going to create another app, apps/auth, to handle user authenticaion and sessions.

Create a new folder under client/apps and name it auth. Then add a new file router.coffee in it, with the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Backbone = require 'Backbone'
class Router extends Backbone.Router
routes:
'login': 'login'
initialize: -> {}
login: ->
LoginPage = require './views/LoginPage'
v = new LoginPage {el: 'body'}
module.exports = Router

This is a very simple router with just one route, but it’s all we need for the login page.

Next, add a simple app.coffee inside client/apps/auth/:

1
2
3
4
5
6
7
8
9
10
Router = require './router'
class App
initialize: -> {}
createRouter: ->
@router = new Router
module.exports = App

We also need to create the view and template for the login page:

1
$ muffin generate view LoginPage -a auth

Note that we used -a to specify the app that generated files should go into. Now add the following to LoginPage.jade:

1
2
3
4
5
6
7
8
9
10
11
nav.navbar.navbar-inverse.navbar-fixed-top
.container
ul.nav.navbar-nav
li
img.logo(src='images/parrot.png')
img#parrots-bg(src='images/parrots-in-a-scene.jpg')
.container
.caption
h1 Welcome to Parrot.
p Find out what's happening, right now, with the people and organizations you care about.
a.btn.btn-primary(href='/api/v1/login') Sign In with Google Account

And add the styles in 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
// Login page
#parrots-bg {
position: fixed;
top: 40px;
bottom: 0;
left: 0;
right: 0;
min-width: 100%;
height: 100%;
z-index: -1;
}
.caption {
position: fixed;
right: 5%;
bottom: 10%;
width: 80%;
max-width: 500px;
color: white;
h1 {
font-size: 1.6em;
}
p {
font-size: 1.4em;
line-height: 1.2em;
}
button {
margin-top: 20px;
}
}

Finally, we need to tell apps/main to initialize the auth app. Add the following to client/apps/main/start.coffee:

1
2
3
4
# Create auth app
AuthApp = require '../auth/app'
apps.auth = new AuthApp()
apps.auth.initialize()

It’s time to see it in action. Open your browser and go to localhost:4000/#login. This is how the login page looks on the desktop:



And on my iPod Touch:



Isn’t that gorgeous?

Passport

So far we haven’t worked on the server side yet. To let users sign in to our app with their Google Accounts, we need to implement an authentication strategy on the server side. We are going to use the awesome Passport library for this task.

First, install “Passport” with npm:

1
2
3
$ cd server/
$ npm install passport --save
$ npm install passport-google --save

We also installed “passport-google”, which provides the Google strategy. To use passport as a middleware, we need to add the following to server/start.coffee:

1
2
3
4
5
6
7
passport = require 'passport'
app.configure ->
# ...
app.use passport.initialize()
app.use passport.session()
app.use app.router

The code changes in the rest of this section are quite substantial, so check the git commit history if you are lost.

Although we delegated user authentication to Google, we still need to create a User model on the server side, so that we can link tweets to users. Create a new file server/apps/auth/models/User.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
mongoose = require 'mongoose'
Schema = mongoose.Schema
ObjectId = Schema.ObjectId
UserSchema = new Schema
email: {type: String, required: true, unique: true, set: (v) -> v.toLowerCase()}
name: {type: String, required: true}
openId: {type: String, required: true}
created_at: { type: Date, default: Date.now }
updated_at: Date
User = mongoose.model('User', UserSchema)
module.exports = User

The Google strategy uses OpenID internally, so we store the identifier in the openId field of the user model.

Passport also requires us to set up the authentication strategy and handle two routes. Let’s create a new file server/apps/auth/router.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
passport = require 'passport'
GoogleStrategy = require('passport-google').Strategy
User = require './models/User'
# Serialization
passport.serializeUser (user, done) ->
done(null, user._id)
passport.deserializeUser (id, done) ->
User.findById id, (err, user) ->
done(err, user)
# Use Google Strategy
passport.use(new GoogleStrategy {
returnURL: 'http://localhost:4000/api/v1/verify'
realm: 'http://localhost:4000/'
}, (identifier, profile, done) ->
user = User.findOne {openId: identifier}, (err, user) ->
if user
user.name = profile.displayName
user.email = profile.emails[0].value
else
user = new User {openId: identifier, name: profile.displayName, email: profile.emails[0].value}
user.save (err) ->
done(err, user)
)
# Router
router = (app) ->
app.get '/login', passport.authenticate('google')
app.get '/verify', passport.authenticate('google', {
successRedirect: '/'
failureRedirect: '/#login'
})
module.exports = router

When the user clicks the “Sign In with Google Account” button, the client app makes a request to /api/v1/login, which triggers Passport to execute the Google strategy. Thus the user is redirected to Google for authentication, and upon success will be redirected to the returnURL as set in the strategy. Finally, Passport checks if the user already exists in the database, and creates a new one if not. You can check Passport’s documentation for details.

Now if you go to localhost:4000/#login and click the signin button. You will be asked to sign in with your Google Account:



After signin, you will be redirected to the main screen:



Sweet! However, we are not done yet. If you reset the browser, then go to localhost:4000, it still lets you in. This is because we didn’t put in any logic to check the session when the app starts up. Let’s fixed that.

Check session on app startup

To do this right, we are going to add two model files to apps/auth on the client side. Create a new file client/apps/auth/models/Session.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
Backbone = require 'Backbone'
User = require './User'
class Session extends Backbone.Model
urlRoot: "<?= settings.baseURL ?>/session"
initialize: ->
app.currentUser = @user = new User
parse: (res) ->
if res?.user
@user.set(res.user)
res._id = 0 # Set a fake id so the session object can be properly saved.
res
clear: ->
super
@user.clear()
sync: (method, model, options) =>
options ?= {}
methodName = method.toLowerCase()
switch methodName
when 'create', 'read', 'delete'
options.url = "<?= settings.baseURL ?>/session"
Backbone.sync method, model, options
module.exports = Session

Create another new file client/apps/auth/models/User.coffee:

1
2
3
4
5
6
Backbone = require 'Backbone'
class User extends Backbone.Model
urlRoot: "<?= settings.baseURL ?>/users"
module.exports = User

Creating these two models makes it easier to conceptualize the login process. We will also store the current user’s profile information in the user model.

Next, set up client/apps/auth/app.coffee as follows:

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
#
# The Auth app handles user authentication and sessions.
#
Router = require './router'
Session = require './models/Session'
class App
initialize: ->
@session = new Session
createRouter: ->
@router = new Router
# Force a redirect by resetting the hash first.
redirect: (hash) ->
@router.navigate '#!'
@router.navigate hash, true
# Check if the user is already logged in.
getSession: ->
@session.fetch
success: (model, response) =>
if model.user?.id
# The session is still valid.
@redirect window.location.hash
else
@clearSession()
@redirect '#login'
error: (model, xhr) =>
@clearSession()
@redirect '#login'
# Clear the session
clearSession: ->
@session.clear()
module.exports = App

When the auth app is initialized, it creates a session instance to facilitate session management. We still need to call apps.auth.getSession before the app starts, so add the following to client/apps/main/start.coffee:

1
2
3
4
5
# Ignore initial route. Let AuthApp redirect.
Backbone.history.start {silent: true}
# Get session info and redirect.
apps.auth.getSession()

Using {silent: true}, we asked Backbone not to follow the initial route, but rely on apps.auth to redirect. When the client app starts, it calls apps.auth.getSession to check if a valid session exists. If yes, the client app moves on to the main screen; otherwise, it redirects to the login page.

At last, we need to add another route handler to the server side. Open server/apps/auth/router.coffee and add the following inside the router = (app) -> function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Authentication middleware
app.authenticate = (req, res, next) ->
if req.session.passport.user?
User.findById req.session.passport.user, (err, user) ->
if user
req.user = user
next()
else
res.send(401)
else
res.send(401)
app.get '/session', app.authenticate, (req, res) ->
res.send {user: req.user}

This sets up an authentication middleware that we can use on all the APIs that require authentication. We also added a route handler /session, which returns the current user if a valid session exists, and returns 401 (Unauthorized) if it doesn’t.

There are a few more bug fixes for the code in this section. Make sure you check git commit history. Now open localhost:4000 again and you should be redirected to the login page.

Profile page

Let’s move on to the profile page. Twitter’s profile page has a very similar layout to the index page: a simple list on the left, and a profile summary on the right. Let’s create a new view ProfilePage:

1
$ muffin generate view ProfilePage

The markup for ProfilePage.jade is very similar to that of IndexPage.jade:

1
2
3
4
5
aside
.card
#content-area
.profile-summary
.list-view

Add a new route to client/apps/main/router.coffee:

1
2
3
4
5
6
7
8
routes:
'me': 'showProfile'
showProfile: ->
ProfilePage = require './views/ProfilePage'
v = new ProfilePage()
app.layout.setView v
app.layout.selectTab 'me'

When the user clicks on the navigation tab, we rely on the router to render the page and highlight the selected tab. This way if the user refreshes the page, the same view state will be presented, just like a standard website bookmark does. We need to add the following function to client/apps/main/LayoutView.coffee to highlight the selected tab:

1
2
3
4
selectTab: (tab) ->
$tab = @$(".nav.navbar-nav li[data-tab='#{tab}']")
$tab.siblings().removeClass('active')
$tab.addClass('active')

Test it on both desktop and mobile devices. You should see the new profile page now, although it’s still empty.



Let’s flesh out the profile page.

First, we are going to add a navigation list to the sidebar. Update ProfilePage.jade to:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
aside
.card.profile-nav
ul
li.active(data-tab='tweets')
a(href='#')
span.glyphicon.glyphicon-chevron-right.pull-right
| Tweets
li(data-tab='following')
a(href='#')
span.glyphicon.glyphicon-chevron-right.pull-right
| Following
li(data-tab='followers')
a(href='#')
span.glyphicon.glyphicon-chevron-right.pull-right
| Followers
#content-area
.profile-summary
.list-view

And 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
// Profile page
.card.profile-nav {
ul {
list-style: none;
margin: 0;
padding: 0;
li {
padding: 14px;
vertical-align: middle;
height: 44px;
background: #F9F9F9;
border-bottom: 1px solid #ccc;
a, .glyphicon {
color: @gray;
}
&:first-child {
.border-radius(5px 5px 0 0);
}
&:last-child {
.border-radius(0 0 5px 5px);
border-bottom: none;
}
&.active {
background: white;
a, .glyphicon {
font-weight: bold;
color: #444;
}
}
}
}
}

This is how it looks on the desktop:



Next, let’s wire it up. Add the following to ProfilePage.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
selectTab: (e) ->
$tab = $(e.currentTarget)
tabName = $tab.attr('data-tab')
$tab.siblings().removeClass('active')
$tab.addClass('active')
switch tabName
when 'tweets'
@showTweets()
when 'following'
@showFollowing()
when 'followers'
@showFollowers()
showTweets: ->
# Create a TweetList collection
tweets = new TweetList
tweets.type = 'tweets'
tweets.user = app.currentUser
# Show the tweets
v = new TweetListView {collection: tweets}
@$('.list-view').html v.render().el

Now you should see a list of tweets on the right.



Let’s make the other two lists work, too. Create a new collection client/apps/main/models/FollowingList.coffee:

1
2
3
4
5
6
7
8
9
10
11
Backbone = require 'Backbone'
User = require 'apps/auth/models/User'
class FollowingList extends Backbone.Collection
model: User
url: ->
"<?= settings.baseURL ?>/users/#{@user.id}/following"
module.exports = FollowingList

Also, create client/apps/main/models/FollowerList.coffee:

1
2
3
4
5
6
7
8
9
10
11
Backbone = require 'Backbone'
User = require 'apps/auth/models/User'
class FollowerList extends Backbone.Collection
model: User
url: ->
"<?= settings.baseURL ?>/users/#{@user.id}/followers"
module.exports = FollowerList

Both collections are associated with the User model and only differs in the API endpoint.

We need a new view to display a list of users. Let’s call it UserListView:

1
$ muffin generate view UserListView

Add the following to UserListView.jade:

1
2
3
.users-header
h3 Users
.users

Also create a partial _user.jade for a single user row:

1
2
3
4
5
6
7
8
9
10
11
12
13
.user(data-id!="<%- user._id %>")
.user-content
.user-header
a(href!="#users/<%- user.username %>")
img.avatar(src!="<%- user.profileImageUrl || 'images/default_avatar.jpeg' %>", alt!="<%- user.name || '' %>")
strong.user-full-name <%- user.name || '' %>
.user-username @<%- user.username || '' %>
.user-actions.pull-right
<% if (type == 'following') { %>
button.btn.btn-info Following
<% } else if (type == 'followers') { %>
button.btn Follow
<% } %>

Add the following 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
30
31
Backbone = require 'Backbone'
class UserListView extends Backbone.View
template: _.tpl(require '../templates/UserListView.html')
userTemplate: _.tpl(require '../templates/_user.html')
events: {}
initialize: (@options) ->
@$el.html @template()
# Set header
@type = @options.type
switch @type
when 'following'
@$('.users-header h3').text 'Following'
when 'followers'
@$('.users-header h3').text 'Followers'
# Set up data structures backing the view
@collection.on 'reset', @render
@collection.fetch {reset: true}
render: =>
$list = @$('.users').empty()
@collection.each (user) =>
$list.append @userTemplate({user: user.toJSON(), type: @type})
@
module.exports = UserListView

And 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
// User
.users-header {
padding: 12px;
border-bottom: 1px solid #e8e8e8;
h3 {
font-size: 18px;
font-weight: bold;
margin: 0;
}
}
.user {
position: relative;
min-height: 54px;
padding: 9px 12px 9px;
border-bottom: 1px solid #e8e8e8;
.user-content {
margin-left: 64px;
margin-top: 5px;
.user-header {
.user-full-name {
color: @lightblack;
}
.user-username {
color: @lightgray;
margin-top: 6px;
}
}
.user-actions {
position: absolute;
top: 21px;
right: 12px;
a {
color: purple;
}
}
}
}

At last, we need to add two more methods in ProfilePage.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
showFollowing: ->
# Show people I am following
following = new FollowingList
following.user = app.currentUser
v = new UserListView {collection: following, type: 'following'}
@$('.list-view').html v.render().el
showFollowers: ->
# Show my followers
followers = new FollowerList
followers.user = app.currentUser
v = new UserListView {collection: followers, type: 'followers'}
@$('.list-view').html v.render().el

Now we can click on the Following or Followers tabs.



It works, but the list is empty. Let’s quickly create a “Who to follow” card so that we can follow a random user.

WhoToFollowCard

Use Muffin to generate a view named WhoToFollowCard:

1
$ muffin generate view WhoToFollowCard

Add the following to WhoToFollowCard.jade:

1
2
3
.recommended-header
h3 Who to follow
.recommended-users

Since the user row inside this card is a little different from the regular user row, let’s create a new partial _user_to_follow.jade:

1
2
3
4
5
6
7
8
9
.user-to-follow(data-id!="<%- user._id %>")
.user-to-follow-content
.user-to-follow-header
a(href!="#users/<%- user.username %>")
img.avatar(src!="<%- user.profileImageUrl || 'images/default_avatar.jpeg' %>", alt!="<%- user.name || '' %>")
strong.user-full-name <%- user.name || '' %>
.user-username @<%- user.username || '' %>
.user-to-follow-actions.pull-right
button.btn.btn-info Follow

Now add the following to WhoToFollowCard.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
Backbone = require 'Backbone'
RecommendedList = require '../models/RecommendedList'
class WhoToFollowCard extends Backbone.View
className: 'card'
template: _.tpl(require '../templates/WhoToFollowCard.html')
userTemplate: _.tpl(require '../templates/_user_to_follow.html')
events: {}
initialize: ->
@$el.html @template()
# Set up data structures backing the view
@collection = new RecommendedList
@collection.user = app.currentUser
@collection.on 'reset', @render
@collection.fetch {reset: true}
render: =>
$list = @$('.recommended-users').empty()
@collection.each (user) =>
$list.append @userTemplate({user: user.toJSON()})
@
module.exports = WhoToFollowCard

We also need to add a model file RecommendedList.coffee:

1
2
3
4
5
6
7
8
9
10
Backbone = require 'Backbone'
User = require 'apps/auth/models/User'
class RecommendedList extends Backbone.Collection
model: User
url: ->
"<?= settings.baseURL ?>/users/#{@user.id}/recommended"
module.exports = RecommendedList

Add the styles in 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
// Who to follow
.recommended-header {
padding: 0 1em;
border-bottom: 1px solid #ccc;
h3 {
font-size: 18px;
font-weight: bold;
margin: 10px 0;
}
}
.user-to-follow {
position: relative;
min-height: 75px;
padding: 9px 12px 9px;
border-bottom: 1px solid #ccc;
.user-to-follow-content {
margin-left: 58px;
.user-to-follow-header {
float: left;
.user-full-name {
color: @lightblack;
}
.user-username {
color: @lightgray;
}
}
.user-to-follow-actions {
margin-top: 8px;
}
}
}

Finally, add the “Who to follow” card to the sidebar in ProfilePage.coffee:

1
2
3
4
5
6
initialize: ->
@$el.html @template()
# Show "Who to Follow" card in the sidebar
@whoToFollowCard = new WhoToFollowCard()
@$('aside').append @whoToFollowCard.render().el

Now let’s switch to the server side. First, we need to add a few more fields to the User model. Create a new file server/apps/main/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
mongoose = require 'mongoose'
Schema = mongoose.Schema
ObjectId = Schema.ObjectId
Tweet = require './Tweet'
User = require '../../auth/models/User'
# Add a few more fields to the schema
UserSchema = User.schema
UserSchema.add
username: [type: String, required: true]
profileImageUrl: String
tweets: [{type: ObjectId, ref: 'Tweet'}]
tweetsCount: {type: Number, default: 0}
following: [{type: ObjectId, ref: 'User'}]
followingCount: {type: Number, default: 0}
followers: [{type: ObjectId, ref: 'User'}]
followersCount: {type: Number, default: 0}
module.exports = User

We have added three lists to the user model: tweets stores the ObjectId’s of all the user’s tweets, following stores the ObjectId’s of all the people the user is following, and followers stores the ObjectId’s of all the user’s followers.

Next, create a new file server/apps/main/controllers/RecommendedController.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
User = require '../models/User'
RecommendedController =
# GET /users/:id/recommended
index: (req, res) ->
User.findById req.params.id, (err, user) ->
if user
exclude = [user.id].concat(user.following ? [])
User
.find({_id: {$nin: exclude}})
.select('email name username profileImageUrl')
.limit(20)
.exec (err, users) ->
if err
res.send(404)
else
res.send(users)
else
res.send(404)
module.exports = RecommendedController

This controller sets up a route handler for GET /users/:id/recommended, and returns a random list of users. We will show this list of random users in the “Who to follow” card.

Finally, add the route to server/apps/main/router.coffee:

1
2
app.namespace '/users/:id', ->
app.get '/recommended', app.authenticate, RecommendedController.index

The server side changes are all done, however, we don’t have any users in the database to follow. Let’s quickly create a few fake users with the mongo shell:

1
2
3
4
5
6
$ mongo
> use muffin_development
> db.users.insert({name: 'Isaac Newton', email: 'isaac.newton@gmail.com', username: 'isaac.newton', profileImageUrl: 'http://www.carebreath.org/wp-content/uploads/avatars/5/3dd2d546ccc75cd56f7d8dabcde6f2dc-bpthumb.jpg', openId: '1323jk23jk2'})
> db.users.insert({name: 'Albert Einstein', email: 'albert.einstein@gmail.com', username: 'albert.einstein', profileImageUrl: 'http://cem.com/e107_images/avatars/einstein.gif', openId: '33kj3jjkjkj3'})
> db.users.insert({name: 'Neils Bohr', email: 'neils.bohr@gmail.com', username: 'neils.bohr', profileImageUrl: 'http://www.manaflask.com/uploads/avatars/user/111836/thumb_Niels_Bohr.jpg', openId: 'j3kjk32djs'})
> db.users.insert({name: 'Galileo Galilei', email: 'galileo.galilei@gmail.com', username: 'galileo.galilei', profileImageUrl: 'http://upload.wikimedia.org/wikipedia/commons/c/cc/Galileo.arp.300pix.jpg', openId: 'fjk3jkj2djdi'})

Refresh your browser and finally we see the “Who to follow” card shown in the sidebar:



Great! Now we have a way to follow other users.

You may have noticed that tweets are no longer working. This is expected, because the old tweets were not associated with any user at all. Now that we have implemented the User model, we need to link tweets to users.

We will also differentiate the list of tweets on the index page from those on the profile page. The list on the index page includes tweets from me and people I follow, so let’s call that timeline. And the list on the profile page only includes my own tweets, so let’s call that tweets.

First, add a field creator to the Tweet schema:

1
creator: {type: ObjectId, ref: 'User'}

We need to put all the tweets API endpoints after /users/:id/, so edit server/apps/main/router.coffee as follows:

1
2
3
4
5
6
app.namespace '/users/:id', ->
# Tweets
app.get '/tweets', app.authenticate, TweetController.index
app.get '/timeline', app.authenticate, TweetController.timeline
app.post '/tweets', app.authenticate, TweetController.create
app.delete '/tweets/:tid', app.authenticate, TweetController.destroy

Note that all these API endpoints are behind the authentication middleware, so if the user’s session expires, the client app will redirect to the login page.

We have to change TweetController.coffee quite substantially:

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
Tweet = require '../models/Tweet'
User = require '../models/User'
TweetController =
# GET /users/:id/tweets
index: (req, res) ->
Tweet
.find({creator: req.params.id})
.populate('creator', 'email name username')
.exec (err, tweets) ->
if err
res.send(404)
else
res.send(tweets)
# GET /users/:id/timeline
timeline: (req, res) ->
User.findById req.params.id, (err, user) ->
if user
Tweet
.find({$or: [{creator: req.params.id}, {creator: {$in: (user.following ? [])}}]})
.populate('creator', 'email name username')
.exec (err, tweets) ->
if err
res.send(404)
else
res.send(tweets)
else
res.send(404)
# POST /users/:id/tweets
create: (req, res) ->
User.findById req.params.id, (err, user) ->
if user
# Make sure the user id matches that of current user
if req.user?.id is user.id
tweet = new Tweet(req.body)
tweet.creator = user.id
tweet.created_at = tweet.updated_at = new Date
tweet.save (err) ->
if err
res.send(err, 422)
else
user.tweets ?= []
user.tweets.push tweet
user.tweetsCount += 1
user.save (err) ->
if err then res.send(err, 422) else res.send(tweet)
else
res.send(403)
else
res.send(404)
# DELETE /users/:id/tweets/:tid
destroy: (req, res) ->
Tweet.findById req.params.tid, (err, tweet) ->
if tweet
tweet.remove -> res.send(200)
else
res.send(404)
module.exports = TweetController

That’s all for the server side. Let’s switch to the client side. Change the API endpoints in client/apps/main/models/TweetList.coffee:

1
2
3
4
5
6
url: ->
switch @type
when 'timeline'
"<?= settings.baseURL ?>/users/#{@user.id}/timeline"
when 'tweets'
"<?= settings.baseURL ?>/users/#{@user.id}/tweets"

Next, update _tweet.jade:

1
2
3
4
5
6
7
8
9
10
.tweet
.tweet-content
.tweet-header
a(href!="#users/<%= creator.username %>")
img.avatar(src!="<%- creator.profileImageUrl || 'images/default_avatar.jpeg' %>", alt!="<%- creator.name || '' %>")
strong.tweet-author-full-name <%- creator.name || '' %>
.tweet-author-username @<%- creator.username || '' %>
p.tweet-text <%- tweet.text %>
.tweet-footer
.tweet-created-at <%- (new Date(tweet.created_at)).toLocaleString() %>

We have replaced a few static texts with real profile information. We also need to set a tweet’s creator when creating a new tweet in ComposeView.coffee:

1
2
3
4
5
sendTweet: (e) ->
text = @$('.compose-text').val()
tweet = new Tweet
tweet.creator = app.currentUser
# ...

Now that tweets can either be “timeline” or “tweets”, we need to make TweetListView more generic:

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
Backbone = require 'Backbone'
class TweetListView extends Backbone.View
template: _.tpl(require '../templates/TweetListView.html')
tweetTemplate: _.tpl(require '../templates/_tweet.html')
events: {}
initialize: ->
@$el.html @template()
# Set up data structures backing the view
@collection.on 'reset', @render
@collection.on 'add', @addItem
@collection.on 'remove', @removeItem
@collection.fetch {reset: true}
addItem: (tweet) =>
$list = @$('.tweets')
$list.prepend @tweetTemplate({tweet: tweet.toJSON(), creator: tweet.creator.toJSON()})
render: =>
$list = @$('.tweets').empty()
@collection.each (tweet) =>
$list.append @tweetTemplate({tweet: tweet.toJSON(), creator: tweet.creator.toJSON()})
@
module.exports = TweetListView

Finally, update the initialize function in IndexPage.coffee to show the timeline:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
initialize: ->
@$el.html @template()
# Show "Who to Follow" card in the sidebar
@whoToFollowCard = new WhoToFollowCard()
@$('aside').append @whoToFollowCard.render().el
# Create a TweetList collection
tweets = new TweetList()
tweets.type = 'timeline'
tweets.user = app.currentUser
# Show tweets in the content area
@tweetListView = new TweetListView {collection: tweets}
@$('.tweet-list-view').html @tweetListView.render().el

Now the tweets should be working again. Refresh your browser and give it a try.



We have done a lot of hard work in this post, so let’s call it a day. In the final part of this tutorial, we will finish up the profile page, optimize the app performance, and deploy the app to production.

Yaogang Lian

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