Twoot, customized

A few days ago, I posted a description of Twoot, Twitter client written as a web app that sits on your hard drive instead of on a server. It’s written in a combination of HTML, CSS, and JavaScript and turned into a standalone site-specific browser (SSB) via Fluid. The great thing about Twoot is its clean design, which allows for easy customization.

I’m done with my customizations now, and have a version of Twoot that meets my normal Twittering needs. You can download a tar.gzip archive of my Twoot from its GitHub repository. It differs from Peter Krantz’s original Twoot in many ways, but the main differences are:

Here’s my Twoot, scrolled down so you can see the history links, and with one of the messages favorited.

Here’s the character countdown in action:

Six files control the behavior of my Twoot customization: the jQuery library, the jQuery hotkeys plugin, the jQuery Masked Input library, a simple HTMl file, a CSS style file, and a JavaScript file.

Here’s twoot.htm, the file you set your SSB to point at:

 1:  <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
 2:  <html xmlns="http://www.w3.org/1999/xhtml">
 3:   <head>
 4:     <title>Twoot</title>
 5:     <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
 6:     <script type="text/javascript" src="jquery-latest.pack.js"></script>
 7:     <script type="text/javascript" src="jquery.hotkeys-0.7.8-packed.js"></script>
 8:     <script type="text/javascript" src="jquery.maskedinput-1.2.1.pack.js"></script>
 9:      <script type="text/javascript" src="twoot.js"></script>
10:      <link href="styles/multiline/style.css" rel="stylesheet" type="text/css" />
11:  </head>
12:  <body>
13:   <div class="tweets">
14:     <div id="alert"><p></p></div>
15:     <ul class="tweet_list">
16:       <li>
17:         <a id="older" href="javascript:olderPage()">Older Tweets</a>
18:         <a id="newer" href="javascript:newerPage()">Newer Tweets</a>
19:       </li>
20:     </ul>
21:   </div> 
22:   <div id="message_entry">
23:     <form id="status_entry" method="post", name="status_entry">
24:       <input type="submit" id="send" value="Update" />
25:        <label id="count" for="status" class="normal">140</label>
26:       <textarea name="status" maxlength="140" id="status"
27:         onKeyUp="charCountdown()"></textarea>
28:     </form>
29:   </div>
30:  </body>
31:  </html>

As you see, there’s not much to it. The history links (Lines 17–18) are the only items in the tweet list; the messages themselves will be prepended to the list by the JavaScript.

