var WebKitDatabase = new Class({ Implements: Options, options: { name: 'Database', version: '1.0', description: 'Generic Safari database wrapper', expectedSize: 200000 }, initialize: function(options) { this.accounts = []; if ($type(window.openDatabase) != 'function') { alert('iTwtr does not yet work with browsers other than Safari or Mobile Safari.'); } this.setOptions(options); this.connection = openDatabase(this.options.name, this.options.version, this.options.description, this.options.expectedSize); }, query: function(sql, vars, callback) { if (!$chk(sql)) { return false; } if (!$chk(vars)) { vars = []; } if (!$chk(callback)) { callback = $lambda(true); } this.connection.transaction(function(tx) { tx.executeSql(sql, vars, function(tx, result) { callback(result); }, function(tx, error) { logError(error); }); }); return true; }, transaction: function(queries) { if ($type(queries) != 'array') { return; } this.connection.transaction(function(tx) { this.transactionQuery(tx, queries); }.bind(this)); }, transactionQuery: function(tx, queries) { var query = queries.shift(); if ($type(query) == 'array') { var sql = query[0]; var vars = query[1]; } else if ($type(query) == 'string') { var sql = query; var vars = []; } else if ($type(query) == 'function') { return query(tx, queries); } else { return; } tx.executeSql(sql, vars, function(tx, results) { if (queries.length > 0) { this.transactionQuery(tx, queries); } }.bind(this)); } }); var TwitterClient = new Class({ version: '2.0', sql: { createAccountTable: "CREATE TABLE IF NOT EXISTS account (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "username TEXT UNIQUE, " + "password TEXT" + ")", createTweetTable: "CREATE TABLE IF NOT EXISTS tweet (" + "id INTEGER PRIMARY KEY, " + "account INTEGER, " + "username TEXT, " + "avatar TEXT, " + "created_at DATETIME, " + "content TEXT" + ")", createImageTable: "CREATE TABLE IF NOT EXISTS image (" + "src TEXT PRIMARY KEY, " + "data TEXT " + ")", storeAccount: "INSERT INTO account " + "(username, password) " + "VALUES (?, ?)", getAccounts: "SELECT * " + "FROM account " + "ORDER BY username", getNewestTweetId: "SELECT id " + "FROM tweet " + "WHERE account = ? " + "ORDER BY id DESC " + "LIMIT 1", saveTweet: "INSERT INTO tweet " + "(id, account, username, avatar, created_at, content) " + "VALUES (?, ?, ?, ?, ?, ?)", loadTweetsBeforeId: "SELECT * " + "FROM tweet " + "WHERE account = ? " + "AND id < ? " + "ORDER BY id DESC " + "LIMIT ?", loadRecentTweets: "SELECT * " + "FROM tweet " + "WHERE account = ? " + "ORDER BY id DESC " + "LIMIT 20", loadImage: "SELECT data " + "FROM image " + "WHERE src = ?", saveImage: "INSERT INTO image " + "(src, data) " + "VALUES (?, ?)", dropAccountTable: "DROP TABLE account", dropTweetTable: "DROP TABLE tweet", dropImageTable: "DROP TABLE image" }, initialize: function() { this.db = new WebKitDatabase({ name: 'iTwtr ' + this.version }); this.accounts = []; window.addEvent("domready", function() { if (!Browser.Platform.ipod) { $('container').addClass('non-mobile'); } this.setupDatabase(); }.bind(this)); }, query: function(sqlId, vars, callback) { this.db.query(this.sql[sqlId], vars, callback); }, setupDatabase: function() { this.db.transaction([ this.sql.createAccountTable, this.sql.createTweetTable, this.sql.createImageTable, this.getAccounts.bind(this) ]); }, getAccounts: function() { this.setupInterface(); this.query('getAccounts', [], function(results) { for (var i = 0; i < results.rows.length; i++) { var options = results.rows.item(i); options.client = this; this.accounts.push(new TwitterAccount(this, options)); } if (this.accounts.length == 0) { this.showLoginForm(); } else { this.showAccount(0); } }.bind(this)); }, clearDatabase: function(callback) { var sequence = [ this.sql.dropAccountTable, this.sql.dropTweetTable, this.sql.dropImageTable ]; if ($type(callback) == 'function') { sequence.push(callback); } this.db.transaction(sequence); }, setupInterface: function() { new CachedImage('images/header.png', function(data) { $('header').style.backgroundImage = 'url(' + data + ')'; }); }, showLoginForm: function() { this.setTitle('Please sign in'); this.setupLoginForm(); document.body.set('class', 'login'); $('more').removeClass('visible'); }, showAccount: function(id) { this.setupLogoutLink(); this.setupUpdateLink(); document.body.set('class', 'account'); this.accounts[id].loadTweets(); }, setTitle: function(title) { $('header').getElement('h1').set('text', title); }, setupLoginForm: function() { if (this.loginFormSetup) { return; } this.loginFormSetup = true; $('login').addEvent('submit', function() { var vars = [$('username').value, $('password').value]; this.query('storeAccount', vars, this.getAccounts.bind(this)); return false; }.bind(this)); }, setupLogoutLink: function() { if (this.logoutLinkSetup) { return; } this.logoutLinkSetup = true; $('logout').addEvent('click', function() { this.clearDatabase(this.showLoginForm.bind(this)); this.accounts.each(function(account) { account.destroyTweets(); }); return false; }.bind(this)); }, setupUpdateLink: function() { if (this.updateLinkSetup) { return; } this.updateLinkSetup = true; $('update').addEvent('click', function() { this.accounts[0].setupUpdateForm(); $('update-form').toggleClass('visible'); if ($('update-form').hasClass('visible')) { $('update-input').focus(); } return false; }.bind(this)); }, apiCall: function(path, login, args, callback) { var options = {}; if ($type(callback) == 'function') { options.onComplete = callback; }; if ($type(args) == 'object') { options.data = args; } new JsonP('https://' + login + '@twitter.com' + path, options).request(); } }); var TwitterAccount = new Class({ Implements: Options, options: {}, initialize: function(client, options) { this.setOptions(options); this.client = client; this.id = options.id; this.login = options.username + ':' + options.password; this.tweets = new Hash(); this.upToDate = false; this.pageNum = 0; this.client.setTitle(options.username); $('more').addEvent('click', this.loadTweets.bind(this)); }, loadTweets: function() { $('loading').addClass('visible'); this.pageNum++; dbug.log('loadTweets (page ' + this.pageNum + ')'); if (this.newestTweetId) { if (this.upToDate) { this.loadSavedTweets(); } else if (this.loadMore) { this.loadNewTweets({ page: Math.ceil(this.tweets.getLength() / 20) + 1 }); } else { this.loadNewTweets({ since_id: this.newestTweetId, page: this.pageNum }); } } else { this.client.query('getNewestTweetId', [this.id], function(results) { if (results.rows.length == 1) { this.newestTweetId = results.rows.item(0).id; this.loadNewTweets({ since_id: this.newestTweetId, page: this.pageNum }); } else { this.loadNewTweets({page: this.pageNum}); } }.bind(this)); } return false; }, loadNewTweets: function(args) { dbug.log('loadNewTweets (page ' + args.page + ')'); var path = '/statuses/friends_timeline.json'; this.client.apiCall(path, this.login, args, function(results) { if ($type(results) != 'array') { throw('Oops, something went wrong saving new tweets.'); } dbug.log('Got ' + results.length + ' tweets from Twitter API'); if (results.length > 0) { dbug.log('First tweet is ' + results[0].text); } var queries = []; results.each(function(data) { if (this.tweets[data.id]) { return; } var tweet = new TwitterTweet(this, data); var vars = [tweet.id, this.id, tweet.username, tweet.avatar, tweet.created_at, tweet.content]; queries.push([this.client.sql.saveTweet, vars]); this.tweets[tweet.id] = tweet; this.lastTweetId = tweet.id; tweet.show(); }.bind(this)); dbug.log($$('#main .tweet').length + ' tweets showing'); if (results.length < 20) { this.upToDate = true; queries.push(this.loadSavedTweets.bind(this)); } else { if (!this.newestTweetId) { this.newestTweetId = 1; this.loadMore = true; } $('loading').removeClass('visible'); $('more').addClass('visible'); } this.client.db.transaction(queries); }.bind(this)); }, loadSavedTweets: function() { dbug.log('loadSavedTweets'); if (this.tweets.getLength() == 0) { var query = 'loadRecentTweets'; var vars = [this.id]; var num = 20; dbug.log('Loading 20 tweets from the DB'); } else { var query ='loadTweetsBeforeId'; var num = 20 - this.tweets.getLength() % 20; var vars = [this.id, this.lastTweetId, num]; dbug.log('Loading ' + num + ' tweets from the DB'); } this.client.query(query, vars, function(results) { dbug.log('Got ' + results.rows.length + ' from DB'); for (var i = 0; i < results.rows.length; i++) { var tweet = new TwitterTweet(this, results.rows.item(i)); this.tweets[tweet.id] = tweet; this.lastTweetId = tweet.id; tweet.show(); } dbug.log($$('#main .tweet').length + ' tweets showing'); if (num != results.rows.length) { dbug.log('last tweet was ' + this.tweets[this.lastTweetId].content); this.upToDate = false; this.loadMore = true; this.loadNewTweets({ page: Math.ceil(this.tweets.getLength() / 20) }); } else { $('loading').removeClass('visible'); $('more').addClass('visible'); } }.bind(this)); }, setupUpdateForm: function() { $('update-form').set('action', 'https://' + this.login + '@twitter.com/statuses/update.xml'); if (this.updateFormSetup) { return; } this.updateFormSetup = true; var iframe = new IFrame('update-iframe', { events: { load: function() { if ($('update-iframe').get('src').indexOf('/statuses/update.xml') > -1) { dbug.log('creating new tweet'); var now = new Date(); var tweet = new TwitterTweet(this, { id: 'newTweet' + now.getTime(), account: this.id, username: this.options.username, avatar: 'http://static.twitter.com/images/default_profile_mini.png', // TODO: make the avatar for new tweets match the user's created_at: now.getTime(), content: $('update-input').value }); tweet.show(); dbug.log($$('#main .tweet').length + ' tweets showing'); this.tweets.unshift(tweet); // TODO look up newly posted tweet IDs $('update-form').removeClass('visible'); $('update-input').value = ''; $('update-length').set('text', '140 characters'); } else { dbug.log('other type of api call'); } }.bind(this) } }); $('update-input').addEvent('focus', function() { this.updateInterval = this.setUpdateLength.periodical(500); }.bind(this)); $('update-input').addEvent('blur', function() { $clear(this.updateInterval); }.bind(this)); }, setUpdateLength: function() { var length = 140 - $('update-input').value.length; $('update-count').set('html', length + ' characters'); if (length < 20 && length > 9) { $('update-count').set('class', 'darkred'); } else if (length < 10) { $('update-count').set('class', 'lightred'); } else { $('update-count').set('class', 'normal'); } }, destroyTweets: function() { this.tweets.each(function(tweet) { tweet.destroy(); }); } }); var TwitterTweet = new Class({ initialize: function(account, data) { this.client = data.client; this.id = data.id; this.account = account; this.username = data.username || data.user.screen_name; this.avatar = data.avatar || data.user.profile_image_url; this.created_at = data.created_at; this.content = data.content || data.text; this.favorited = false; // TODO: make this reflect the initial favorited status }, formatContent: function(html) { formatted = html; var urlRegex = /\(?\bhttps?:\/\/[-A-Za-z0-9+&@#\/%?=~_()|!:,.;]*[-A-Za-z0-9+&@#\/%=~_()|]/g; var matches = html.match(urlRegex); if ($type(matches) == 'array') { matches.each(function(url) { if (url.substr(0, 1) == '(' && url.substr(url.length - 1, 1) == ')') { url = substr(1, url.length - 2); } var regex = new RegExp(url.escapeRegExp(), 'g'); if (url.length > 27) { formatted = formatted.replace(regex, '' + url.substr(0, 27) + '...'); } else { formatted = formatted.replace(regex, '' + url + ''); } }); } var userRegex = /@([a-zA-Z0-9_]+)/g; formatted = formatted.replace(userRegex, '@$1'); return formatted; }, show: function() { if (this.displayed) { return; } this.displayed = true; var el = $('tweet').clone(); var where = 'bottom'; if ($type(this.id) == 'string' && this.id.substr(0, 8) == 'newTweet') { where = 'top'; } el.set('id', 'tweet' + this.id); el.getElement('a.user').set('href', 'http://twitter.com/' + this.username); el.getElement('a.user').set('text', this.username); el.getElement('.content').set('html', this.formatContent(this.content)); el.getElement('a.avatar').set('href', 'http://twitter.com/' + this.username); new CachedImage(this.avatar.replace(/_normal\./, '_mini.'), function(data) { new Element('img', { alt: this.username, src: data }).inject(el.getElement('a.avatar')); }); el.inject($('main'), where); this.el = el; if (Browser.Platform.ipod) { this.setupTouchEvents(); } else { this.setupHoverEvents(); } }, setupTouchEvents: function() { var debug = this.el.getElement('.debug'); this.el.ontouchstart = function(e) { if ($type(e.target) == 'element') { if (e.target.hasClass('reply') || e.target.hasClass('favorite')) { return false; } } if (e.touches.length == 1) { this.touchStart = { x: e.touches[0].pageX, y: e.touches[0].pageY }; this.touchToggled = false; } }.bind(this); this.el.ontouchmove = function(e) { if (e.touches.length == 1) { this.touchX = e.touches[0].pageX - this.touchStart.x; this.touchY = e.touches[0].pageY - this.touchStart.y; } }.bind(this); this.el.ontouchend = function(e) { if ($type(e.target) == 'element') { if (e.target.hasClass('reply')) { this.replyClick(); return false; } else if (e.target.hasClass('favorite')) { this.toggleFavorite(); return false; } } if (!this.touchX || !this.touchY) { return; } if (this.touchX > 50 && Math.abs(this.touchY) < 10) { this.toggleOptions(); } else if (this.touchX < -50 && Math.abs(this.touchY) < 10) { this.el.getElement('.options').removeClass('visible'); } }.bind(this); }, setupHoverEvents: function() { this.setupOptions(); var options = this.el.getElement('.options'); //options.fade('hide'); //options.addClass('visible'); this.el.addEvent('mouseenter', function() { this.hoverTimeout = function() { //options.fade('in'); options.addClass('visible'); }.delay(3000, this); }.bind(this)); this.el.addEvent('mouseleave', function(e) { $clear(this.hoverTimeout); //options.fade('out'); options.removeClass('visible'); }.bind(this)); }, toggleOptions: function() { this.setupOptions(); this.el.getElement('.options').toggleClass('visible'); }, setupOptions: function() { if (this.optionsSetup) { return; } this.optionsSetup = true; var link = this.el.getElement('.options .favorite'); if (this.favorited) { link.addClass('full'); } else { link.removeClass('full'); } }, replyClick: function() { this.account.setupUpdateForm(); $('update-input').value = '@' + this.username + ' ' + $('update-input').value; $('update-form').addClass('visible'); var focus = function() { $('update-input').focus(); }; focus.delay(50); return false; }, toggleFavorite: function() { var action = 'https://' + this.account.login + '@twitter.com/favorites/'; var link = this.el.getElement('.options .favorite'); if (this.favorited) { this.favorited = false; $('poster').set('action', action + 'destroy/' + this.id + '.xml'); link.removeClass('full'); } else { this.favorited = true; $('poster').set('action', action + 'create/' + this.id + '.xml'); link.addClass('full'); } $('poster').submit(); return false; }, destroy: function() { if (this.el) { this.el.destroy(); } } }); var CachedImage = new Class({ initialize: function(src, callback) { this.src = src; this.callback = callback; iTwtr.query('loadImage', [src], function(results) { if (results.rows.length == 1) { this.data = results.rows.item(0).data; this.callback(this.data); } else { this.loadData(); } }.bind(this)); }, loadData: function() { new Request({ method: 'GET', url: 'urlify.php?' + encodeURIComponent(this.src), onComplete: this.saveData.bind(this) }).send(); }, saveData: function(data) { this.data = data; this.callback(data); iTwtr.query('saveImage', [this.src, data], $empty); } }); function logError(e) { var line = e.line || e.lineNumber; var url = e.fileName || e.sourceURL; if (url) { var file = url.match(/\/([^\/?]+)[^\/]*$/)[1]; } else { var file = '.'; } var origin = file + ':' + line; dbug.log("Error: " + e.message + " (" + origin + ")"); } dbug.enable(); try { var iTwtr = new TwitterClient(); } catch (e) { logError(e); } //iTwtr.clearDatabase();