Twoot, customized
December 30, 2008 at 12:29 PM by Dr. Drang
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:
- It has a multiline entry box for typing. It takes up more space, but I like to see all the tweet as I type.
- The entry box doesn’t cover any of the messages in the list.
- Each displayed message has a link for sending a proper @Reply. The original Twoot would just stick “@somebody” in the message area. My Twoot also sets the
in_reply_to_status_id
parameter. Some Twitter clients display a little “in reply to” link along with an @Reply - Each displayed message has a favorite indicator/button similar to the one on the main Twitter web page. The star is red if the message is one of your favorites, black otherwise. Clicking on the star toggles the message between favorite and not.
- It has links at the bottom of the message list for going forward and backward through the message history.
- It has a counter that updates as you type, telling you how many characters are left. The counter turns red when you have 20 characters left and changes to “Twoosh!” when you’ve entered 140 exactly. Travis Jeffery’s Twoot customization also has a counter, which Peter Krantz added to the standard version just before Christmas.
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 + ')">✭</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(/[<]+[3]/g, "<tt class='heart'>♥</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:
- The
page
parameter, which the script keeps track of through thePAGE
global variable. By default, Twitter returns messages in pages of 20 messages each. - The
since
parameter, a time value which the script assigns via theLAST_UPDATE
global variable and thegetSinceParameter
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.
-
Maybe I’m not done customizing; that would be a nice feature if I can do it without taking up too much space. ↩