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();