Yaogang Lian

Muffin Tutorial: Build a Twitter Clone (Part 1)

In this tutorial we will use Muffin to build a simple social networking app. Since our goal is to demonstrate how to use Muffin, we would like to focus on the core features of social networking apps. In that regard, what could be a better choice than building a Twitter clone?

Note that we are not building yet another Twitter client, but a full-blown Twitter clone with both frontend and backend. All the project code can be found in this GitHub repository, and the git commit history largely follows this tutorial.

Strategy

Building a clone means that a lot of design work has already been done for us, but we still need a strategy. We are not going to replicate every single feature of Twitter, so we need to think about which features are essential, and which ones are not.

Let’s take a look at the real Twitter.



Twitter’s home page is quite simple, with just a full screen image as the background and a login form.

After logging in, Twitter’s main UI looks like this:



Again, the UI is quite simple: a navigation bar on top, an account summary on the left, and a list of tweets on the right.

The “@Connect” and “#Discover” screens are just variations of the main screen, so we are going to skip those. The compose screen looks like this:



And the profile screen looks like this:



Here you can view a list of my tweets, or a list of people I am following, or a list of my followers, etc.

Inside settings, there are “lists”, “direct messages” and “help”. Direct messages are just variations of tweets, so we are going to skip those as well.

It seems that the essential features of Twitter include:

  1. A login screen.
  2. Compose a new tweet.
  3. View a list of tweets from me and people I follow.
  4. View a list of people I am following.
  5. View a list of my followers.
  6. Follow/unfollow people.
  7. Profile summary.

Other features in Twitter are more or less variations of the above. So let’s focus on implementing these essential features in our Twitter clone. Also, we are not going to implement another user registration system, because we all have way too many accounts already. We are going to let users sign in with their Google Accounts instead.

We also need to think about mobile. Twitter’s website doesn’t use a responsive layout, so it doesn’t work well on mobile. But you can download Twitter’s native mobile apps. The new Twitter app for iOS 7 looks like this:





The mobile interface actually isn’t too different from the desktop interface. Maybe we can create a single responsive layout that works well on both desktop and mobile.

Create a new project

Since we are building a full-blown Twitter clone with both frontend and backend, we need to pick a server stack. Muffin comes with two preferred server stacks: Google App Engine and Node.js/MongoDB. In this tutorial we are going to pick Node.js/MongoDB, simply because we picked Google App Engine in the RSS Reader tutorial.

Creating a new project with Muffin takes just one line:

1
$ muffin new parrot -s nodejs

We have named our project parrot and specified the server stack as Node.js/MongoDB. Why “parrot”? Well, you will know soon.

Now install the dependencies:

1
2
$ cd parrot/
$ muffin install

Since we are using Node.js/MongoDB as the server stack, muffin install also automatically calls npm install inside the server directory to install all the server dependencies.

Let’s put everything under version control:

1
2
3
$ git init
$ git add .
$ git commit -m"Initial commit."

Now start the server while watching files:

1
$ muffin watch -s

This starts a Node.js server at http://localhost:4000. Open the URL in your browser and you should see the app in action.



Great! Our new webapp is up and running.

App name and app icon

Now that we have the project boilerplate set up, let’s change the app name and app icon.

You need to change the app name in two places: in index.jade change title Webapp to title Parrot, and in client/assets/strings/en/str.coffee change title: "Webapp" to title: "Parrot".

For the app icon, we are not going to use Twitter’s blue bird, because that would be a trademark violation. Since we are going to let users sign in with their Google Accounts, let’s add some Google colors to the bird. The final design looks like this:




Now you know why we named our project “parrot”!

Layout

It’s time to get down to business. First we need to create the main application layout. Our strategy is to create a responsive layout that adjusts well on both desktop and mobile.

On the desktop, the main layout consists of a navigation bar on top, an account summary on the left, and a list of tweets on the right. On mobile devices, the main layout consists of a top navigation bar, a list of tweets, and a tab bar at the bottom. We are going to let the list of tweets auto adjust to the screen width, but hide or show the navigation bar or tab bar.