Here’s the twoot.js file:

  1:  /*
  2:   * The Twitter request code is based on the jquery tweet extension by http://tweet.seaofclouds.com/
  3:   *
  4:   * */
  5:  var LAST_UPDATE;
  6:  var MSG_ID;
  7:  var PAGE = 1;
  8:  
  9:  //Reverse collection
 10:  jQuery.fn.reverse = function() {
 11:    return this.pushStack(this.get().reverse(), arguments);
 12:  }; 
 13:  
 14:  
 15:  (function($) {
 16:   $.fn.gettweets = function(){
 17:    return this.each(function(){
 18:       var list = $('ul.tweet_list').prependTo(this);
 19:       var url = 'http://twitter.com/statuses/friends_timeline.json?page=' + PAGE + getSinceParameter();
 20:       
 21:       $.getJSON(url, function(data){
 22:         $.each(data.reverse(), function(i, item) { 
 23:          if($("#msg-" + item.id).length == 0) { // <- fix for twitter caching which sometimes have problems with the "since" parameter
 24:            list.prepend('<li id="msg-' + item.id + '">' +
 25:            '<img class="profile_image" src="' + 
 26:              item.user.profile_image_url + '" alt="' + item.user.name + '" />' +
 27:            '<span class="time" title="' + item.created_at + '">' +
 28:              relative_time(item.created_at) + '</span> '+
 29:            '<a class="user" href="http://twitter.com/' + 
 30:              item.user.screen_name + '">' +
 31:            item.user.screen_name + '</a> ' +
 32:            '<a class="favorite" title="Toggle favorite status" '+
 33:              'href="javascript:toggleFavorite(' + 
 34:              item.id + ')">&#10029;</a>' +
 35:            '<a class="reply" title="Reply to this" ' +
 36:              'href="javascript:replyTo(\'' +
 37:              item.user.screen_name + '\',' + item.id +
 38:              ')">@</a>' +
 39:            '<div class="tweet_text">' +
 40:            item.text.replace(/(\w+:\/\/[A-Za-z0-9-_]+\.[A-Za-z0-9-_:%&\?\/.=]+)/g, '<a href="$1">$1</a>').replace(/[\@]+([A-Za-z0-9-_]+)/g, '<a href="http://twitter.com/$1">@$1</a>').replace(/[&lt;]+[3]/g, "<tt class='heart'>&#x2665;</tt>") + '</div></li>');
 41:  
 42:            // Change the class if it's a favorite.
 43:            if (item.favorited) {
 44:              $('#msg-' + item.id + ' a.favorite').css('color', 'red');
 45:            }
 46:            
 47:            // Hide the Newer link if we're on the first page.
 48:            if (PAGE == 1) {
 49:              $("#newer").css("visibility", "hidden");
 50:            }
 51:            else {
 52:              $("#newer").css("visibility", "visible");
 53:            }
 54:            
 55:            // The Older link is always visible after the tweets are shown.
 56:            $("#older").css("visibility", "visible");
 57:              
 58:            // Don't want Growl notifications? Comment out the following method call
 59:            fluid.showGrowlNotification({
 60:              title: item.user.name + " @" + item.user.screen_name,
 61:              description: item.text,
 62:              priority: 2,
 63:              icon: item.user.profile_image_url
 64:            });
 65:  
 66:            }
 67:           });
 68:         });
 69:       });
 70:   };
 71:  })(jQuery);
 72:  
 73:  
 74:  function relative_time(time_value) {
 75:    var values = time_value.split(" ");
 76:    time_value = values[1] + " " + values[2] + ", " + values[5] + " " + values[3];
 77:    var parsed_date = Date.parse(time_value);
 78:    var relative_to = (arguments.length > 1) ? arguments[1] : new Date();
 79:    var delta = parseInt((relative_to.getTime() - parsed_date) / 1000);
 80:    delta = delta + (relative_to.getTimezoneOffset() * 60);
 81:    if (delta < 60) {
 82:      return 'less than a minute ago';
 83:    } else if(delta < 120) {
 84:      return 'a minute ago';
 85:    } else if(delta < (45*60)) {
 86:      return (parseInt(delta / 60)).toString() + ' minutes ago';
 87:    } else if(delta < (90*60)) {
 88:      return 'an hour ago';
 89:    } else if(delta < (24*60*60)) {
 90:      return '' + (parseInt(delta / 3600)).toString() + ' hours ago';
 91:    } else if(delta < (48*60*60)) {
 92:      return '1 day ago';
 93:    } else {
 94:      return (parseInt(delta / 86400)).toString() + ' days ago';
 95:    }
 96:  };
 97:  
 98:  
 99:  //get all span.time and recalc from title attribute
100:  function recalcTime() {
101:    $('span.time').each( 
102:        function() {
103:          $(this).text(relative_time($(this).attr("title")));
104:        }
105:    )
106:  }
107:  
108:  
109:  function getSinceParameter() {
110:    if(LAST_UPDATE == null) {
111:      return "";
112:    } else {
113:      return "&since=" + LAST_UPDATE;
114:    }
115:  }
116:  
117:  function showAlert(message) {
118:    $("#alert p").text(message);
119:    $("#alert").fadeIn("fast");
120:    return;
121:  }
122:  
123:  
124:  function refreshMessages() {
125:    showAlert("Getting new tweets...");
126:    $(".tweets").gettweets();
127:    LAST_UPDATE = new Date().toGMTString(); 
128:    $("#alert").fadeOut(2000);
129:    return;
130:  }
131:  
132:  function replyTo(screen_name, msg_id) {
133:    MSG_ID = msg_id;
134:    start = '@' + screen_name + ' ';
135:    $("#status").val(start);
136:    $("#status").focus();
137:    $("#status").caret(start.length, start.length);
138:    return;
139:  }
140:  
141:  function toggleFavorite(id) {
142:    $.getJSON("http://twitter.com/statuses/show/" + id + ".json", 
143:      function(data){
144:        if (data.favorited) {
145:          $.post('http://twitter.com/favorites/destroy/' + id + '.json', {id:msgid});
146:          $('#msg-' + id + ' a.favorite').css('color', 'black');
147:        }
148:        else {
149:          $.post('http://twitter.com/favorites/create/' + id + '.json', {id:msgid});
150:          $('#msg-' + id + ' a.favorite').css('color', 'red');
151:        }
152:      }
153:    );
154:  }
155:  
156:  function olderPage() {
157:    PAGE = PAGE + 1;
158:    LAST_UPDATE = null;
159:    // Hide the paging links before removing the messages. They're made
160:    // visible again by gettweets().
161:    $("#older").css('visibility','hidden');
162:    $("#newer").css('visibility','hidden');
163:    $("ul.tweet_list li[id^=msg]").remove();
164:    refreshMessages();
165:  }
166:  
167:  function newerPage() {
168:    if (PAGE > 1) {
169:      PAGE = PAGE - 1;
170:      LAST_UPDATE = null;
171:      // Hide the paging links before removing the messages. They're made
172:      // visible again by gettweets().
173:      $("#older").css('visibility','hidden');
174:      $("#newer").css('visibility','hidden');
175:      $("ul.tweet_list li[id^=msg]").remove();
176:      refreshMessages();
177:    }
178:  }
179:  
180:  function setStatus(status_text) {
181:    if (status_text[0] == "@" && MSG_ID) {
182:      $.post("http://twitter.com/statuses/update.json", { status: status_text, source: "twoot", in_reply_to_status_id: MSG_ID }, function(data) { refreshStatusField(); }, "json" );
183:      MSG_ID = '';
184:    }
185:    else {
186:      $.post("http://twitter.com/statuses/update.json", { status: status_text, source: "twoot" }, function(data) { refreshStatusField(); }, "json" );
187:    }
188:    return;
189:  }
190:  
191:  function refreshStatusField() {
192:    //maybe show some text below field with last message sent?
193:    refreshMessages();
194:    $("#status").val("");
195:    $('html').animate({scrollTop:0}, 'fast'); 
196:    // added by Dr. Drang to reset char count
197:    $("#count").removeClass("warning");
198:    $("#count").addClass("normal");
199:    $("#count").html("140");
200:    return;
201:  }
202:  
203:  // Count down the number of characters left in the tweet.  Change the
204:  // style to warn the user when there are only 20 characters left. Show
205:  // "Twoosh!" when the tweet is exactly 140 characters long.
206:  function charCountdown() {
207:    charsLeft = 140 - $("#status").val().length;
208:    if (charsLeft <= 20) {
209:      $("#count").removeClass("normal");
210:      $("#count").addClass("warning");
211:    }
212:    else {
213:      $("#count").removeClass("warning");
214:      $("#count").addClass("normal");
215:    }
216:    if (charsLeft == 0) {
217:      $("#count").html("Twoosh!");
218:    }
219:    else {
220:      $("#count").html(String(charsLeft));
221:    }
222:  }
223:  
224:  // set up basic stuff for first load
225:  $(document).ready(function(){
226:  
227:      //get the user's messages
228:      refreshMessages();
229:  
230:      //add event capture to form submit
231:      $("#status_entry").submit(function() {
232:        setStatus($("#status").val());
233:        return false;
234:      });
235:  
236:      //set timer to reload messages every 3 minutes
237:      window.setInterval("refreshMessages()", 3*60*1000);
238:  
239:      //set timer to recalc timestamps every 60 secs
240:      window.setInterval("recalcTime()", 60000);
241:  
242:      //Bind r key to request new messages
243:      $(document).bind('keydown', {combi:'r', disableInInput: true}, refreshMessages);
244:  
245:  });
246:  
247:  
248:  // Reset the bottom margin of the tweet list so the status entry stuff
249:  // doesn't cover the last tweet. This has to be done after the size of
250:  // the #message_entry div is known (load) and whenever the text size is
251:  // changed in the browser (scroll).
252:  
253:  function setBottomMargin() {
254:    $("div.tweets").css("margin-bottom", $("#message_entry").height() + parseInt($("#message_entry").css("border-top-width")));
255:  }
256:  
257:  $(window).load(setBottomMargin);
258:  $(window).scroll(setBottomMargin);

The great bulk of this is Peter Krantz’s work. I’ll confine my description to the parts I added or modified.