Replace LayoutView.jade with the following:

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
nav.navbar.navbar-inverse.navbar-fixed-top.hidden-xs
.container
ul.nav.navbar-nav
li.active
a(href='#home')
span.glyphicon.glyphicon-home
| Home
li
a(href='#me')
span.glyphicon.glyphicon-user
| Me
li
a(href='#')
span.glyphicon.glyphicon-log-out
| Logout
ul.nav.navbar-nav.pull-right
li
img.logo(src='images/parrot.png')
li
button.btn-compose
span.glyphicon.glyphicon-edit
nav.navbar.navbar-inverse.navbar-fixed-top.visible-xs
ul.nav.navbar-nav.pull-right
li
button.btn-compose
span.glyphicon.glyphicon-edit
#page-container.container
aside
.card
#content-area
ul.tweet-list-view
.tabbar.visible-xs
ul.nav.navbar-nav
li.active
a(href='#home')
span.glyphicon.glyphicon-home
| Home
li
a(href='#me')
span.glyphicon.glyphicon-user
| Me

Also 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
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@import "prefixer.less";
body {
margin-top: 40px;
background: #b2dfda;
}
nav.navbar {
min-height: 40px;
height: 40px;
background-color: #333;
}
nav.navbar.visible-xs {
background: #333 url(../images/parrot.png) no-repeat center center;
background-size: 24px 24px;
}
.navbar-nav {
float: left;
margin: 0;
li {
float: left;
}
}
.nav > li > a {
padding: 0 15px;
font-size: 12px;
font-weight: bold;
line-height: 40px;
.glyphicon {
top: 2px;
font-size: 18px;
margin-right: 6px;
}
}
li.active .glyphicon {
color: #72cbf8;
}
.logo {
height: 24px;
margin-top: 8px;
}
button.btn-compose {
margin: 6px 16px 6px 10px;
padding: 0 8px 5px 10px;
font-size: 20px;
line-height: 20px;
color: white;
border: none;
.box-shadow(inset 0 1px 0 rgba(255,255,255,.30));
background-color: #2c77ba;
.border-radius(4px);
}
.tabbar {
position: fixed;
bottom: 0;
width: 100%;
height: 49px;
background-color: #333;
ul {
width: 100%;
margin: 0;
li {
width: 50%;
text-align: center;
a {
color: #999999;
line-height: 49px;
&:hover {
color: #ffffff;
background-color: transparent;
}
}
&.active a {
color: #ffffff;
background-color: #080808;
}
}
}
}
#page-container {
background: rgba(255, 255, 255, 0.3);
aside {
width: 37%;
float: left;
padding: 15px 5px 15px 16px;
.card {
height: 156px;
background: #f9f9f9;
.border-radius(5px);
border: 1px solid #ccc;
}
}
#content-area {
width: 63%;
float: left;
padding: 15px 16px 15px 8px;
.tweet-list-view {
height: 700px;
background: white;
.border-radius(5px);
border: 1px solid #ccc;
}
}
}
// Phone
@media (max-width: 767px) {
#page-container {
padding: 0;
aside {
display: none;
}
#content-area {
width: 100%;
padding: 0;
.tweet-list-view {
.border-radius(0);
}
}
}
}
// Tablet or Desktop
@media (min-width: 768px) {
.container {
max-width: 870px;
padding: 0;
}
}

This is how it looks on the desktop:



And this is how it looks on my iPod Touch:



The responsive layout works like a charm! The desktop layout mimics Twitter’s website layout quite nicely, and the mobile layout looks very similar to Twitter’s iOS 7 native app, except that we changed the color scheme to better fit our “parrot”.

Tweets

The app layout looks nice but it’s kind of empty. Let’s add some real contents to it. The right side of the desktop layout should show a list of tweets from me and people I follow, so let’s implement that.

First, we need to create a data model for the tweets. Let’s use Muffin’s scaffold generator to quickly create a fully functional CRUD prototype:

1
$ muffin generate scaffold tweet text:string

Muffin generated a list of files including client models and views, server models and controllers, as well as routers. Now navigate to localhost:4000/#tweets and you should see this:



Create a new tweet by pressing the “Add New Tweet” button. You can also edit or delete a tweet. I just added a couple and this is how my screen looks now:



If you turn on Chrome’s developer console, you can see that we are indeed making API requests to the server:




It’s great to have a working CRUD prototype, but the interface isn’t what we had in mind. Let’s make some changes.

Remove the view files that we won’t use, including TweetEditView and TweetShowView. You can use muffin destroy to do this:

1
2
$ muffin destroy view TweetEditView
$ muffin destroy view TweetShowView

We do need a tweet list view, but the auto-generated TweetIndexView is too lengthy. So let’s generate a clean one:

1
$ muffin generate view TweetListView

Copy over just the following from TweetIndexView into our newly minted TweetListView:

1
2
3
4
5
6
# Set up data structures backing the view
@collection = new TweetList()
@collection.on 'reset', @render
@collection.on 'add', @render
@collection.on 'remove', @render
@collection.fetch {reset: true}

Now we can remove the auto-generated TweetIndexView:

1
$ muffin destroy view TweetIndexView

To keep the code modular, we will create another view class IndexPage to manage the main view. In Part 2 we will create another view class ProfilePage for the profile page.

1
$ muffin generate view IndexPage

Move the following from LayoutView.jade into IndexPage.jade:

1
2
3
4
aside
.card
#content-area
.tweet-list-view

The IndexPage also acts as a mediator between the left side of the main layout and the list of tweets. We will see this shortly.

Now we need to update the routes in client/apps/main/router.coffee. Remove the auto-generated routes for tweets, then replace the index function with the following:

1
2
3
4
index: ->
IndexPage = require './views/IndexPage'
v = new IndexPage()
app.layout.setView v

We haven’t made any visible change to the main screen yet. But we are almost there. Let’s flesh out the TweetListView. Add the following to TweetListView.jade:

1
2
3
.tweets-header.hidden-xs
h3 Tweets
.tweets

To show TweetListView in the index page, add the following to the initialize function of IndexPage:

1
2
3
4
5
initialize: ->
@$el.html @template()
# Show a list of tweets on the right
@tweetListView = new TweetListView {el: @$('.tweet-list-view')}

Now we just need to render the list of tweets inside TweetListView. Update TweetListView.coffee to the following:

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'
TweetList = require '../models/TweetList'
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
@tweets = new TweetList()
@tweets.on 'reset', @render
@tweets.on 'add', @addItem
@tweets.on 'remove', @removeItem
@tweets.fetch {reset: true}
addItem: (tweet) =>
$list = @$('.tweets')
$list.prepend @tweetTemplate({tweet: tweet.toJSON()})
render: =>
$list = @$('.tweets').empty()
@tweets.each (tweet) =>
$list.append @tweetTemplate({tweet: tweet.toJSON()})
@
module.exports = TweetListView

We also need to add a partial _tweet.jade to client/apps/main/templates/.

1
2
3
4
5
6
7
8
9
10
.tweet
.tweet-content
.tweet-header
a(href="#")
img.avatar(src="images/default-avatar.jpg")
strong.tweet-author-full-name Yaogang Lian
.tweet-author-username @ylian
p.tweet-text <%- tweet.text %>
.tweet-footer
.tweet-created-at <%- (new Date(tweet.created_at)).toLocaleString() %>

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
@black: #000;
@lightblack: #333;
@gray: #999;
@white: #fff;
@lightgray: #AAA;
// Tweets
.tweets-header {
padding: 12px;
border-bottom: 1px solid #e8e8e8;
h3 {
font-size: 18px;
font-weight: bold;
margin: 0;
}
}
.tweet {
position: relative;
min-height: 75px;
padding: 9px 12px 9px;
border-bottom: 1px solid #e8e8e8;
.tweet-content {
margin-left: 58px;
.tweet-header {
.tweet-author-full-name {
color: @lightblack;
}
.tweet-author-username {
display: inline;
color: @lightgray;
}
}
.tweet-text {
margin: 0.6em 0 0.6em 0;
word-wrap: break-word;
}
.tweet-footer {
overflow: hidden;
color: @gray;
font-size: 12px;
.tweet-created-at {
color: @gray;
}
}
}
}
.avatar {
position: absolute;
top: 12px;
left: 12px;
width: 48px;
height: 48px;
.border-radius(5px);
}
.size32 {
width: 32px;
height: 32px;
}

Let’s take a look at what we have achieved. This is how the app looks on the desktop:



And this is how it looks on the iPod Touch:



The app looks awesome on both desktop and mobile.

Compose a tweet

We need to show the compose view in a lightbox, so let’s install a decent lighbox implementation first —- Lightbox_me. Add the following shim to client/config.json, inside “dependencies”:

1
2
3
4
5
6
7
8
"buckwilson/Lightbox_me": {
"version": "*",
"type": "script",
"main": "jquery.lightbox_me.js",
"dependencies": {
"components/jquery": "*"
}
}

Then run muffin install, and Lighbox_me will be installed inside client/components/.

Now we need to create the compose view. Let’s use muffin generate to do that:

1
$ muffin generate view ComposeView

Flesh out ComposeView.jade:

1
2
3
4
5
6
7
8
.compose-view-container
.compose-header
h3 What's happening?
button.close &times;
textarea.compose-text(placeholder="")
.compose-info
span.compose-character-count 140
button.btn.btn-primary.btn-sm.tweet-button(disabled) Tweet

Add some 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Compose View
.compose-view-container {
.border-radius(6px);
background: #f8f8f8;
.compose-header {
position: relative;
padding: 8px 0;
background: #EFEFEF;
border-bottom: 1px solid #ddd;
.border-radius(6px 6px 0 0);
h3 {
width: 75%;
margin: 0 auto;
text-align: center;
color: #555;
font-size: 14px;
font-weight: bold;
text-shadow: 0 1px 0 #fff;
}
.close {
position: absolute;
right: 10px;
top: 4px;
}
}
textarea {
width: 500px;
height: 100px;
margin: 18px 16px 6px 16px;
border: 1px solid #ccc;
.border-radius(3px);
}
.compose-info {
text-align: right;
padding: 0 4px 8px;
margin-right: 10px;
.compose-character-count {
margin-right: 6px;
color: @gray;
&.invalid {
color: #b83535;
}
}
}
}

We also need to add an event handler to LayoutView.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'
ComposeView = require './ComposeView'
require 'buckwilson/Lightbox_me'
class LayoutView extends Backbone.View
template: _.tpl(require '../templates/LayoutView.html')
events:
'click .btn-compose': 'showComposeView'
initialize: ->
@$el.html @template()
render: => @
setView: (v) ->
@$('#page-container').html v.render().el
showComposeView: ->
v = new ComposeView()
v.render()
v.$el.lightbox_me
centered: true
destroyOnClose: true
module.exports = LayoutView

It’s time to see the compose view in action. This is how it looks like on the desktop:



And on my iPod Touch:



Great! It looks just like the real thing. But we still need to make it functional.

Open ComposeView.coffee and implement event handlers for updating the character count and sending a tweet.

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
Backbone = require 'Backbone'
Tweet = require '../models/Tweet'
class ComposeView extends Backbone.View
template: _.tpl(require '../templates/ComposeView.html')
events:
'click .tweet-button': 'sendTweet'
'keyup textarea': 'updateCounter'
'keypress textarea': 'onKeypress'
initialize: ->
@$el.html @template()
render: => @
updateCounter: (e) ->
$counter = @$('.compose-character-count')
$tweetButton = @$('.tweet-button')
text = @$('.compose-text').val().trim()
valid = Tweet.isTextValid(text)
remaining = Tweet.remainingCharsCount(text.length)
$counter.text remaining
if valid
$counter.removeClass 'invalid'
$tweetButton.removeAttr 'disabled'
else if text.length is 0
$counter.removeClass 'invalid'
$tweetButton.attr 'disabled', 'disabled'
else
$counter.addClass 'invalid'
$tweetButton.attr 'disabled', 'disabled'
onKeypress: (e) ->
if e.which is 13
@sendTweet()
false
sendTweet: (e) ->
text = @$('.compose-text').val()
tweet = new Tweet
tweet.save {text},
success: (model, response) =>
@$('.compose-text').val('').trigger('keyup')
@trigger 'send:tweet', model
logging.debug "Sent Tweet."
@dismiss()
error: (model, response) =>
logging.debug "Failed to send Tweet."
false
dismiss: ->
@$el.trigger('close')
module.exports = ComposeView

We also need to add a few convenient functions to Tweet.coffee:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Backbone = require 'Backbone'
MIN_LENGTH = 1
MAX_LENGTH = 140
class Tweet extends Backbone.Model
urlRoot: "<?= settings.baseURL ?>/tweets"
@remainingCharsCount: (value) ->
MAX_LENGTH - value
@isTextValid: (text) ->
text and MIN_LENGTH <= text.length <= MAX_LENGTH
module.exports = Tweet

You might have also noticed that the tweets are not sorted in the reverse chronological order, so let’s fixed that in TweetList.coffee:

1
2
comparator: (model) ->
-(new Date(model.get('created_at'))).getTime()

Great. It’s all done. Try it on your desktop browser or mobile devices. You should see the character count change as you type, and the “Tweet” button is enabled only if you have entered between 1 and 140 characters.



Now we can remove the auto-generated TweetNewView:

1
$ muffin destroy view TweetNewView

We have done pretty well in this part of the tutorial, so let’s call it a day. In the next part of this tutorial, we will implement Google authentication, the profile page, and follow/unfollow people.

Yaogang Lian

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