The gettweets function, which starts on Line 16, is the workhorse. It uses the Twitter API to collect the messages from your “friends timeline.” Two parameters are sent to Twitter:

  1. The page parameter, which the script keeps track of through the PAGE global variable. By default, Twitter returns messages in pages of 20 messages each.
  2. The since parameter, a time value which the script assigns via the LAST_UPDATE global variable and the getSinceParameter function.

PAGE is initially set to 1 on Line 7, and is changed in the olderPage and newerPage functions in Lines 156–178. LAST_UPDATE is initially null (Line 5). It gets updated periodically by the refreshMessages function in Lines 124–138. When set, it acts as a filter; gettweets will only retrieve messages later than LAST_UPDATE to add to the top of the list. The olderPage and newerPage functions reset LAST_UPDATE to null so an entire page of messages is retrieved and displayed.

For each message, gettweets adds a list item with the user icon, message time (modified by the relative_time function to make it less “digital”), user screen name, links for @Replies and favoriting, and, finally, the text of the message itself. This is all done in Lines 24–40.

Lines 42–45 check whether the message is a favorite and change the color of the star if it is.

Lines 47–56 fiddle with the visibility of the history links. The “Older” link should always be visible; the “Newer” link should be visible unless we’re on Page 1. Why do we have to keep making the “Older” link visible? Because we hide it in the olderPage and newerPage functions, which we’ll describe in a bit.

The replyTo function in Lines 132–139 is called when the user clicks on a message’s “@” link. It puts the screen name of the message’s author (prepended with an “@”) into the entry field, focuses on the entry field, and sets the text insertion point after the name. The caret function used in Line 137 comes from the Masked Input library. The MSG_ID global variable is set to the original message’s id number. This value is then used by the setStatus function in Lines 180–189 to set the in_reply_to_status_id parameter of the update. As mentioned above, this gives the “in reply to” links of other Twitter clients the correct message to point to. The setStatus function will include the in_reply_to_status_id parameter only if both MSG_ID is set and the first character of the entry field is “@.”

The toggleFavorite function in Lines 141–154 is called when the user clicks on a message’s “✭” link. It uses the message’s id number to tell Twitter to flip its favorited status and changes the color of the star as appropriate. This, to put it nicely, is not the most bulletproof code in the world. It sends a command to Twitter, but doesn’t check Twitter’s response—or even if there is a response. I’m sure this will come back to bite me eventually, but right now Favorites aren’t important enough to me to justify the time it would take to figure out how to handle the return value. Maybe someday.

The olderPage and newerPage functions increment or decrement PAGE, set LAST_UPDATE to null so there’s no time filtering, remove the currently-displayed messages from the list, and repopulate the list with an older or newer page of messages. There’s bit of UI niceness in Lines 161–162 and 173–174: the history links are hidden before the messages are removed; that way, the user doesn’t see the “Older” and “Newer” links jump to the top of the window. The history links are made visible again in the gettweets function.

The charCountdown function on Lines 203–222 is called by the onKeyUp event of the entry field. It changes the number in the label above the field with every key press, and changes the label’s class from “normal” to “warning” when there are only 20 characters left. I chose 20 because that’s about the length of the shortened URLs I use. Changing the label from the count to “Twoosh!” when the counter hit zero was a bit of cutesy programming I couldn’t resist.

Twoot looks for new messages according to the time specified in the window.setInterval command on Line 237. Peter Krantz had it set to 65 seconds; I have it set to 3 minutes.

The setBottomMargin function in Lines 248–255 is how my Twoot avoids covering the bottom of the message list with the entry field. It figures out the height of the entry field, including its top border, and gives the message list a bottom margin equal to that value. It’s first called after the page is loaded (Line 257), which is when the height of the entry field is known. Since the height of the entry field is tied to the font size, the Make Text Bigger and Make Text Smaller commands will change the height, and the margin has to change with it. It turns out that whenever the user Makes Text Bigger or Makes Text Smaller, some scrolling is done—either by the user or automatically by the browser. By calling setBottomMargin on the scroll event (Line 258), the margin is adjusted properly on the fly.

Although I’ve made a fair number of additions to Twoot, it really wasn’t all that hard. Twoot’s design is very clean and easy to understand. jQuery is fun to work with because it leverages what you already know from CSS. This was my first jQuery programming and it went quite smoothly. If you’re looking for a Twitter client you can mold to your own needs, you should look into Twoot.

Tags:


  1. Maybe I’m not done customizing; that would be a nice feature if I can do it without taking up too much space.