diff options
44 files changed, 3556 insertions, 493 deletions
@@ -1129,6 +1129,9 @@ welcome: nickname of a user account that sends welcome messages to new busy servers it may be a good idea to keep that one just for 'urgent' messages. Default is null; no message. +If either of these special user accounts are specified, the users should +be created before the configuration is updated. + Troubleshooting =============== diff --git a/actions/all.php b/actions/all.php index a92e55462..a53bbea07 100644 --- a/actions/all.php +++ b/actions/all.php @@ -93,7 +93,7 @@ class AllAction extends ProfileAction if (common_logged_in()) { $current_user = common_current_user(); if ($this->user->id === $current_user->id) { - $message .= _('Try subscribing to more people, [join a group](%%action.groups) or post something yourself.'); + $message .= _('Try subscribing to more people, [join a group](%%action.groups%%) or post something yourself.'); } else { $message .= sprintf(_('You can try to [nudge %s](../%s) from his profile or [post something to his or her attention](%%%%action.newnotice%%%%?status_textarea=%s).'), $this->user->nickname, $this->user->nickname, '@' . $this->user->nickname); } diff --git a/actions/finishopenidlogin.php b/actions/finishopenidlogin.php index 952185742..b08b96df6 100644 --- a/actions/finishopenidlogin.php +++ b/actions/finishopenidlogin.php @@ -191,11 +191,28 @@ class FinishopenidloginAction extends Action { # FIXME: save invite code before redirect, and check here - if (common_config('site', 'closed') || common_config('site', 'inviteonly')) { + if (common_config('site', 'closed')) { $this->clientError(_('Registration not allowed.')); return; } + $invite = null; + + if (common_config('site', 'inviteonly')) { + $code = $_SESSION['invitecode']; + if (empty($code)) { + $this->clientError(_('Registration not allowed.')); + return; + } + + $invite = Invitation::staticGet($code); + + if (empty($invite)) { + $this->clientError(_('Not a valid invitation code.')); + return; + } + } + $nickname = $this->trimmed('newname'); if (!Validate::string($nickname, array('min_length' => 1, @@ -257,10 +274,16 @@ class FinishopenidloginAction extends Action # XXX: add language # XXX: add timezone - $user = User::register(array('nickname' => $nickname, - 'email' => $email, - 'fullname' => $fullname, - 'location' => $location)); + $args = array('nickname' => $nickname, + 'email' => $email, + 'fullname' => $fullname, + 'location' => $location); + + if (!empty($invite)) { + $args['code'] = $invite->code; + } + + $user = User::register($args); $result = oid_link_user($user->id, $canonical, $display); diff --git a/actions/grouprss.php b/actions/grouprss.php index a9a2eef87..0b7280a11 100644 --- a/actions/grouprss.php +++ b/actions/grouprss.php @@ -34,7 +34,7 @@ if (!defined('LACONICA')) { require_once INSTALLDIR.'/lib/rssaction.php'; -define('MEMBERS_PER_SECTION', 81); +define('MEMBERS_PER_SECTION', 27); /** * Group RSS feed diff --git a/actions/newmessage.php b/actions/newmessage.php index 82276ff34..52d4899ba 100644 --- a/actions/newmessage.php +++ b/actions/newmessage.php @@ -172,15 +172,54 @@ class NewmessageAction extends Action $this->notify($user, $this->other, $message); - $url = common_local_url('outbox', array('nickname' => $user->nickname)); + if ($this->boolean('ajax')) { + $this->startHTML('text/xml;charset=utf-8'); + $this->elementStart('head'); + $this->element('title', null, _('Message sent')); + $this->elementEnd('head'); + $this->elementStart('body'); + $this->element('p', array('id' => 'command_result'), + sprintf(_('Direct message to %s sent'), + $this->other->nickname)); + $this->elementEnd('body'); + $this->elementEnd('html'); + } else { + $url = common_local_url('outbox', + array('nickname' => $user->nickname)); + common_redirect($url, 303); + } + } - common_redirect($url, 303); + /** + * Show an Ajax-y error message + * + * Goes back to the browser, where it's shown in a popup. + * + * @param string $msg Message to show + * + * @return void + */ + + function ajaxErrorMsg($msg) + { + $this->startHTML('text/xml;charset=utf-8', true); + $this->elementStart('head'); + $this->element('title', null, _('Ajax Error')); + $this->elementEnd('head'); + $this->elementStart('body'); + $this->element('p', array('id' => 'error'), $msg); + $this->elementEnd('body'); + $this->elementEnd('html'); } function showForm($msg = null) { - $this->msg = $msg; + if ($msg && $this->boolean('ajax')) { + $this->ajaxErrorMsg($msg); + return; + } + $this->msg = $msg; $this->showPage(); } diff --git a/actions/profilesettings.php b/actions/profilesettings.php index 60f7c0796..fb847680b 100644 --- a/actions/profilesettings.php +++ b/actions/profilesettings.php @@ -91,67 +91,68 @@ class ProfilesettingsAction extends AccountSettingsAction $this->element('legend', null, _('Profile information')); $this->hidden('token', common_session_token()); - # too much common patterns here... abstractable? - + // too much common patterns here... abstractable? $this->elementStart('ul', 'form_data'); - $this->elementStart('li'); - $this->input('nickname', _('Nickname'), - ($this->arg('nickname')) ? $this->arg('nickname') : $profile->nickname, - _('1-64 lowercase letters or numbers, no punctuation or spaces')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->input('fullname', _('Full name'), - ($this->arg('fullname')) ? $this->arg('fullname') : $profile->fullname); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->input('homepage', _('Homepage'), - ($this->arg('homepage')) ? $this->arg('homepage') : $profile->homepage, - _('URL of your homepage, blog, or profile on another site')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->textarea('bio', _('Bio'), - ($this->arg('bio')) ? $this->arg('bio') : $profile->bio, - _('Describe yourself and your interests in 140 chars')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->input('location', _('Location'), - ($this->arg('location')) ? $this->arg('location') : $profile->location, - _('Where you are, like "City, State (or Region), Country"')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->input('tags', _('Tags'), - ($this->arg('tags')) ? $this->arg('tags') : implode(' ', $user->getSelfTags()), - _('Tags for yourself (letters, numbers, -, ., and _), comma- or space- separated')); - $this->elementEnd('li'); - $this->elementStart('li'); - $language = common_language(); - $this->dropdown('language', _('Language'), - get_nice_language_list(), _('Preferred language'), - true, $language); - $this->elementEnd('li'); - $timezone = common_timezone(); - $timezones = array(); - foreach(DateTimeZone::listIdentifiers() as $k => $v) { - $timezones[$v] = $v; + if (Event::handle('StartProfileFormData', array($this))) { + $this->elementStart('li'); + $this->input('nickname', _('Nickname'), + ($this->arg('nickname')) ? $this->arg('nickname') : $profile->nickname, + _('1-64 lowercase letters or numbers, no punctuation or spaces')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->input('fullname', _('Full name'), + ($this->arg('fullname')) ? $this->arg('fullname') : $profile->fullname); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->input('homepage', _('Homepage'), + ($this->arg('homepage')) ? $this->arg('homepage') : $profile->homepage, + _('URL of your homepage, blog, or profile on another site')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->textarea('bio', _('Bio'), + ($this->arg('bio')) ? $this->arg('bio') : $profile->bio, + _('Describe yourself and your interests in 140 chars')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->input('location', _('Location'), + ($this->arg('location')) ? $this->arg('location') : $profile->location, + _('Where you are, like "City, State (or Region), Country"')); + $this->elementEnd('li'); + Event::handle('EndProfileFormData', array($this)); + $this->elementStart('li'); + $this->input('tags', _('Tags'), + ($this->arg('tags')) ? $this->arg('tags') : implode(' ', $user->getSelfTags()), + _('Tags for yourself (letters, numbers, -, ., and _), comma- or space- separated')); + $this->elementEnd('li'); + $this->elementStart('li'); + $language = common_language(); + $this->dropdown('language', _('Language'), + get_nice_language_list(), _('Preferred language'), + false, $language); + $this->elementEnd('li'); + $timezone = common_timezone(); + $timezones = array(); + foreach(DateTimeZone::listIdentifiers() as $k => $v) { + $timezones[$v] = $v; + } + $this->elementStart('li'); + $this->dropdown('timezone', _('Timezone'), + $timezones, _('What timezone are you normally in?'), + true, $timezone); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->checkbox('autosubscribe', + _('Automatically subscribe to whoever '. + 'subscribes to me (best for non-humans)'), + ($this->arg('autosubscribe')) ? + $this->boolean('autosubscribe') : $user->autosubscribe); + $this->elementEnd('li'); } - $this->elementStart('li'); - $this->dropdown('timezone', _('Timezone'), - $timezones, _('What timezone are you normally in?'), - true, $timezone); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->checkbox('autosubscribe', - _('Automatically subscribe to whoever '. - 'subscribes to me (best for non-humans)'), - ($this->arg('autosubscribe')) ? - $this->boolean('autosubscribe') : $user->autosubscribe); - $this->elementEnd('li'); $this->elementEnd('ul'); $this->submit('save', _('Save')); $this->elementEnd('fieldset'); $this->elementEnd('form'); - } /** @@ -165,158 +166,158 @@ class ProfilesettingsAction extends AccountSettingsAction function handlePost() { - # CSRF protection - + // CSRF protection $token = $this->trimmed('token'); if (!$token || $token != common_session_token()) { $this->showForm(_('There was a problem with your session token. '. - 'Try again, please.')); + 'Try again, please.')); return; } - $nickname = $this->trimmed('nickname'); - $fullname = $this->trimmed('fullname'); - $homepage = $this->trimmed('homepage'); - $bio = $this->trimmed('bio'); - $location = $this->trimmed('location'); - $autosubscribe = $this->boolean('autosubscribe'); - $language = $this->trimmed('language'); - $timezone = $this->trimmed('timezone'); - $tagstring = $this->trimmed('tags'); - - # Some validation - - if (!Validate::string($nickname, array('min_length' => 1, - 'max_length' => 64, - 'format' => VALIDATE_NUM . VALIDATE_ALPHA_LOWER))) { - $this->showForm(_('Nickname must have only lowercase letters and numbers and no spaces.')); - return; - } else if (!User::allowed_nickname($nickname)) { - $this->showForm(_('Not a valid nickname.')); - return; - } else if (!is_null($homepage) && (strlen($homepage) > 0) && - !Validate::uri($homepage, array('allowed_schemes' => array('http', 'https')))) { - $this->showForm(_('Homepage is not a valid URL.')); - return; - } else if (!is_null($fullname) && mb_strlen($fullname) > 255) { - $this->showForm(_('Full name is too long (max 255 chars).')); - return; - } else if (!is_null($bio) && mb_strlen($bio) > 140) { - $this->showForm(_('Bio is too long (max 140 chars).')); - return; - } else if (!is_null($location) && mb_strlen($location) > 255) { - $this->showForm(_('Location is too long (max 255 chars).')); - return; - } else if (is_null($timezone) || !in_array($timezone, DateTimeZone::listIdentifiers())) { - $this->showForm(_('Timezone not selected.')); - return; - } else if ($this->nicknameExists($nickname)) { - $this->showForm(_('Nickname already in use. Try another one.')); - return; - } else if (!is_null($language) && strlen($language) > 50) { - $this->showForm(_('Language is too long (max 50 chars).')); - return; - } + if (Event::handle('StartProfileSaveForm', array($this))) { + + $nickname = $this->trimmed('nickname'); + $fullname = $this->trimmed('fullname'); + $homepage = $this->trimmed('homepage'); + $bio = $this->trimmed('bio'); + $location = $this->trimmed('location'); + $autosubscribe = $this->boolean('autosubscribe'); + $language = $this->trimmed('language'); + $timezone = $this->trimmed('timezone'); + $tagstring = $this->trimmed('tags'); + + // Some validation + if (!Validate::string($nickname, array('min_length' => 1, + 'max_length' => 64, + 'format' => VALIDATE_NUM . VALIDATE_ALPHA_LOWER))) { + $this->showForm(_('Nickname must have only lowercase letters and numbers and no spaces.')); + return; + } else if (!User::allowed_nickname($nickname)) { + $this->showForm(_('Not a valid nickname.')); + return; + } else if (!is_null($homepage) && (strlen($homepage) > 0) && + !Validate::uri($homepage, array('allowed_schemes' => array('http', 'https')))) { + $this->showForm(_('Homepage is not a valid URL.')); + return; + } else if (!is_null($fullname) && mb_strlen($fullname) > 255) { + $this->showForm(_('Full name is too long (max 255 chars).')); + return; + } else if (!is_null($bio) && mb_strlen($bio) > 140) { + $this->showForm(_('Bio is too long (max 140 chars).')); + return; + } else if (!is_null($location) && mb_strlen($location) > 255) { + $this->showForm(_('Location is too long (max 255 chars).')); + return; + } else if (is_null($timezone) || !in_array($timezone, DateTimeZone::listIdentifiers())) { + $this->showForm(_('Timezone not selected.')); + return; + } else if ($this->nicknameExists($nickname)) { + $this->showForm(_('Nickname already in use. Try another one.')); + return; + } else if (!is_null($language) && strlen($language) > 50) { + $this->showForm(_('Language is too long (max 50 chars).')); + return; + } - if ($tagstring) { - $tags = array_map('common_canonical_tag', preg_split('/[\s,]+/', $tagstring)); - } else { - $tags = array(); - } + if ($tagstring) { + $tags = array_map('common_canonical_tag', preg_split('/[\s,]+/', $tagstring)); + } else { + $tags = array(); + } - foreach ($tags as $tag) { - if (!common_valid_profile_tag($tag)) { - $this->showForm(sprintf(_('Invalid tag: "%s"'), $tag)); - return; + foreach ($tags as $tag) { + if (!common_valid_profile_tag($tag)) { + $this->showForm(sprintf(_('Invalid tag: "%s"'), $tag)); + return; + } } - } - $user = common_current_user(); + $user = common_current_user(); - $user->query('BEGIN'); + $user->query('BEGIN'); - if ($user->nickname != $nickname || - $user->language != $language || - $user->timezone != $timezone) { + if ($user->nickname != $nickname || + $user->language != $language || + $user->timezone != $timezone) { - common_debug('Updating user nickname from ' . $user->nickname . ' to ' . $nickname, - __FILE__); - common_debug('Updating user language from ' . $user->language . ' to ' . $language, - __FILE__); - common_debug('Updating user timezone from ' . $user->timezone . ' to ' . $timezone, - __FILE__); + common_debug('Updating user nickname from ' . $user->nickname . ' to ' . $nickname, + __FILE__); + common_debug('Updating user language from ' . $user->language . ' to ' . $language, + __FILE__); + common_debug('Updating user timezone from ' . $user->timezone . ' to ' . $timezone, + __FILE__); - $original = clone($user); + $original = clone($user); - $user->nickname = $nickname; - $user->language = $language; - $user->timezone = $timezone; + $user->nickname = $nickname; + $user->language = $language; + $user->timezone = $timezone; - $result = $user->updateKeys($original); + $result = $user->updateKeys($original); - if ($result === false) { - common_log_db_error($user, 'UPDATE', __FILE__); - $this->serverError(_('Couldn\'t update user.')); - return; - } else { - # Re-initialize language environment if it changed - common_init_language(); + if ($result === false) { + common_log_db_error($user, 'UPDATE', __FILE__); + $this->serverError(_('Couldn\'t update user.')); + return; + } else { + // Re-initialize language environment if it changed + common_init_language(); + } } - } - - # XXX: XOR - if ($user->autosubscribe ^ $autosubscribe) { +// XXX: XOR + if ($user->autosubscribe ^ $autosubscribe) { - $original = clone($user); + $original = clone($user); - $user->autosubscribe = $autosubscribe; + $user->autosubscribe = $autosubscribe; - $result = $user->update($original); + $result = $user->update($original); - if ($result === false) { - common_log_db_error($user, 'UPDATE', __FILE__); - $this->serverError(_('Couldn\'t update user for autosubscribe.')); - return; + if ($result === false) { + common_log_db_error($user, 'UPDATE', __FILE__); + $this->serverError(_('Couldn\'t update user for autosubscribe.')); + return; + } } - } - - $profile = $user->getProfile(); - $orig_profile = clone($profile); + $profile = $user->getProfile(); - $profile->nickname = $user->nickname; - $profile->fullname = $fullname; - $profile->homepage = $homepage; - $profile->bio = $bio; - $profile->location = $location; - $profile->profileurl = common_profile_url($nickname); + $orig_profile = clone($profile); - common_debug('Old profile: ' . common_log_objstring($orig_profile), __FILE__); - common_debug('New profile: ' . common_log_objstring($profile), __FILE__); + $profile->nickname = $user->nickname; + $profile->fullname = $fullname; + $profile->homepage = $homepage; + $profile->bio = $bio; + $profile->location = $location; + $profile->profileurl = common_profile_url($nickname); - $result = $profile->update($orig_profile); + common_debug('Old profile: ' . common_log_objstring($orig_profile), __FILE__); + common_debug('New profile: ' . common_log_objstring($profile), __FILE__); - if (!$result) { - common_log_db_error($profile, 'UPDATE', __FILE__); - $this->serverError(_('Couldn\'t save profile.')); - return; - } + $result = $profile->update($orig_profile); - # Set the user tags + if (!$result) { + common_log_db_error($profile, 'UPDATE', __FILE__); + $this->serverError(_('Couldn\'t save profile.')); + return; + } - $result = $user->setSelfTags($tags); + // Set the user tags + $result = $user->setSelfTags($tags); - if (!$result) { - $this->serverError(_('Couldn\'t save tags.')); - return; - } + if (!$result) { + $this->serverError(_('Couldn\'t save tags.')); + return; + } - $user->query('COMMIT'); + $user->query('COMMIT'); + Event::handle('EndProfileSaveForm', array($this)); + common_broadcast_profile($profile); - common_broadcast_profile($profile); + $this->showForm(_('Settings saved.'), true); - $this->showForm(_('Settings saved.'), true); + } } function nicknameExists($nickname) diff --git a/actions/register.php b/actions/register.php index 5d7a8ce69..033cf557f 100644 --- a/actions/register.php +++ b/actions/register.php @@ -56,6 +56,45 @@ class RegisterAction extends Action var $registered = false; /** + * Prepare page to run + * + * + * @param $args + * @return string title + */ + + function prepare($args) + { + parent::prepare($args); + $this->code = $this->trimmed('code'); + + if (empty($this->code)) { + common_ensure_session(); + if (array_key_exists('invitecode', $_SESSION)) { + $this->code = $_SESSION['invitecode']; + } + } + + if (common_config('site', 'inviteonly') && empty($this->code)) { + $this->clientError(_('Sorry, only invited people can register.')); + return false; + } + + if (!empty($this->code)) { + $this->invite = Invitation::staticGet('code', $this->code); + if (empty($this->invite)) { + $this->clientError(_('Sorry, invalid invitation code.')); + return false; + } + // Store this in case we need it + common_ensure_session(); + $_SESSION['invitecode'] = $this->code; + } + + return true; + } + + /** * Title of the page * * @return string title @@ -108,107 +147,109 @@ class RegisterAction extends Action function tryRegister() { - $token = $this->trimmed('token'); - if (!$token || $token != common_session_token()) { - $this->showForm(_('There was a problem with your session token. '. - 'Try again, please.')); - return; - } - - $nickname = $this->trimmed('nickname'); - $email = $this->trimmed('email'); - $fullname = $this->trimmed('fullname'); - $homepage = $this->trimmed('homepage'); - $bio = $this->trimmed('bio'); - $location = $this->trimmed('location'); - - // We don't trim these... whitespace is OK in a password! - - $password = $this->arg('password'); - $confirm = $this->arg('confirm'); + if (Event::handle('StartRegistrationTry', array($this))) { + $token = $this->trimmed('token'); + if (!$token || $token != common_session_token()) { + $this->showForm(_('There was a problem with your session token. '. + 'Try again, please.')); + return; + } - // invitation code, if any + $nickname = $this->trimmed('nickname'); + $email = $this->trimmed('email'); + $fullname = $this->trimmed('fullname'); + $homepage = $this->trimmed('homepage'); + $bio = $this->trimmed('bio'); + $location = $this->trimmed('location'); - $code = $this->trimmed('code'); + // We don't trim these... whitespace is OK in a password! + $password = $this->arg('password'); + $confirm = $this->arg('confirm'); - if ($code) { - $invite = Invitation::staticGet($code); - } + // invitation code, if any + $code = $this->trimmed('code'); - if (common_config('site', 'inviteonly') && !($code && $invite)) { - $this->clientError(_('Sorry, only invited people can register.')); - return; - } + if ($code) { + $invite = Invitation::staticGet($code); + } - // Input scrubbing - - $nickname = common_canonical_nickname($nickname); - $email = common_canonical_email($email); - - if (!$this->boolean('license')) { - $this->showForm(_('You can\'t register if you don\'t '. - 'agree to the license.')); - } else if ($email && !Validate::email($email, true)) { - $this->showForm(_('Not a valid email address.')); - } else if (!Validate::string($nickname, array('min_length' => 1, - 'max_length' => 64, - 'format' => NICKNAME_FMT))) { - $this->showForm(_('Nickname must have only lowercase letters '. - 'and numbers and no spaces.')); - } else if ($this->nicknameExists($nickname)) { - $this->showForm(_('Nickname already in use. Try another one.')); - } else if (!User::allowed_nickname($nickname)) { - $this->showForm(_('Not a valid nickname.')); - } else if ($this->emailExists($email)) { - $this->showForm(_('Email address already exists.')); - } else if (!is_null($homepage) && (strlen($homepage) > 0) && - !Validate::uri($homepage, - array('allowed_schemes' => - array('http', 'https')))) { - $this->showForm(_('Homepage is not a valid URL.')); - return; - } else if (!is_null($fullname) && mb_strlen($fullname) > 255) { - $this->showForm(_('Full name is too long (max 255 chars).')); - return; - } else if (!is_null($bio) && mb_strlen($bio) > 140) { - $this->showForm(_('Bio is too long (max 140 chars).')); - return; - } else if (!is_null($location) && mb_strlen($location) > 255) { - $this->showForm(_('Location is too long (max 255 chars).')); - return; - } else if (strlen($password) < 6) { - $this->showForm(_('Password must be 6 or more characters.')); - return; - } else if ($password != $confirm) { - $this->showForm(_('Passwords don\'t match.')); - } else if ($user = User::register(array('nickname' => $nickname, - 'password' => $password, - 'email' => $email, - 'fullname' => $fullname, - 'homepage' => $homepage, - 'bio' => $bio, - 'location' => $location, - 'code' => $code))) { - if (!$user) { - $this->showForm(_('Invalid username or password.')); + if (common_config('site', 'inviteonly') && !($code && $invite)) { + $this->clientError(_('Sorry, only invited people can register.')); return; } - // success! - if (!common_set_user($user)) { - $this->serverError(_('Error setting user.')); + + // Input scrubbing + $nickname = common_canonical_nickname($nickname); + $email = common_canonical_email($email); + + if (!$this->boolean('license')) { + $this->showForm(_('You can\'t register if you don\'t '. + 'agree to the license.')); + } else if ($email && !Validate::email($email, true)) { + $this->showForm(_('Not a valid email address.')); + } else if (!Validate::string($nickname, array('min_length' => 1, + 'max_length' => 64, + 'format' => NICKNAME_FMT))) { + $this->showForm(_('Nickname must have only lowercase letters '. + 'and numbers and no spaces.')); + } else if ($this->nicknameExists($nickname)) { + $this->showForm(_('Nickname already in use. Try another one.')); + } else if (!User::allowed_nickname($nickname)) { + $this->showForm(_('Not a valid nickname.')); + } else if ($this->emailExists($email)) { + $this->showForm(_('Email address already exists.')); + } else if (!is_null($homepage) && (strlen($homepage) > 0) && + !Validate::uri($homepage, + array('allowed_schemes' => + array('http', 'https')))) { + $this->showForm(_('Homepage is not a valid URL.')); return; + } else if (!is_null($fullname) && mb_strlen($fullname) > 255) { + $this->showForm(_('Full name is too long (max 255 chars).')); + return; + } else if (!is_null($bio) && mb_strlen($bio) > 140) { + $this->showForm(_('Bio is too long (max 140 chars).')); + return; + } else if (!is_null($location) && mb_strlen($location) > 255) { + $this->showForm(_('Location is too long (max 255 chars).')); + return; + } else if (strlen($password) < 6) { + $this->showForm(_('Password must be 6 or more characters.')); + return; + } else if ($password != $confirm) { + $this->showForm(_('Passwords don\'t match.')); + } else if ($user = User::register(array('nickname' => $nickname, + 'password' => $password, + 'email' => $email, + 'fullname' => $fullname, + 'homepage' => $homepage, + 'bio' => $bio, + 'location' => $location, + 'code' => $code))) { + if (!$user) { + $this->showForm(_('Invalid username or password.')); + return; + } + // success! + if (!common_set_user($user)) { + $this->serverError(_('Error setting user.')); + return; + } + // this is a real login + common_real_login(true); + if ($this->boolean('rememberme')) { + common_debug('Adding rememberme cookie for ' . $nickname); + common_rememberme($user); + } + + Event::handle('EndRegistrationTry', array($this)); + + // Re-init language env in case it changed (not yet, but soon) + common_init_language(); + $this->showSuccess(); + } else { + $this->showForm(_('Invalid username or password.')); } - // this is a real login - common_real_login(true); - if ($this->boolean('rememberme')) { - common_debug('Adding rememberme cookie for ' . $nickname); - common_rememberme($user); - } - // Re-init language env in case it changed (not yet, but soon) - common_init_language(); - $this->showSuccess(); - } else { - $this->showForm(_('Invalid username or password.')); } } @@ -250,22 +291,24 @@ class RegisterAction extends Action // overrrided to add entry-title class function showPageTitle() { - $this->element('h1', array('class' => 'entry-title'), $this->title()); + if (Event::handle('StartShowPageTitle', array($this))) { + $this->element('h1', array('class' => 'entry-title'), $this->title()); + } } // overrided to add hentry, and content-inner class function showContentBlock() - { - $this->elementStart('div', array('id' => 'content', 'class' => 'hentry')); - $this->showPageTitle(); - $this->showPageNoticeBlock(); - $this->elementStart('div', array('id' => 'content_inner', - 'class' => 'entry-content')); - // show the actual content (forms, lists, whatever) - $this->showContent(); - $this->elementEnd('div'); - $this->elementEnd('div'); - } + { + $this->elementStart('div', array('id' => 'content', 'class' => 'hentry')); + $this->showPageTitle(); + $this->showPageNoticeBlock(); + $this->elementStart('div', array('id' => 'content_inner', + 'class' => 'entry-content')); + // show the actual content (forms, lists, whatever) + $this->showContent(); + $this->elementEnd('div'); + $this->elementEnd('div'); + } /** * Instructions or a notice for the page @@ -339,17 +382,6 @@ class RegisterAction extends Action function showFormContent() { - $code = $this->trimmed('code'); - - if ($code) { - $invite = Invitation::staticGet($code); - } - - if (common_config('site', 'inviteonly') && !($code && $invite)) { - $this->clientError(_('Sorry, only invited people can register.')); - return; - } - $this->elementStart('form', array('method' => 'post', 'id' => 'form_register', 'class' => 'form_settings', @@ -358,82 +390,85 @@ class RegisterAction extends Action $this->element('legend', null, 'Account settings'); $this->hidden('token', common_session_token()); - if ($code) { - $this->hidden('code', $code); + if ($this->code) { + $this->hidden('code', $this->code); } $this->elementStart('ul', 'form_data'); - $this->elementStart('li'); - $this->input('nickname', _('Nickname'), $this->trimmed('nickname'), - _('1-64 lowercase letters or numbers, '. - 'no punctuation or spaces. Required.')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->password('password', _('Password'), - _('6 or more characters. Required.')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->password('confirm', _('Confirm'), - _('Same as password above. Required.')); - $this->elementEnd('li'); - $this->elementStart('li'); - if ($invite && $invite->address_type == 'email') { - $this->input('email', _('Email'), $invite->address, - _('Used only for updates, announcements, '. - 'and password recovery')); - } else { - $this->input('email', _('Email'), $this->trimmed('email'), - _('Used only for updates, announcements, '. - 'and password recovery')); - } - $this->elementEnd('li'); - $this->elementStart('li'); - $this->input('fullname', _('Full name'), - $this->trimmed('fullname'), - _('Longer name, preferably your "real" name')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->input('homepage', _('Homepage'), - $this->trimmed('homepage'), - _('URL of your homepage, blog, '. - 'or profile on another site')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->textarea('bio', _('Bio'), - $this->trimmed('bio'), - _('Describe yourself and your '. - 'interests in 140 chars')); - $this->elementEnd('li'); - $this->elementStart('li'); - $this->input('location', _('Location'), - $this->trimmed('location'), - _('Where you are, like "City, '. - 'State (or Region), Country"')); - $this->elementEnd('li'); - $this->elementStart('li', array('id' => 'settings_rememberme')); - $this->checkbox('rememberme', _('Remember me'), - $this->boolean('rememberme'), - _('Automatically login in the future; '. - 'not for shared computers!')); - $this->elementEnd('li'); - $attrs = array('type' => 'checkbox', - 'id' => 'license', - 'class' => 'checkbox', - 'name' => 'license', - 'value' => 'true'); - if ($this->boolean('license')) { - $attrs['checked'] = 'checked'; + if (Event::handle('StartRegistrationFormData', array($this))) { + $this->elementStart('li'); + $this->input('nickname', _('Nickname'), $this->trimmed('nickname'), + _('1-64 lowercase letters or numbers, '. + 'no punctuation or spaces. Required.')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->password('password', _('Password'), + _('6 or more characters. Required.')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->password('confirm', _('Confirm'), + _('Same as password above. Required.')); + $this->elementEnd('li'); + $this->elementStart('li'); + if ($this->invite && $this->invite->address_type == 'email') { + $this->input('email', _('Email'), $this->invite->address, + _('Used only for updates, announcements, '. + 'and password recovery')); + } else { + $this->input('email', _('Email'), $this->trimmed('email'), + _('Used only for updates, announcements, '. + 'and password recovery')); + } + $this->elementEnd('li'); + $this->elementStart('li'); + $this->input('fullname', _('Full name'), + $this->trimmed('fullname'), + _('Longer name, preferably your "real" name')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->input('homepage', _('Homepage'), + $this->trimmed('homepage'), + _('URL of your homepage, blog, '. + 'or profile on another site')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->textarea('bio', _('Bio'), + $this->trimmed('bio'), + _('Describe yourself and your '. + 'interests in 140 chars')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->input('location', _('Location'), + $this->trimmed('location'), + _('Where you are, like "City, '. + 'State (or Region), Country"')); + $this->elementEnd('li'); + Event::handle('EndRegistrationFormData', array($this)); + $this->elementStart('li', array('id' => 'settings_rememberme')); + $this->checkbox('rememberme', _('Remember me'), + $this->boolean('rememberme'), + _('Automatically login in the future; '. + 'not for shared computers!')); + $this->elementEnd('li'); + $attrs = array('type' => 'checkbox', + 'id' => 'license', + 'class' => 'checkbox', + 'name' => 'license', + 'value' => 'true'); + if ($this->boolean('license')) { + $attrs['checked'] = 'checked'; + } + $this->elementStart('li'); + $this->element('input', $attrs); + $this->elementStart('label', array('class' => 'checkbox', 'for' => 'license')); + $this->text(_('My text and files are available under ')); + $this->element('a', array('href' => common_config('license', 'url')), + common_config('license', 'title'), _("Creative Commons Attribution 3.0")); + $this->text(_(' except this private data: password, '. + 'email address, IM address, and phone number.')); + $this->elementEnd('label'); + $this->elementEnd('li'); } - $this->elementStart('li'); - $this->element('input', $attrs); - $this->elementStart('label', array('class' => 'checkbox', 'for' => 'license')); - $this->text(_('My text and files are available under ')); - $this->element('a', array('href' => common_config('license', 'url')), - common_config('license', 'title'), _("Creative Commons Attribution 3.0")); - $this->text(_(' except this private data: password, '. - 'email address, IM address, and phone number.')); - $this->elementEnd('label'); - $this->elementEnd('li'); $this->elementEnd('ul'); $this->submit('submit', _('Register')); $this->elementEnd('fieldset'); @@ -515,3 +550,4 @@ class RegisterAction extends Action $nav->show(); } } + diff --git a/actions/showgroup.php b/actions/showgroup.php index 025f8383a..a7df39727 100644 --- a/actions/showgroup.php +++ b/actions/showgroup.php @@ -35,7 +35,7 @@ if (!defined('LACONICA')) { require_once INSTALLDIR.'/lib/noticelist.php'; require_once INSTALLDIR.'/lib/feedlist.php'; -define('MEMBERS_PER_SECTION', 81); +define('MEMBERS_PER_SECTION', 27); /** * Group main page @@ -361,7 +361,7 @@ class ShowgroupAction extends Action $this->element('p', null, _('(None)')); } - if ($cnt == MEMBERS_PER_SECTION) { + if ($cnt > MEMBERS_PER_SECTION) { $this->element('a', array('href' => common_local_url('groupmembers', array('nickname' => $this->group->nickname))), _('All members')); diff --git a/actions/subscribers.php b/actions/subscribers.php index d91a7d4fd..4482de9a7 100644 --- a/actions/subscribers.php +++ b/actions/subscribers.php @@ -118,6 +118,16 @@ class SubscribersAction extends GalleryAction $this->raw(common_markup_to_html($message)); $this->elementEnd('div'); } + + function showSections() + { + parent::showSections(); + $cloud = new SubscribersPeopleTagCloudSection($this); + $cloud->show(); + + $cloud2 = new SubscribersPeopleSelfTagCloudSection($this); + $cloud2->show(); + } } class SubscribersList extends ProfileList diff --git a/actions/subscriptions.php b/actions/subscriptions.php index e6f3c54db..095b18ad8 100644 --- a/actions/subscriptions.php +++ b/actions/subscriptions.php @@ -125,6 +125,16 @@ class SubscriptionsAction extends GalleryAction $this->raw(common_markup_to_html($message)); $this->elementEnd('div'); } + + function showSections() + { + parent::showSections(); + $cloud = new SubscriptionsPeopleTagCloudSection($this); + $cloud->show(); + + $cloud2 = new SubscriptionsPeopleSelfTagCloudSection($this); + $cloud2->show(); + } } class SubscriptionsList extends ProfileList diff --git a/actions/twitapifriendships.php b/actions/twitapifriendships.php index c50c5e84a..2f8250e0d 100644 --- a/actions/twitapifriendships.php +++ b/actions/twitapifriendships.php @@ -133,11 +133,7 @@ class TwitapifriendshipsAction extends TwitterapiAction return; } - if ($user_a->isSubscribed($user_b)) { - $result = 'true'; - } else { - $result = 'false'; - } + $result = $user_a->isSubscribed($user_b); switch ($apidata['content-type']) { case 'xml': diff --git a/actions/twitapiusers.php b/actions/twitapiusers.php index 2894b7486..1542cfb33 100644 --- a/actions/twitapiusers.php +++ b/actions/twitapiusers.php @@ -82,8 +82,8 @@ class TwitapiusersAction extends TwitterapiAction $twitter_user['profile_text_color'] = ''; $twitter_user['profile_link_color'] = ''; $twitter_user['profile_sidebar_fill_color'] = ''; - $twitter_user['profile_sidebar_border_color'] = ''; - $twitter_user['profile_background_tile'] = 'false'; + $twitter_user['profile_sidebar_border_color'] = ''; + $twitter_user['profile_background_tile'] = false; $faves = DB_DataObject::factory('fave'); $faves->user_id = $user->id; @@ -103,24 +103,16 @@ class TwitapiusersAction extends TwitterapiAction if (isset($apidata['user'])) { - if ($apidata['user']->isSubscribed($profile)) { - $twitter_user['following'] = 'true'; - } else { - $twitter_user['following'] = 'false'; - } + $twitter_user['following'] = $apidata['user']->isSubscribed($profile); - // Notifications on? - $sub = Subscription::pkeyGet(array('subscriber' => - $apidata['user']->id, 'subscribed' => $profile->id)); + // Notifications on? + $sub = Subscription::pkeyGet(array('subscriber' => + $apidata['user']->id, 'subscribed' => $profile->id)); - if ($sub) { - if ($sub->jabber || $sub->sms) { - $twitter_user['notifications'] = 'true'; - } else { - $twitter_user['notifications'] = 'false'; - } - } - } + if ($sub) { + $twitter_user['notifications'] = ($sub->jabber || $sub->sms); + } + } if ($apidata['content-type'] == 'xml') { $this->init_document('xml'); diff --git a/classes/Notice.php b/classes/Notice.php index 5fa0d79a1..27b98de1c 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -197,7 +197,12 @@ class Notice extends Memcached_DataObject $notice->saveTags(); $notice->saveGroups(); - $notice->addToInboxes(); + if (common_config('queue', 'enabled')) { + $notice->addToAuthorInbox(); + } else { + $notice->addToInboxes(); + } + $notice->query('COMMIT'); Event::handle('EndNoticeSave', array($notice)); @@ -207,7 +212,11 @@ class Notice extends Memcached_DataObject # XXX: someone clever could prepend instead of clearing the cache if (common_config('memcached', 'enabled')) { - $notice->blowCaches(); + if (common_config('queue', 'enabled')) { + $notice->blowAuthorCaches(); + } else { + $notice->blowCaches(); + } } return $notice; @@ -271,6 +280,17 @@ class Notice extends Memcached_DataObject $this->blowGroupCache($blowLast); } + function blowAuthorCaches($blowLast=false) + { + // Clear the user's cache + $cache = common_memcache(); + if (!empty($cache)) { + $cache->delete(common_cache_key('user:notices_with_friends:' . $this->profile_id)); + } + $this->blowNoticeCache($blowLast); + $this->blowPublicCache($blowLast); + } + function blowGroupCache($blowLast=false) { $cache = common_memcache(); @@ -639,6 +659,33 @@ class Notice extends Memcached_DataObject return; } + function addToAuthorInbox() + { + $enabled = common_config('inboxes', 'enabled'); + + if ($enabled === true || $enabled === 'transitional') { + $user = User::staticGet('id', $this->profile_id); + if (empty($user)) { + return; + } + $inbox = new Notice_inbox(); + $UT = common_config('db','type')=='pgsql'?'"user"':'user'; + $qry = 'INSERT INTO notice_inbox (user_id, notice_id, created) ' . + "SELECT $UT.id, " . $this->id . ", '" . $this->created . "' " . + "FROM $UT " . + "WHERE $UT.id = " . $this->profile_id . ' ' . + 'AND NOT EXISTS (SELECT user_id, notice_id ' . + 'FROM notice_inbox ' . + "WHERE user_id = " . $this->profile_id . ' '. + 'AND notice_id = ' . $this->id . ' )'; + if ($enabled === 'transitional') { + $qry .= " AND $UT.inboxed = 1"; + } + $inbox->query($qry); + } + return; + } + function saveGroups() { $enabled = common_config('inboxes', 'enabled'); @@ -691,24 +738,29 @@ class Notice extends Memcached_DataObject // FIXME: do this in an offline daemon - $inbox = new Notice_inbox(); - $UT = common_config('db','type')=='pgsql'?'"user"':'user'; - $qry = 'INSERT INTO notice_inbox (user_id, notice_id, created, source) ' . - "SELECT $UT.id, " . $this->id . ", '" . $this->created . "', 2 " . - "FROM $UT JOIN group_member ON $UT.id = group_member.profile_id " . - 'WHERE group_member.group_id = ' . $group->id . ' ' . - 'AND NOT EXISTS (SELECT user_id, notice_id ' . - 'FROM notice_inbox ' . - "WHERE user_id = $UT.id " . - 'AND notice_id = ' . $this->id . ' )'; - if ($enabled === 'transitional') { - $qry .= " AND $UT.inboxed = 1"; - } - $result = $inbox->query($qry); + $this->addToGroupInboxes($group); } } } + function addToGroupInboxes($group) + { + $inbox = new Notice_inbox(); + $UT = common_config('db','type')=='pgsql'?'"user"':'user'; + $qry = 'INSERT INTO notice_inbox (user_id, notice_id, created, source) ' . + "SELECT $UT.id, " . $this->id . ", '" . $this->created . "', 2 " . + "FROM $UT JOIN group_member ON $UT.id = group_member.profile_id " . + 'WHERE group_member.group_id = ' . $group->id . ' ' . + 'AND NOT EXISTS (SELECT user_id, notice_id ' . + 'FROM notice_inbox ' . + "WHERE user_id = $UT.id " . + 'AND notice_id = ' . $this->id . ' )'; + if ($enabled === 'transitional') { + $qry .= " AND $UT.inboxed = 1"; + } + $result = $inbox->query($qry); + } + function saveReplies() { // Alternative reply format diff --git a/db/laconica.sql b/db/laconica.sql index a790a3fd2..5b57494d9 100644 --- a/db/laconica.sql +++ b/db/laconica.sql @@ -117,6 +117,7 @@ create table notice ( index notice_profile_id_idx (profile_id), index notice_created_idx (created), + index notice_replyto_idx (reply_to), FULLTEXT(content) ) ENGINE=MyISAM CHARACTER SET utf8 COLLATE utf8_general_ci; diff --git a/db/notice_source.sql b/db/notice_source.sql index d5124e223..ce44f3235 100644 --- a/db/notice_source.sql +++ b/db/notice_source.sql @@ -8,6 +8,7 @@ VALUES ('deskbar','Deskbar-Applet','http://www.gnome.org/projects/deskbar-applet/', now()), ('Do','Gnome Do','http://do.davebsd.com/wiki/index.php?title=Microblog_Plugin', now()), ('Facebook','Facebook','http://apps.facebook.com/identica/', now()), + ('feed2omb','feed2omb','http://projects.ciarang.com/p/feed2omb/', now()), ('Gwibber','Gwibber','http://launchpad.net/gwibber', now()), ('HelloTxt','HelloTxt','http://hellotxt.com/', now()), ('identicatools','Laconica Tools','http://bitbucketlabs.net/laconica-tools/', now()), diff --git a/js/util.js b/js/util.js index 81139744f..f15c4f2bb 100644 --- a/js/util.js +++ b/js/util.js @@ -166,19 +166,40 @@ $(document).ready(function(){ $("#notice_action-submit").addClass("disabled"); return true; }, - success: function(xml) { if ($("#error", xml).length > 0 || $("#command_result", xml).length > 0) { + error: function (xhr, textStatus, errorThrown) { $("#form_notice").removeClass("processing"); + $("#notice_action-submit").removeAttr("disabled"); + $("#notice_action-submit").removeClass("disabled"); + if ($(".error", xhr.responseXML).length > 0) { + $('#form_notice').append(document._importNode($(".error", xhr.responseXML).get(0), true)); + } + else { + alert("Sorry! We had trouble sending your notice ("+xhr.status+" "+xhr.statusText+"). Please report the problem to the site administrator if this happens again."); + } + }, + success: function(xml) { if ($("#error", xml).length > 0) { var result = document._importNode($("p", xml).get(0), true); result = result.textContent || result.innerHTML; alert(result); } else { - $("#notices_primary .notices").prepend(document._importNode($("li", xml).get(0), true)); + if ($("#command_result", xml).length > 0) { + var result = document._importNode($("p", xml).get(0), true); + result = result.textContent || result.innerHTML; + alert(result); + } + else { + li = $("li", xml).get(0); + id = li.id; + if ($("#"+li.id).length == 0) { + $("#notices_primary .notices").prepend(document._importNode(li, true)); + $("#notices_primary .notice:first").css({display:"none"}); + $("#notices_primary .notice:first").fadeIn(2500); + NoticeHover(); + NoticeReply(); + } + } $("#notice_data-text").val(""); - counter(); - $("#notices_primary .notice:first").css({display:"none"}); - $("#notices_primary .notice:first").fadeIn(2500); - NoticeHover(); - NoticeReply(); + counter(); } $("#form_notice").removeClass("processing"); $("#notice_action-submit").removeAttr("disabled"); @@ -187,7 +208,6 @@ $(document).ready(function(){ }; $("#form_notice").ajaxForm(PostNotice); $("#form_notice").each(addAjaxHidden); - NoticeHover(); NoticeReply(); }); diff --git a/lib/common.php b/lib/common.php index b3882d207..f983c4d16 100644 --- a/lib/common.php +++ b/lib/common.php @@ -192,7 +192,7 @@ $_config_files[] = INSTALLDIR.'/config.php'; $_have_a_config = false; foreach ($_config_files as $_config_file) { - if (file_exists($_config_file)) { + if (@file_exists($_config_file)) { include_once($_config_file); $_have_a_config = true; } diff --git a/lib/galleryaction.php b/lib/galleryaction.php index 0484918ce..8fa11a756 100644 --- a/lib/galleryaction.php +++ b/lib/galleryaction.php @@ -134,9 +134,11 @@ class GalleryAction extends Action $this->elementStart('li', array('id'=>'filter_tags_item')); $this->elementStart('form', array('name' => 'bytag', 'id' => 'bytag', + 'action' => common_path('?action=' . $this->trimmed('action')), 'method' => 'post')); $this->dropdown('tag', _('Tag'), $content, _('Choose a tag to narrow list'), false, $tag); + $this->hidden('nickname', $this->user->nickname); $this->submit('submit', _('Go')); $this->elementEnd('form'); $this->elementEnd('li'); @@ -169,4 +171,4 @@ class GalleryAction extends Action { return array(); } -}
\ No newline at end of file +} diff --git a/lib/personalgroupnav.php b/lib/personalgroupnav.php index 63e6138df..acc033667 100644 --- a/lib/personalgroupnav.php +++ b/lib/personalgroupnav.php @@ -93,43 +93,45 @@ class PersonalGroupNav extends Widget $this->out->elementStart('ul', array('class' => 'nav')); - $this->out->menuItem(common_local_url('all', array('nickname' => - $nickname)), - _('Personal'), - sprintf(_('%s and friends'), (($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname)), - $action == 'all', 'nav_timeline_personal'); - $this->out->menuItem(common_local_url('replies', array('nickname' => - $nickname)), - _('Replies'), - sprintf(_('Replies to %s'), (($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname)), - $action == 'replies', 'nav_timeline_replies'); - $this->out->menuItem(common_local_url('showstream', array('nickname' => - $nickname)), - _('Profile'), - ($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname, - $action == 'showstream', 'nav_profile'); - $this->out->menuItem(common_local_url('showfavorites', array('nickname' => - $nickname)), - _('Favorites'), - sprintf(_('%s\'s favorite notices'), ($user_profile) ? $user_profile->getBestName() : _('User')), - $action == 'showfavorites', 'nav_timeline_favorites'); + if (Event::handle('StartPersonalGroupNav', array($this))) { + $this->out->menuItem(common_local_url('all', array('nickname' => + $nickname)), + _('Personal'), + sprintf(_('%s and friends'), (($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname)), + $action == 'all', 'nav_timeline_personal'); + $this->out->menuItem(common_local_url('replies', array('nickname' => + $nickname)), + _('Replies'), + sprintf(_('Replies to %s'), (($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname)), + $action == 'replies', 'nav_timeline_replies'); + $this->out->menuItem(common_local_url('showstream', array('nickname' => + $nickname)), + _('Profile'), + ($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname, + $action == 'showstream', 'nav_profile'); + $this->out->menuItem(common_local_url('showfavorites', array('nickname' => + $nickname)), + _('Favorites'), + sprintf(_('%s\'s favorite notices'), ($user_profile) ? $user_profile->getBestName() : _('User')), + $action == 'showfavorites', 'nav_timeline_favorites'); - $cur = common_current_user(); + $cur = common_current_user(); - if ($cur && $cur->id == $user->id) { + if ($cur && $cur->id == $user->id) { - $this->out->menuItem(common_local_url('inbox', array('nickname' => - $nickname)), - _('Inbox'), - _('Your incoming messages'), - $action == 'inbox'); - $this->out->menuItem(common_local_url('outbox', array('nickname' => - $nickname)), - _('Outbox'), - _('Your sent messages'), - $action == 'outbox'); + $this->out->menuItem(common_local_url('inbox', array('nickname' => + $nickname)), + _('Inbox'), + _('Your incoming messages'), + $action == 'inbox'); + $this->out->menuItem(common_local_url('outbox', array('nickname' => + $nickname)), + _('Outbox'), + _('Your sent messages'), + $action == 'outbox'); + } + Event::handle('EndPersonalGroupNav', array($this)); } - $this->out->elementEnd('ul'); } } diff --git a/lib/queuehandler.php b/lib/queuehandler.php index 9ce9e32b3..fde650d9e 100644 --- a/lib/queuehandler.php +++ b/lib/queuehandler.php @@ -36,7 +36,7 @@ class QueueHandler extends Daemon $this->set_id($id); } } - + function class_name() { return ucfirst($this->transport()) . 'Handler'; @@ -46,7 +46,7 @@ class QueueHandler extends Daemon { return strtolower($this->class_name().'.'.$this->get_id()); } - + function get_id() { return $this->_id; @@ -56,16 +56,16 @@ class QueueHandler extends Daemon { $this->_id = $id; } - + function transport() { return null; } - + function start() { } - + function finish() { } @@ -74,14 +74,14 @@ class QueueHandler extends Daemon { return true; } - + function run() { if (!$this->start()) { return false; } - $this->log(LOG_INFO, 'checking for queued notices'); $transport = $this->transport(); + $this->log(LOG_INFO, 'checking for queued notices for "' . $transport . '"'); do { $qi = Queue_item::top($transport); if ($qi) { @@ -113,7 +113,7 @@ class QueueHandler extends Daemon } else { $this->clear_old_claims(); $this->idle(5); - } + } } while (true); if (!$this->finish()) { return false; @@ -127,7 +127,7 @@ class QueueHandler extends Daemon sleep($timeout); } } - + function clear_old_claims() { $qi = new Queue_item(); @@ -137,10 +137,9 @@ class QueueHandler extends Daemon $qi->free(); unset($qi); } - + function log($level, $msg) { common_log($level, $this->class_name() . ' ('. $this->get_id() .'): '.$msg); } } -
\ No newline at end of file diff --git a/lib/searchaction.php b/lib/searchaction.php index e74450e11..e7ad4affd 100644 --- a/lib/searchaction.php +++ b/lib/searchaction.php @@ -51,7 +51,7 @@ class SearchAction extends Action * * @return boolean true */ - function isReadOnly($args) + function isReadOnly() { return true; } diff --git a/lib/subpeopletagcloudsection.php b/lib/subpeopletagcloudsection.php new file mode 100644 index 000000000..9f6948dc9 --- /dev/null +++ b/lib/subpeopletagcloudsection.php @@ -0,0 +1,77 @@ +<?php +/** + * Laconica, the distributed open-source microblogging tool + * + * Personal tag cloud section + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @copyright 2009 Control Yourself, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +if (!defined('LACONICA')) { + exit(1); +} + +/** + * Personal tag cloud section + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +class SubPeopleTagCloudSection extends TagCloudSection +{ + function getTags() + { + $qry = $this->query(); + $limit = TAGS_PER_SECTION; + $offset = 0; + + if (common_config('db','type') == 'pgsql') { + $qry .= ' LIMIT ' . $limit . ' OFFSET ' . $offset; + } else { + $qry .= ' LIMIT ' . $offset . ', ' . $limit; + } + + $profile_tag = Memcached_DataObject::cachedQuery('Profile_tag', + sprintf($qry, + $this->out->user->id)); + return $profile_tag; + } + + function tagUrl($tag) { + return common_local_url('peopletag', array('tag' => $tag)); + } + + function showTag($tag, $weight, $relative) { + $rel = 'tag-cloud-'; + $rel .= 1+intval(7 * $relative * $weight - 0.01); + + $this->out->elementStart('li', $rel); + $this->out->element('a', array('href' => $this->tagUrl($tag)), $tag); + $this->out->elementEnd('li'); + } +} + diff --git a/lib/subscriberspeopleselftagcloudsection.php b/lib/subscriberspeopleselftagcloudsection.php new file mode 100644 index 000000000..115241a53 --- /dev/null +++ b/lib/subscriberspeopleselftagcloudsection.php @@ -0,0 +1,62 @@ +<?php +/** + * Laconica, the distributed open-source microblogging tool + * + * Personal tag cloud section + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @copyright 2009 Control Yourself, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +if (!defined('LACONICA')) { + exit(1); +} + +/** + * Personal tag cloud section + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +class SubscribersPeopleSelfTagCloudSection extends SubPeopleTagCloudSection +{ + function title() + { + return _('People Tagcloud as self-tagged'); + } + + function query() { +// return 'select tag, count(tag) as weight from subscription left join profile_tag on tagger = subscriber where subscribed=%d and subscribed != subscriber and tagger = tagged group by tag order by weight desc'; + + + return 'select tag, count(tag) as weight from subscription left join profile_tag on tagger = subscriber where subscribed=%d and subscribed != subscriber and tagger = tagged and tag is not null group by tag order by weight desc'; + +// return 'select tag, count(tag) as weight from subscription left join profile_tag on tagger = subscribed where subscriber=%d and subscribed != subscriber and tagger = tagged and tag is not null group by tag order by weight desc'; + + + } +} + diff --git a/lib/subscriberspeopletagcloudsection.php b/lib/subscriberspeopletagcloudsection.php new file mode 100644 index 000000000..4dafbc1c7 --- /dev/null +++ b/lib/subscriberspeopletagcloudsection.php @@ -0,0 +1,61 @@ +<?php +/** + * Laconica, the distributed open-source microblogging tool + * + * Personal tag cloud section + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @copyright 2009 Control Yourself, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +if (!defined('LACONICA')) { + exit(1); +} + +/** + * Personal tag cloud section + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +class SubscribersPeopleTagCloudSection extends SubPeopleTagCloudSection +{ + function title() + { + return _('People Tagcloud as tagged'); + } + + function tagUrl($tag) { + $nickname = $this->out->profile->nickname; + return common_local_url('subscribers', array('nickname' => $nickname, 'tag' => $tag)); + } + + function query() { +// return 'select tag, count(tag) as weight from subscription left join profile_tag on subscriber=tagged and subscribed=tagger where subscribed=%d and subscriber != subscribed group by tag order by weight desc'; + return 'select tag, count(tag) as weight from subscription left join profile_tag on subscriber=tagged and subscribed=tagger where subscribed=%d and subscriber != subscribed and tag is not null group by tag order by weight desc'; + } +} + diff --git a/lib/subscriptionspeopleselftagcloudsection.php b/lib/subscriptionspeopleselftagcloudsection.php new file mode 100644 index 000000000..3896294d2 --- /dev/null +++ b/lib/subscriptionspeopleselftagcloudsection.php @@ -0,0 +1,61 @@ +<?php +/** + * Laconica, the distributed open-source microblogging tool + * + * Personal tag cloud section + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @copyright 2009 Control Yourself, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +if (!defined('LACONICA')) { + exit(1); +} + +/** + * Personal tag cloud section + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +class SubscriptionsPeopleSelfTagCloudSection extends SubPeopleTagCloudSection +{ + function title() + { + return _('People Tagcloud as self-tagged'); + } + + function query() { +// return 'select tag, count(tag) as weight from subscription left join profile_tag on tagger = subscriber where subscribed=%d and subscriber != subscribed and tagger = tagged group by tag order by weight desc'; + + + + return 'select tag, count(tag) as weight from subscription left join profile_tag on tagger = subscribed where subscriber=%d and subscribed != subscriber and tagger = tagged and tag is not null group by tag order by weight desc'; + +// return 'select tag, count(tag) as weight from subscription left join profile_tag on tagger = subscriber where subscribed=%d and subscribed != subscriber and tagger = tagged and tag is not null group by tag order by weight desc'; + } +} + diff --git a/lib/subscriptionspeopletagcloudsection.php b/lib/subscriptionspeopletagcloudsection.php new file mode 100644 index 000000000..f9c8672e3 --- /dev/null +++ b/lib/subscriptionspeopletagcloudsection.php @@ -0,0 +1,61 @@ +<?php +/** + * Laconica, the distributed open-source microblogging tool + * + * Personal tag cloud section + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @copyright 2009 Control Yourself, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +if (!defined('LACONICA')) { + exit(1); +} + +/** + * Personal tag cloud section + * + * @category Widget + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +class SubscriptionsPeopleTagCloudSection extends SubPeopleTagCloudSection +{ + function title() + { + return _('People Tagcloud as tagged'); + } + + function tagUrl($tag) { + $nickname = $this->out->profile->nickname; + return common_local_url('subscriptions', array('nickname' => $nickname, 'tag' => $tag)); + } + + function query() { +// return 'select tag, count(tag) as weight from subscription left join profile_tag on subscriber=tagger and subscribed=tagged where subscriber=%d and subscriber != subscribed group by tag order by weight desc'; + return 'select tag, count(tag) as weight from subscription left join profile_tag on subscriber=tagger and subscribed=tagged where subscriber=%d and subscriber != subscribed and tag is not null group by tag order by weight desc'; + } +} + diff --git a/lib/twitterapi.php b/lib/twitterapi.php index b8357c688..caf8c0716 100644 --- a/lib/twitterapi.php +++ b/lib/twitterapi.php @@ -51,6 +51,26 @@ class TwitterapiAction extends Action parent::handle($args); } + /** + * Overrides XMLOutputter::element to write booleans as strings (true|false). + * See that method's documentation for more info. + * + * @param string $tag Element type or tagname + * @param array $attrs Array of element attributes, as + * key-value pairs + * @param string $content string content of the element + * + * @return void + */ + function element($tag, $attrs=null, $content=null) + { + if (is_bool($content)) { + $content = ($content ? 'true' : 'false'); + } + + return parent::element($tag, $attrs, $content); + } + function twitter_user_array($profile, $get_notice=false) { @@ -66,7 +86,7 @@ class TwitterapiAction extends Action $avatar = $profile->getAvatar(AVATAR_STREAM_SIZE); $twitter_user['profile_image_url'] = ($avatar) ? $avatar->displayUrl() : Avatar::defaultImage(AVATAR_STREAM_SIZE); - $twitter_user['protected'] = 'false'; # not supported by Laconica yet + $twitter_user['protected'] = false; # not supported by Laconica yet $twitter_user['url'] = ($profile->homepage) ? $profile->homepage : null; if ($get_notice) { @@ -86,7 +106,7 @@ class TwitterapiAction extends Action $twitter_status = array(); $twitter_status['text'] = $notice->content; - $twitter_status['truncated'] = 'false'; # Not possible on Laconica + $twitter_status['truncated'] = false; # Not possible on Laconica $twitter_status['created_at'] = $this->date_twitter($notice->created); $twitter_status['in_reply_to_status_id'] = ($notice->reply_to) ? intval($notice->reply_to) : null; @@ -108,10 +128,9 @@ class TwitterapiAction extends Action ($replier_profile) ? $replier_profile->nickname : null; if (isset($this->auth_user)) { - $twitter_status['favorited'] = - ($this->auth_user->hasFave($notice)) ? 'true' : 'false'; + $twitter_status['favorited'] = $this->auth_user->hasFave($notice); } else { - $twitter_status['favorited'] = 'false'; + $twitter_status['favorited'] = false; } if ($include_user) { @@ -418,7 +437,7 @@ class TwitterapiAction extends Action function date_twitter($dt) { $t = strtotime($dt); - return date("D M d G:i:s O Y", $t); + return date("D M d H:i:s O Y", $t); } // XXX: Candidate for a general utility method somewhere? diff --git a/lib/util.php b/lib/util.php index 675ff51f0..f862d7fcc 100644 --- a/lib/util.php +++ b/lib/util.php @@ -519,11 +519,16 @@ function common_shorten_links($text) function common_shorten_link($url, $reverse = false) { + static $url_cache = array(); if ($reverse) return isset($url_cache[$url]) ? $url_cache[$url] : $url; $user = common_current_user(); - + if (!isset($user)) { + // common current user does not find a user when called from the XMPP daemon + // therefore we'll set one here fix, so that XMPP given URLs may be shortened + $user->urlshorteningservice = 'ur1.ca'; + } $curlh = curl_init(); curl_setopt($curlh, CURLOPT_CONNECTTIMEOUT, 20); // # seconds to wait curl_setopt($curlh, CURLOPT_USERAGENT, 'Laconica'); @@ -874,7 +879,23 @@ function common_broadcast_notice($notice, $remote=false) function common_enqueue_notice($notice) { - foreach (array('jabber', 'omb', 'sms', 'public', 'twitter', 'facebook', 'ping') as $transport) { + $transports = array('omb', 'sms', 'twitter', 'facebook', 'ping'); + + if (common_config('xmpp', 'enabled')) { + $transports = array_merge($transports, array('jabber', 'public')); + } + + if (common_config('memcached', 'enabled')) { + // Note: limited to 8 chars + $transports[] = 'memcache'; + } + + if (common_config('inboxes', 'enabled') === true || + common_config('inboxes', 'enabled') === 'transitional') { + $transports[] = 'inbox'; + } + + foreach ($transports as $transport) { $qi = new Queue_item(); $qi->notice_id = $notice->id; $qi->transport = $transport; @@ -961,7 +982,7 @@ function common_root_url($ssl=false) function common_good_rand($bytes) { // XXX: use random.org...? - if (file_exists('/dev/urandom')) { + if (@file_exists('/dev/urandom')) { return common_urandom($bytes); } else { // FIXME: this is probably not good enough return common_mtrand($bytes); @@ -1327,10 +1348,10 @@ function common_compatible_license($from, $to) */ function common_database_tablename($tablename) { - + if(common_config('db','quote_identifiers')) { $tablename = '"'. $tablename .'"'; } //table prefixes could be added here later return $tablename; -}
\ No newline at end of file +} diff --git a/plugins/Comet/CometPlugin.php b/plugins/Comet/CometPlugin.php new file mode 100644 index 000000000..2e0bb40a4 --- /dev/null +++ b/plugins/Comet/CometPlugin.php @@ -0,0 +1,180 @@ +<?php +/** + * Laconica, the distributed open-source microblogging tool + * + * Plugin to do "real time" updates using Comet/Bayeux + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Plugin + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @copyright 2009 Control Yourself, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +if (!defined('LACONICA')) { + exit(1); +} + +/** + * Plugin to do realtime updates using Comet + * + * @category Plugin + * @package Laconica + * @author Evan Prodromou <evan@controlyourself.ca> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://laconi.ca/ + */ + +class CometPlugin extends Plugin +{ + var $server = null; + + function __construct($server=null) + { + $this->server = $server; + + parent::__construct(); + } + + function onEndShowScripts($action) + { + $timeline = null; + + $this->log(LOG_DEBUG, 'got action ' . $action->trimmed('action')); + + switch ($action->trimmed('action')) { + case 'public': + $timeline = '/timelines/public'; + break; + case 'tag': + $tag = $action->trimmed('tag'); + if (!empty($tag)) { + $timeline = '/timelines/tag/'.$tag; + } else { + return true; + } + break; + default: + return true; + } + + $scripts = array('jquery.comet.js', 'json2.js', 'updatetimeline.js'); + + foreach ($scripts as $script) { + $action->element('script', array('type' => 'text/javascript', + 'src' => common_path('plugins/Comet/'.$script)), + ' '); + } + + $action->elementStart('script', array('type' => 'text/javascript')); + $action->raw("$(document).ready(function() { updater.init(\"$this->server\", \"$timeline\");});"); + $action->elementEnd('script'); + + return true; + } + + function onEndNoticeSave($notice) + { + $this->log(LOG_INFO, "Called for save notice."); + + $timelines = array(); + + // XXX: Add other timelines; this is just for the public one + + if ($notice->is_local || + ($notice->is_local == 0 && !common_config('public', 'localonly'))) { + $timelines[] = '/timelines/public'; + } + + $tags = $this->getNoticeTags($notice); + + if (!empty($tags)) { + foreach ($tags as $tag) { + $timelines[] = '/timelines/tag/' . $tag; + } + } + + if (count($timelines) > 0) { + // Require this, since we need it + require_once(INSTALLDIR.'/plugins/Comet/bayeux.class.inc.php'); + + $json = $this->noticeAsJson($notice); + + // Bayeux? Comet? Huh? These terms confuse me + $bay = new Bayeux($this->server); + + foreach ($timelines as $timeline) { + $this->log(LOG_INFO, "Posting notice $notice->id to '$timeline'."); + $bay->publish($timeline, $json); + } + + $bay = NULL; + } + + return true; + } + + function noticeAsJson($notice) + { + // FIXME: this code should be abstracted to a neutral third + // party, like Notice::asJson(). I'm not sure of the ethics + // of refactoring from within a plugin, so I'm just abusing + // the TwitterApiAction method. Don't do this unless you're me! + + require_once(INSTALLDIR.'/lib/twitterapi.php'); + + $act = new TwitterApiAction('/dev/null'); + + $arr = $act->twitter_status_array($notice, true); + $arr['url'] = $notice->bestUrl(); + $arr['html'] = htmlspecialchars($notice->rendered); + + $profile = $notice->getProfile(); + $arr['user']['profile_url'] = $profile->profileurl; + + return $arr; + } + + function getNoticeTags($notice) + { + $tags = null; + + $nt = new Notice_tag(); + $nt->notice_id = $notice->id; + + if ($nt->find()) { + $tags = array(); + while ($nt->fetch()) { + $tags[] = $nt->tag; + } + } + + $nt->free(); + $nt = null; + + return $tags; + } + + // Push this up to Plugin + + function log($level, $msg) + { + common_log($level, get_class($this) . ': '.$msg); + } +} diff --git a/plugins/Comet/README b/plugins/Comet/README new file mode 100644 index 000000000..4abd40af7 --- /dev/null +++ b/plugins/Comet/README @@ -0,0 +1,26 @@ +This is a plugin to automatically load notices in the browser no +matter who creates them -- the kind of thing we see with +search.twitter.com, rejaw.com, or FriendFeed's "real time" news. + +NOTE: this is an insecure version; don't roll it out on a production +server. + +It requires a cometd server. I've only had the cometd-java server work +correctly; something's wiggy with the Twisted-based server. + +After you have a cometd server installed, just add this code to your +config.php: + + require_once(INSTALLDIR.'/plugins/Comet/CometPlugin.php'); + $cp = new CometPlugin('http://example.com:8080/cometd/'); + +Change 'example.com:8080' to the name and port of the server you +installed cometd on. + +TODO: + +* Needs to be tested with Ajax submission. Probably messes everything + up. +* Add more timelines: personal inbox and tags would be great. +* Add security. In particular, only let the PHP code publish notices + to the cometd server. Currently, it doesn't try to authenticate. diff --git a/plugins/Comet/bayeux.class.inc.php b/plugins/Comet/bayeux.class.inc.php new file mode 100644 index 000000000..785d3e393 --- /dev/null +++ b/plugins/Comet/bayeux.class.inc.php @@ -0,0 +1,127 @@ +<?php +/* + * + * Phomet: a php comet client + * + * Copyright (C) 2008 Morgan 'ARR!' Allen <morganrallen@gmail.com> http://morglog.alleycatracing.com + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +class Bayeux +{ + private $oCurl = ''; + private $nNextId = 0; + + public $sUrl = ''; + + function __construct($sUrl) + { + $this->sUrl = $sUrl; + + $this->oCurl = curl_init(); + + $aHeaders = array(); + $aHeaders[] = 'Connection: Keep-Alive'; + + curl_setopt($this->oCurl, CURLOPT_URL, $sUrl); + curl_setopt($this->oCurl, CURLOPT_HTTPHEADER, $aHeaders); + curl_setopt($this->oCurl, CURLOPT_HEADER, 0); + curl_setopt($this->oCurl, CURLOPT_POST, 1); + curl_setopt($this->oCurl, CURLOPT_RETURNTRANSFER,1); + + $this->handShake(); + } + + function __destruct() + { + $this->disconnect(); + } + + function handShake() + { + $msgHandshake = array(); + $msgHandshake['channel'] = '/meta/handshake'; + $msgHandshake['version'] = "1.0"; + $msgHandshake['minimumVersion'] = "0.9"; + $msgHandshake['supportedConnectionTypes'] = array('long-polling'); + $msgHandshake['id'] = $this->nNextId++; + + curl_setopt($this->oCurl, CURLOPT_POSTFIELDS, "message=".urlencode(str_replace('\\', '', json_encode(array($msgHandshake))))); + + $data = curl_exec($this->oCurl); + + if(curl_errno($this->oCurl)) + die("Error: " . curl_error($this->oCurl)); + + $oReturn = json_decode($data); + + if (is_array($oReturn)) { + $oReturn = $oReturn[0]; + } + + $bSuccessful = ($oReturn->successful) ? true : false; + + if($bSuccessful) + { + $this->clientId = $oReturn->clientId; + + $this->connect(); + } + } + + public function connect() + { + $aMsg['channel'] = '/meta/connect'; + $aMsg['id'] = $this->nNextId++; + $aMsg['clientId'] = $this->clientId; + $aMsg['connectionType'] = 'long-polling'; + + curl_setopt($this->oCurl, CURLOPT_POSTFIELDS, "message=".urlencode(str_replace('\\', '', json_encode(array($aMsg))))); + + $data = curl_exec($this->oCurl); + } + + function disconnect() + { + $msgHandshake = array(); + $msgHandshake['channel'] = '/meta/disconnect'; + $msgHandshake['id'] = $this->nNextId++; + $msgHandshake['clientId'] = $this->clientId; + + curl_setopt($this->oCurl, CURLOPT_POSTFIELDS, "message=".urlencode(str_replace('\\', '', json_encode(array($msgHandshake))))); + + curl_exec($this->oCurl); + } + + public function publish($sChannel, $oData) + { + if(!$sChannel || !$oData) + return; + + $aMsg = array(); + + $aMsg['channel'] = $sChannel; + $aMsg['id'] = $this->nNextId++; + $aMsg['data'] = $oData; + $aMsg['clientId'] = $this->clientId; + + curl_setopt($this->oCurl, CURLOPT_POSTFIELDS, "message=".urlencode(str_replace('\\', '', json_encode(array($aMsg))))); + + $data = curl_exec($this->oCurl); +// var_dump($data); + } +} diff --git a/plugins/Comet/jquery.comet.js b/plugins/Comet/jquery.comet.js new file mode 100644 index 000000000..6de437fa8 --- /dev/null +++ b/plugins/Comet/jquery.comet.js @@ -0,0 +1,1451 @@ +/** + * Copyright 2008 Mort Bay Consulting Pty. Ltd. + * Dual licensed under the Apache License 2.0 and the MIT license. + * ---------------------------------------------------------------------------- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http: *www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * Licensed under the MIT license; + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * ---------------------------------------------------------------------------- + * $Revision$ $Date$ + */ +(function($) +{ + /** + * The constructor for a Comet object. + * There is a default Comet instance already created at the variable <code>$.cometd</code>, + * and hence that can be used to start a comet conversation with a server. + * In the rare case a page needs more than one comet conversation, a new instance can be + * created via: + * <pre> + * var url2 = ...; + * var cometd2 = new $.Cometd(); + * cometd2.init(url2); + * </pre> + */ + $.Cometd = function(name) + { + var _name = name || 'default'; + var _logPriorities = { debug: 1, info: 2, warn: 3, error: 4 }; + var _logLevel = 'info'; + var _url; + var _xd = false; + var _transport; + var _status = 'disconnected'; + var _messageId = 0; + var _clientId = null; + var _batch = 0; + var _messageQueue = []; + var _listeners = {}; + var _backoff = 0; + var _backoffIncrement = 1000; + var _maxBackoff = 60000; + var _scheduledSend = null; + var _extensions = []; + var _advice = {}; + var _handshakeProps; + + /** + * Returns the name assigned to this Comet object, or the string 'default' + * if no name has been explicitely passed as parameter to the constructor. + */ + this.getName = function() + { + return _name; + }; + + /** + * Configures the initial comet communication with the comet server. + * @param cometURL the URL of the comet server + */ + this.configure = function(cometURL) + { + _configure(cometURL); + }; + + function _configure(cometURL) + { + _url = cometURL; + _debug('Initializing comet with url: {}', _url); + + // Check immediately if we're cross domain + // If cross domain, the handshake must not send the long polling transport type + var urlParts = /(^https?:)?(\/\/(([^:\/\?#]+)(:(\d+))?))?([^\?#]*)/.exec(cometURL); + if (urlParts[3]) _xd = urlParts[3] != location.host; + + // Temporary setup a transport to send the initial handshake + // The transport may be changed as a result of handshake + if (_xd) + _transport = newCallbackPollingTransport(); + else + _transport = newLongPollingTransport(); + _debug('Initial transport is {}', _transport.getType()); + }; + + /** + * Configures and establishes the comet communication with the comet server + * via a handshake and a subsequent connect. + * @param cometURL the URL of the comet server + * @param handshakeProps an object to be merged with the handshake message + * @see #configure(cometURL) + * @see #handshake(handshakeProps) + */ + this.init = function(cometURL, handshakeProps) + { + _configure(cometURL); + _handshake(handshakeProps); + }; + + /** + * Establishes the comet communication with the comet server + * via a handshake and a subsequent connect. + * @param handshakeProps an object to be merged with the handshake message + */ + this.handshake = function(handshakeProps) + { + _handshake(handshakeProps); + }; + + /** + * Disconnects from the comet server. + * @param disconnectProps an object to be merged with the disconnect message + */ + this.disconnect = function(disconnectProps) + { + var bayeuxMessage = { + channel: '/meta/disconnect' + }; + var message = $.extend({}, disconnectProps, bayeuxMessage); + // Deliver immediately + // The handshake and connect mechanism make use of startBatch(), and in case + // of a failed handshake the disconnect would not be delivered if using _send(). + _setStatus('disconnecting'); + _deliver([message], false); + }; + + /** + * Marks the start of a batch of application messages to be sent to the server + * in a single request, obtaining a single response containing (possibly) many + * application reply messages. + * Messages are held in a queue and not sent until {@link #endBatch()} is called. + * If startBatch() is called multiple times, then an equal number of endBatch() + * calls must be made to close and send the batch of messages. + * @see #endBatch() + */ + this.startBatch = function() + { + _startBatch(); + }; + + /** + * Marks the end of a batch of application messages to be sent to the server + * in a single request. + * @see #startBatch() + */ + this.endBatch = function() + { + _endBatch(true); + }; + + /** + * Subscribes to the given channel, performing the given callback in the given scope + * when a message for the channel arrives. + * @param channel the channel to subscribe to + * @param scope the scope of the callback + * @param callback the callback to call when a message is delivered to the channel + * @param subscribeProps an object to be merged with the subscribe message + * @return the subscription handle to be passed to {@link #unsubscribe(object)} + */ + this.subscribe = function(channel, scope, callback, subscribeProps) + { + var subscription = this.addListener(channel, scope, callback); + + // Send the subscription message after the subscription registration to avoid + // races where the server would deliver a message to the subscribers, but here + // on the client the subscription has not been added yet to the data structures + var bayeuxMessage = { + channel: '/meta/subscribe', + subscription: channel + }; + var message = $.extend({}, subscribeProps, bayeuxMessage); + _send(message); + + return subscription; + }; + + /** + * Unsubscribes the subscription obtained with a call to {@link #subscribe(string, object, function)}. + * @param subscription the subscription to unsubscribe. + */ + this.unsubscribe = function(subscription, unsubscribeProps) + { + // Remove the local listener before sending the message + // This ensures that if the server fails, this client does not get notifications + this.removeListener(subscription); + var bayeuxMessage = { + channel: '/meta/unsubscribe', + subscription: subscription[0] + }; + var message = $.extend({}, unsubscribeProps, bayeuxMessage); + _send(message); + }; + + /** + * Publishes a message on the given channel, containing the given content. + * @param channel the channel to publish the message to + * @param content the content of the message + * @param publishProps an object to be merged with the publish message + */ + this.publish = function(channel, content, publishProps) + { + var bayeuxMessage = { + channel: channel, + data: content + }; + var message = $.extend({}, publishProps, bayeuxMessage); + _send(message); + }; + + /** + * Adds a listener for bayeux messages, performing the given callback in the given scope + * when a message for the given channel arrives. + * @param channel the channel the listener is interested to + * @param scope the scope of the callback + * @param callback the callback to call when a message is delivered to the channel + * @returns the subscription handle to be passed to {@link #removeListener(object)} + * @see #removeListener(object) + */ + this.addListener = function(channel, scope, callback) + { + // The data structure is a map<channel, subscription[]>, where each subscription + // holds the callback to be called and its scope. + + // Normalize arguments + if (!callback) + { + callback = scope; + scope = undefined; + } + + var subscription = { + scope: scope, + callback: callback + }; + + var subscriptions = _listeners[channel]; + if (!subscriptions) + { + subscriptions = []; + _listeners[channel] = subscriptions; + } + // Pushing onto an array appends at the end and returns the id associated with the element increased by 1. + // Note that if: + // a.push('a'); var hb=a.push('b'); delete a[hb-1]; var hc=a.push('c'); + // then: + // hc==3, a.join()=='a',,'c', a.length==3 + var subscriptionIndex = subscriptions.push(subscription) - 1; + _debug('Added listener: channel \'{}\', callback \'{}\', index {}', channel, callback.name, subscriptionIndex); + + // The subscription to allow removal of the listener is made of the channel and the index + return [channel, subscriptionIndex]; + }; + + /** + * Removes the subscription obtained with a call to {@link #addListener(string, object, function)}. + * @param subscription the subscription to unsubscribe. + */ + this.removeListener = function(subscription) + { + var subscriptions = _listeners[subscription[0]]; + if (subscriptions) + { + delete subscriptions[subscription[1]]; + _debug('Removed listener: channel \'{}\', index {}', subscription[0], subscription[1]); + } + }; + + /** + * Removes all listeners registered with {@link #addListener(channel, scope, callback)} or + * {@link #subscribe(channel, scope, callback)}. + */ + this.clearListeners = function() + { + _listeners = {}; + }; + + /** + * Returns a string representing the status of the bayeux communication with the comet server. + */ + this.getStatus = function() + { + return _status; + }; + + /** + * Sets the backoff period used to increase the backoff time when retrying an unsuccessful or failed message. + * Default value is 1 second, which means if there is a persistent failure the retries will happen + * after 1 second, then after 2 seconds, then after 3 seconds, etc. So for example with 15 seconds of + * elapsed time, there will be 5 retries (at 1, 3, 6, 10 and 15 seconds elapsed). + * @param period the backoff period to set + * @see #getBackoffIncrement() + */ + this.setBackoffIncrement = function(period) + { + _backoffIncrement = period; + }; + + /** + * Returns the backoff period used to increase the backoff time when retrying an unsuccessful or failed message. + * @see #setBackoffIncrement(period) + */ + this.getBackoffIncrement = function() + { + return _backoffIncrement; + }; + + /** + * Returns the backoff period to wait before retrying an unsuccessful or failed message. + */ + this.getBackoffPeriod = function() + { + return _backoff; + }; + + /** + * Sets the log level for console logging. + * Valid values are the strings 'error', 'warn', 'info' and 'debug', from + * less verbose to more verbose. + * @param level the log level string + */ + this.setLogLevel = function(level) + { + _logLevel = level; + }; + + /** + * Registers an extension whose callbacks are called for every incoming message + * (that comes from the server to this client implementation) and for every + * outgoing message (that originates from this client implementation for the + * server). + * The format of the extension object is the following: + * <pre> + * { + * incoming: function(message) { ... }, + * outgoing: function(message) { ... } + * } + * Both properties are optional, but if they are present they will be called + * respectively for each incoming message and for each outgoing message. + * </pre> + * @param name the name of the extension + * @param extension the extension to register + * @return true if the extension was registered, false otherwise + * @see #unregisterExtension(name) + */ + this.registerExtension = function(name, extension) + { + var existing = false; + for (var i = 0; i < _extensions.length; ++i) + { + var existingExtension = _extensions[i]; + if (existingExtension.name == name) + { + existing = true; + return false; + } + } + if (!existing) + { + _extensions.push({ + name: name, + extension: extension + }); + _debug('Registered extension \'{}\'', name); + return true; + } + else + { + _info('Could not register extension with name \'{}\': another extension with the same name already exists'); + return false; + } + }; + + /** + * Unregister an extension previously registered with + * {@link #registerExtension(name, extension)}. + * @param name the name of the extension to unregister. + * @return true if the extension was unregistered, false otherwise + */ + this.unregisterExtension = function(name) + { + var unregistered = false; + $.each(_extensions, function(index, extension) + { + if (extension.name == name) + { + _extensions.splice(index, 1); + unregistered = true; + _debug('Unregistered extension \'{}\'', name); + return false; + } + }); + return unregistered; + }; + + /** + * Starts a the batch of messages to be sent in a single request. + * @see _endBatch(deliverMessages) + */ + function _startBatch() + { + ++_batch; + }; + + /** + * Ends the batch of messages to be sent in a single request, + * optionally delivering messages present in the message queue depending + * on the given argument. + * @param deliverMessages whether to deliver the messages in the queue or not + * @see _startBatch() + */ + function _endBatch(deliverMessages) + { + --_batch; + if (_batch < 0) _batch = 0; + if (deliverMessages && _batch == 0 && !_isDisconnected()) + { + var messages = _messageQueue; + _messageQueue = []; + if (messages.length > 0) _deliver(messages, false); + } + }; + + function _nextMessageId() + { + return ++_messageId; + }; + + /** + * Converts the given response into an array of bayeux messages + * @param response the response to convert + * @return an array of bayeux messages obtained by converting the response + */ + function _convertToMessages(response) + { + if (response === undefined) return []; + if (response instanceof Array) return response; + if (response instanceof String || typeof response == 'string') return eval('(' + response + ')'); + if (response instanceof Object) return [response]; + throw 'Conversion Error ' + response + ', typeof ' + (typeof response); + }; + + function _setStatus(newStatus) + { + _debug('{} -> {}', _status, newStatus); + _status = newStatus; + }; + + function _isDisconnected() + { + return _status == 'disconnecting' || _status == 'disconnected'; + }; + + /** + * Sends the initial handshake message + */ + function _handshake(handshakeProps) + { + _debug('Starting handshake'); + _clientId = null; + + // Start a batch. + // This is needed because handshake and connect are async. + // It may happen that the application calls init() then subscribe() + // and the subscribe message is sent before the connect message, if + // the subscribe message is not held until the connect message is sent. + // So here we start a batch to hold temporarly any message until + // the connection is fully established. + _batch = 0; + _startBatch(); + + // Save the original properties provided by the user + // Deep copy to avoid the user to be able to change them later + _handshakeProps = $.extend(true, {}, handshakeProps); + + var bayeuxMessage = { + version: '1.0', + minimumVersion: '0.9', + channel: '/meta/handshake', + supportedConnectionTypes: _xd ? ['callback-polling'] : ['long-polling', 'callback-polling'] + }; + // Do not allow the user to mess with the required properties, + // so merge first the user properties and *then* the bayeux message + var message = $.extend({}, handshakeProps, bayeuxMessage); + + // We started a batch to hold the application messages, + // so here we must bypass it and deliver immediately. + _setStatus('handshaking'); + _deliver([message], false); + }; + + function _findTransport(handshakeResponse) + { + var transportTypes = handshakeResponse.supportedConnectionTypes; + if (_xd) + { + // If we are cross domain, check if the server supports it, that's the only option + if ($.inArray('callback-polling', transportTypes) >= 0) return _transport; + } + else + { + // Check if we can keep long-polling + if ($.inArray('long-polling', transportTypes) >= 0) return _transport; + + // The server does not support long-polling + if ($.inArray('callback-polling', transportTypes) >= 0) return newCallbackPollingTransport(); + } + return null; + }; + + function _delayedHandshake() + { + _setStatus('handshaking'); + _delayedSend(function() + { + _handshake(_handshakeProps); + }); + }; + + function _delayedConnect() + { + _setStatus('connecting'); + _delayedSend(function() + { + _connect(); + }); + }; + + function _delayedSend(operation) + { + _cancelDelayedSend(); + var delay = _backoff; + _debug("Delayed send: backoff {}, interval {}", _backoff, _advice.interval); + if (_advice.interval && _advice.interval > 0) + delay += _advice.interval; + _scheduledSend = _setTimeout(operation, delay); + }; + + function _cancelDelayedSend() + { + if (_scheduledSend !== null) clearTimeout(_scheduledSend); + _scheduledSend = null; + }; + + function _setTimeout(funktion, delay) + { + return setTimeout(function() + { + try + { + funktion(); + } + catch (x) + { + _debug('Exception during scheduled execution of function \'{}\': {}', funktion.name, x); + } + }, delay); + }; + + /** + * Sends the connect message + */ + function _connect() + { + _debug('Starting connect'); + var message = { + channel: '/meta/connect', + connectionType: _transport.getType() + }; + _setStatus('connecting'); + _deliver([message], true); + _setStatus('connected'); + }; + + function _send(message) + { + if (_batch > 0) + _messageQueue.push(message); + else + _deliver([message], false); + }; + + /** + * Delivers the messages to the comet server + * @param messages the array of messages to send + */ + function _deliver(messages, comet) + { + // We must be sure that the messages have a clientId. + // This is not guaranteed since the handshake may take time to return + // (and hence the clientId is not known yet) and the application + // may create other messages. + $.each(messages, function(index, message) + { + message['id'] = _nextMessageId(); + if (_clientId) message['clientId'] = _clientId; + messages[index] = _applyOutgoingExtensions(message); + }); + + var self = this; + var envelope = { + url: _url, + messages: messages, + onSuccess: function(request, response) + { + try + { + _handleSuccess.call(self, request, response, comet); + } + catch (x) + { + _debug('Exception during execution of success callback: {}', x); + } + }, + onFailure: function(request, reason, exception) + { + try + { + _handleFailure.call(self, request, messages, reason, exception, comet); + } + catch (x) + { + _debug('Exception during execution of failure callback: {}', x); + } + } + }; + _debug('Sending request to {}, message(s): {}', envelope.url, JSON.stringify(envelope.messages)); + _transport.send(envelope, comet); + }; + + function _applyIncomingExtensions(message) + { + for (var i = 0; i < _extensions.length; ++i) + { + var extension = _extensions[i]; + var callback = extension.extension.incoming; + if (callback && typeof callback === 'function') + { + _debug('Calling incoming extension \'{}\', callback \'{}\'', extension.name, callback.name); + message = _applyExtension(extension.name, callback, message) || message; + } + } + return message; + }; + + function _applyOutgoingExtensions(message) + { + for (var i = 0; i < _extensions.length; ++i) + { + var extension = _extensions[i]; + var callback = extension.extension.outgoing; + if (callback && typeof callback === 'function') + { + _debug('Calling outgoing extension \'{}\', callback \'{}\'', extension.name, callback.name); + message = _applyExtension(extension.name, callback, message) || message; + } + } + return message; + }; + + function _applyExtension(name, callback, message) + { + try + { + return callback(message); + } + catch (x) + { + _debug('Exception during execution of extension \'{}\': {}', name, x); + return message; + } + }; + + function _handleSuccess(request, response, comet) + { + var messages = _convertToMessages(response); + _debug('Received response {}', JSON.stringify(messages)); + + // Signal the transport it can deliver other queued requests + _transport.complete(request, true, comet); + + for (var i = 0; i < messages.length; ++i) + { + var message = messages[i]; + message = _applyIncomingExtensions(message); + + if (message.advice) _advice = message.advice; + + var channel = message.channel; + switch (channel) + { + case '/meta/handshake': + _handshakeSuccess(message); + break; + case '/meta/connect': + _connectSuccess(message); + break; + case '/meta/disconnect': + _disconnectSuccess(message); + break; + case '/meta/subscribe': + _subscribeSuccess(message); + break; + case '/meta/unsubscribe': + _unsubscribeSuccess(message); + break; + default: + _messageSuccess(message); + break; + } + } + }; + + function _handleFailure(request, messages, reason, exception, comet) + { + var xhr = request.xhr; + _debug('Request failed, status: {}, reason: {}, exception: {}', xhr && xhr.status, reason, exception); + + // Signal the transport it can deliver other queued requests + _transport.complete(request, false, comet); + + for (var i = 0; i < messages.length; ++i) + { + var message = messages[i]; + var channel = message.channel; + switch (channel) + { + case '/meta/handshake': + _handshakeFailure(xhr, message); + break; + case '/meta/connect': + _connectFailure(xhr, message); + break; + case '/meta/disconnect': + _disconnectFailure(xhr, message); + break; + case '/meta/subscribe': + _subscribeFailure(xhr, message); + break; + case '/meta/unsubscribe': + _unsubscribeFailure(xhr, message); + break; + default: + _messageFailure(xhr, message); + break; + } + } + }; + + function _handshakeSuccess(message) + { + if (message.successful) + { + _debug('Handshake successful'); + // Save clientId, figure out transport, then follow the advice to connect + _clientId = message.clientId; + + var newTransport = _findTransport(message); + if (newTransport === null) + { + throw 'Could not agree on transport with server'; + } + else + { + if (_transport.getType() != newTransport.getType()) + { + _debug('Changing transport from {} to {}', _transport.getType(), newTransport.getType()); + _transport = newTransport; + } + } + + // Notify the listeners + // Here the new transport is in place, as well as the clientId, so + // the listener can perform a publish() if it wants, and the listeners + // are notified before the connect below. + _notifyListeners('/meta/handshake', message); + + var action = _advice.reconnect ? _advice.reconnect : 'retry'; + switch (action) + { + case 'retry': + _delayedConnect(); + break; + default: + break; + } + } + else + { + _debug('Handshake unsuccessful'); + + var retry = !_isDisconnected() && _advice.reconnect != 'none'; + if (!retry) _setStatus('disconnected'); + + _notifyListeners('/meta/handshake', message); + _notifyListeners('/meta/unsuccessful', message); + + // Only try again if we haven't been disconnected and + // the advice permits us to retry the handshake + if (retry) + { + _increaseBackoff(); + _debug('Handshake failure, backing off and retrying in {} ms', _backoff); + _delayedHandshake(); + } + } + }; + + function _handshakeFailure(xhr, message) + { + _debug('Handshake failure'); + + // Notify listeners + var failureMessage = { + successful: false, + failure: true, + channel: '/meta/handshake', + request: message, + xhr: xhr, + advice: { + action: 'retry', + interval: _backoff + } + }; + + var retry = !_isDisconnected() && _advice.reconnect != 'none'; + if (!retry) _setStatus('disconnected'); + + _notifyListeners('/meta/handshake', failureMessage); + _notifyListeners('/meta/unsuccessful', failureMessage); + + // Only try again if we haven't been disconnected and the + // advice permits us to try again + if (retry) + { + _increaseBackoff(); + _debug('Handshake failure, backing off and retrying in {} ms', _backoff); + _delayedHandshake(); + } + }; + + function _connectSuccess(message) + { + var action = _isDisconnected() ? 'none' : (_advice.reconnect ? _advice.reconnect : 'retry'); + if (!_isDisconnected()) _setStatus(action == 'retry' ? 'connecting' : 'disconnecting'); + + if (message.successful) + { + _debug('Connect successful'); + + // End the batch and allow held messages from the application + // to go to the server (see _handshake() where we start the batch). + // The batch is ended before notifying the listeners, so that + // listeners can batch other cometd operations + _endBatch(true); + + // Notify the listeners after the status change but before the next connect + _notifyListeners('/meta/connect', message); + + // Connect was successful. + // Normally, the advice will say "reconnect: 'retry', interval: 0" + // and the server will hold the request, so when a response returns + // we immediately call the server again (long polling) + switch (action) + { + case 'retry': + _resetBackoff(); + _delayedConnect(); + break; + default: + _resetBackoff(); + _setStatus('disconnected'); + break; + } + } + else + { + _debug('Connect unsuccessful'); + + // Notify the listeners after the status change but before the next action + _notifyListeners('/meta/connect', message); + _notifyListeners('/meta/unsuccessful', message); + + // Connect was not successful. + // This may happen when the server crashed, the current clientId + // will be invalid, and the server will ask to handshake again + switch (action) + { + case 'retry': + _increaseBackoff(); + _delayedConnect(); + break; + case 'handshake': + // End the batch but do not deliver the messages until we connect successfully + _endBatch(false); + _resetBackoff(); + _delayedHandshake(); + break; + case 'none': + _resetBackoff(); + _setStatus('disconnected'); + break; + } + } + }; + + function _connectFailure(xhr, message) + { + _debug('Connect failure'); + + // Notify listeners + var failureMessage = { + successful: false, + failure: true, + channel: '/meta/connect', + request: message, + xhr: xhr, + advice: { + action: 'retry', + interval: _backoff + } + }; + _notifyListeners('/meta/connect', failureMessage); + _notifyListeners('/meta/unsuccessful', failureMessage); + + if (!_isDisconnected()) + { + var action = _advice.reconnect ? _advice.reconnect : 'retry'; + switch (action) + { + case 'retry': + _increaseBackoff(); + _debug('Connect failure, backing off and retrying in {} ms', _backoff); + _delayedConnect(); + break; + case 'handshake': + _resetBackoff(); + _delayedHandshake(); + break; + case 'none': + _resetBackoff(); + break; + default: + _debug('Unrecognized reconnect value: {}', action); + break; + } + } + }; + + function _disconnectSuccess(message) + { + if (message.successful) + { + _debug('Disconnect successful'); + _disconnect(false); + _notifyListeners('/meta/disconnect', message); + } + else + { + _debug('Disconnect unsuccessful'); + _disconnect(true); + _notifyListeners('/meta/disconnect', message); + _notifyListeners('/meta/usuccessful', message); + } + }; + + function _disconnect(abort) + { + _cancelDelayedSend(); + if (abort) _transport.abort(); + _clientId = null; + _setStatus('disconnected'); + _batch = 0; + _messageQueue = []; + _resetBackoff(); + }; + + function _disconnectFailure(xhr, message) + { + _debug('Disconnect failure'); + _disconnect(true); + + var failureMessage = { + successful: false, + failure: true, + channel: '/meta/disconnect', + request: message, + xhr: xhr, + advice: { + action: 'none', + interval: 0 + } + }; + _notifyListeners('/meta/disconnect', failureMessage); + _notifyListeners('/meta/unsuccessful', failureMessage); + }; + + function _subscribeSuccess(message) + { + if (message.successful) + { + _debug('Subscribe successful'); + _notifyListeners('/meta/subscribe', message); + } + else + { + _debug('Subscribe unsuccessful'); + _notifyListeners('/meta/subscribe', message); + _notifyListeners('/meta/unsuccessful', message); + } + }; + + function _subscribeFailure(xhr, message) + { + _debug('Subscribe failure'); + + var failureMessage = { + successful: false, + failure: true, + channel: '/meta/subscribe', + request: message, + xhr: xhr, + advice: { + action: 'none', + interval: 0 + } + }; + _notifyListeners('/meta/subscribe', failureMessage); + _notifyListeners('/meta/unsuccessful', failureMessage); + }; + + function _unsubscribeSuccess(message) + { + if (message.successful) + { + _debug('Unsubscribe successful'); + _notifyListeners('/meta/unsubscribe', message); + } + else + { + _debug('Unsubscribe unsuccessful'); + _notifyListeners('/meta/unsubscribe', message); + _notifyListeners('/meta/unsuccessful', message); + } + }; + + function _unsubscribeFailure(xhr, message) + { + _debug('Unsubscribe failure'); + + var failureMessage = { + successful: false, + failure: true, + channel: '/meta/unsubscribe', + request: message, + xhr: xhr, + advice: { + action: 'none', + interval: 0 + } + }; + _notifyListeners('/meta/unsubscribe', failureMessage); + _notifyListeners('/meta/unsuccessful', failureMessage); + }; + + function _messageSuccess(message) + { + if (message.successful === undefined) + { + if (message.data) + { + // It is a plain message, and not a bayeux meta message + _notifyListeners(message.channel, message); + } + else + { + _debug('Unknown message {}', JSON.stringify(message)); + } + } + else + { + if (message.successful) + { + _debug('Publish successful'); + _notifyListeners('/meta/publish', message); + } + else + { + _debug('Publish unsuccessful'); + _notifyListeners('/meta/publish', message); + _notifyListeners('/meta/unsuccessful', message); + } + } + }; + + function _messageFailure(xhr, message) + { + _debug('Publish failure'); + + var failureMessage = { + successful: false, + failure: true, + channel: message.channel, + request: message, + xhr: xhr, + advice: { + action: 'none', + interval: 0 + } + }; + _notifyListeners('/meta/publish', failureMessage); + _notifyListeners('/meta/unsuccessful', failureMessage); + }; + + function _notifyListeners(channel, message) + { + // Notify direct listeners + _notify(channel, message); + + // Notify the globbing listeners + var channelParts = channel.split("/"); + var last = channelParts.length - 1; + for (var i = last; i > 0; --i) + { + var channelPart = channelParts.slice(0, i).join('/') + '/*'; + // We don't want to notify /foo/* if the channel is /foo/bar/baz, + // so we stop at the first non recursive globbing + if (i == last) _notify(channelPart, message); + // Add the recursive globber and notify + channelPart += '*'; + _notify(channelPart, message); + } + }; + + function _notify(channel, message) + { + var subscriptions = _listeners[channel]; + if (subscriptions && subscriptions.length > 0) + { + for (var i = 0; i < subscriptions.length; ++i) + { + var subscription = subscriptions[i]; + // Subscriptions may come and go, so the array may have 'holes' + if (subscription) + { + try + { + _debug('Notifying subscription: channel \'{}\', callback \'{}\'', channel, subscription.callback.name); + subscription.callback.call(subscription.scope, message); + } + catch (x) + { + // Ignore exceptions from callbacks + _warn('Exception during execution of callback \'{}\' on channel \'{}\' for message {}, exception: {}', subscription.callback.name, channel, JSON.stringify(message), x); + } + } + } + } + }; + + function _resetBackoff() + { + _backoff = 0; + }; + + function _increaseBackoff() + { + if (_backoff < _maxBackoff) _backoff += _backoffIncrement; + }; + + var _error = this._error = function(text, args) + { + _log('error', _format.apply(this, arguments)); + }; + + var _warn = this._warn = function(text, args) + { + _log('warn', _format.apply(this, arguments)); + }; + + var _info = this._info = function(text, args) + { + _log('info', _format.apply(this, arguments)); + }; + + var _debug = this._debug = function(text, args) + { + _log('debug', _format.apply(this, arguments)); + }; + + function _log(level, text) + { + var priority = _logPriorities[level]; + var configPriority = _logPriorities[_logLevel]; + if (!configPriority) configPriority = _logPriorities['info']; + if (priority >= configPriority) + { + if (window.console) window.console.log(text); + } + }; + + function _format(text) + { + var braces = /\{\}/g; + var result = ''; + var start = 0; + var count = 0; + while (braces.test(text)) + { + result += text.substr(start, braces.lastIndex - start - 2); + var arg = arguments[++count]; + result += arg !== undefined ? arg : '{}'; + start = braces.lastIndex; + } + result += text.substr(start, text.length - start); + return result; + }; + + function newLongPollingTransport() + { + return $.extend({}, new Transport('long-polling'), new LongPollingTransport()); + }; + + function newCallbackPollingTransport() + { + return $.extend({}, new Transport('callback-polling'), new CallbackPollingTransport()); + }; + + /** + * Base object with the common functionality for transports. + * The key responsibility is to allow at most 2 outstanding requests to the server, + * to avoid that requests are sent behind a long poll. + * To achieve this, we have one reserved request for the long poll, and all other + * requests are serialized one after the other. + */ + var Transport = function(type) + { + var _maxRequests = 2; + var _requestIds = 0; + var _cometRequest = null; + var _requests = []; + var _packets = []; + + this.getType = function() + { + return type; + }; + + this.send = function(packet, comet) + { + if (comet) + _cometSend(this, packet); + else + _send(this, packet); + }; + + function _cometSend(self, packet) + { + if (_cometRequest !== null) throw 'Concurrent comet requests not allowed, request ' + _cometRequest.id + ' not yet completed'; + + var requestId = ++_requestIds; + _debug('Beginning comet request {}', requestId); + + var request = {id: requestId}; + _debug('Delivering comet request {}', requestId); + self.deliver(packet, request); + _cometRequest = request; + }; + + function _send(self, packet) + { + var requestId = ++_requestIds; + _debug('Beginning request {}, {} other requests, {} queued requests', requestId, _requests.length, _packets.length); + + var request = {id: requestId}; + // Consider the comet request which should always be present + if (_requests.length < _maxRequests - 1) + { + _debug('Delivering request {}', requestId); + self.deliver(packet, request); + _requests.push(request); + } + else + { + _packets.push([packet, request]); + _debug('Queued request {}, {} queued requests', requestId, _packets.length); + } + }; + + this.complete = function(request, success, comet) + { + if (comet) + _cometComplete(request); + else + _complete(this, request, success); + }; + + function _cometComplete(request) + { + var requestId = request.id; + if (_cometRequest !== request) throw 'Comet request mismatch, completing request ' + requestId; + + // Reset comet request + _cometRequest = null; + _debug('Ended comet request {}', requestId); + }; + + function _complete(self, request, success) + { + var requestId = request.id; + var index = $.inArray(request, _requests); + // The index can be negative the request has been aborted + if (index >= 0) _requests.splice(index, 1); + _debug('Ended request {}, {} other requests, {} queued requests', requestId, _requests.length, _packets.length); + + if (_packets.length > 0) + { + var packet = _packets.shift(); + if (success) + { + _debug('Dequeueing and sending request {}, {} queued requests', packet[1].id, _packets.length); + _send(self, packet[0]); + } + else + { + _debug('Dequeueing and failing request {}, {} queued requests', packet[1].id, _packets.length); + // Keep the semantic of calling response callbacks asynchronously after the request + setTimeout(function() { packet[0].onFailure(packet[1], 'error'); }, 0); + } + } + }; + + this.abort = function() + { + for (var i = 0; i < _requests.length; ++i) + { + var request = _requests[i]; + _debug('Aborting request {}', request.id); + if (request.xhr) request.xhr.abort(); + } + if (_cometRequest) + { + _debug('Aborting comet request {}', _cometRequest.id); + if (_cometRequest.xhr) _cometRequest.xhr.abort(); + } + _cometRequest = null; + _requests = []; + _packets = []; + }; + }; + + var LongPollingTransport = function() + { + this.deliver = function(packet, request) + { + request.xhr = $.ajax({ + url: packet.url, + type: 'POST', + contentType: 'text/json;charset=UTF-8', + beforeSend: function(xhr) + { + xhr.setRequestHeader('Connection', 'Keep-Alive'); + return true; + }, + data: JSON.stringify(packet.messages), + success: function(response) { packet.onSuccess(request, response); }, + error: function(xhr, reason, exception) { packet.onFailure(request, reason, exception); } + }); + }; + }; + + var CallbackPollingTransport = function() + { + var _maxLength = 2000; + this.deliver = function(packet, request) + { + // Microsoft Internet Explorer has a 2083 URL max length + // We must ensure that we stay within that length + var messages = JSON.stringify(packet.messages); + // Encode the messages because all brackets, quotes, commas, colons, etc + // present in the JSON will be URL encoded, taking many more characters + var urlLength = packet.url.length + encodeURI(messages).length; + _debug('URL length: {}', urlLength); + // Let's stay on the safe side and use 2000 instead of 2083 + // also because we did not count few characters among which + // the parameter name 'message' and the parameter 'jsonp', + // which sum up to about 50 chars + if (urlLength > _maxLength) + { + var x = packet.messages.length > 1 ? + 'Too many bayeux messages in the same batch resulting in message too big ' + + '(' + urlLength + ' bytes, max is ' + _maxLength + ') for transport ' + this.getType() : + 'Bayeux message too big (' + urlLength + ' bytes, max is ' + _maxLength + ') ' + + 'for transport ' + this.getType(); + // Keep the semantic of calling response callbacks asynchronously after the request + _setTimeout(function() { packet.onFailure(request, 'error', x); }, 0); + } + else + { + $.ajax({ + url: packet.url, + type: 'GET', + dataType: 'jsonp', + jsonp: 'jsonp', + beforeSend: function(xhr) + { + xhr.setRequestHeader('Connection', 'Keep-Alive'); + return true; + }, + data: + { + // In callback-polling, the content must be sent via the 'message' parameter + message: messages + }, + success: function(response) { packet.onSuccess(request, response); }, + error: function(xhr, reason, exception) { packet.onFailure(request, reason, exception); } + }); + } + }; + }; + }; + + /** + * The JS object that exposes the comet API to applications + */ + $.cometd = new $.Cometd(); // The default instance + +})(jQuery); diff --git a/plugins/Comet/json2.js b/plugins/Comet/json2.js new file mode 100644 index 000000000..7e27df518 --- /dev/null +++ b/plugins/Comet/json2.js @@ -0,0 +1,478 @@ +/* + http://www.JSON.org/json2.js + 2009-04-16 + + Public Domain. + + NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. + + See http://www.JSON.org/js.html + + This file creates a global JSON object containing two methods: stringify + and parse. + + JSON.stringify(value, replacer, space) + value any JavaScript value, usually an object or array. + + replacer an optional parameter that determines how object + values are stringified for objects. It can be a + function or an array of strings. + + space an optional parameter that specifies the indentation + of nested structures. If it is omitted, the text will + be packed without extra whitespace. If it is a number, + it will specify the number of spaces to indent at each + level. If it is a string (such as '\t' or ' '), + it contains the characters used to indent at each level. + + This method produces a JSON text from a JavaScript value. + + When an object value is found, if the object contains a toJSON + method, its toJSON method will be called and the result will be + stringified. A toJSON method does not serialize: it returns the + value represented by the name/value pair that should be serialized, + or undefined if nothing should be serialized. The toJSON method + will be passed the key associated with the value, and this will be + bound to the object holding the key. + + For example, this would serialize Dates as ISO strings. + + Date.prototype.toJSON = function (key) { + function f(n) { + // Format integers to have at least two digits. + return n < 10 ? '0' + n : n; + } + + return this.getUTCFullYear() + '-' + + f(this.getUTCMonth() + 1) + '-' + + f(this.getUTCDate()) + 'T' + + f(this.getUTCHours()) + ':' + + f(this.getUTCMinutes()) + ':' + + f(this.getUTCSeconds()) + 'Z'; + }; + + You can provide an optional replacer method. It will be passed the + key and value of each member, with this bound to the containing + object. The value that is returned from your method will be + serialized. If your method returns undefined, then the member will + be excluded from the serialization. + + If the replacer parameter is an array of strings, then it will be + used to select the members to be serialized. It filters the results + such that only members with keys listed in the replacer array are + stringified. + + Values that do not have JSON representations, such as undefined or + functions, will not be serialized. Such values in objects will be + dropped; in arrays they will be replaced with null. You can use + a replacer function to replace those with JSON values. + JSON.stringify(undefined) returns undefined. + + The optional space parameter produces a stringification of the + value that is filled with line breaks and indentation to make it + easier to read. + + If the space parameter is a non-empty string, then that string will + be used for indentation. If the space parameter is a number, then + the indentation will be that many spaces. + + Example: + + text = JSON.stringify(['e', {pluribus: 'unum'}]); + // text is '["e",{"pluribus":"unum"}]' + + + text = JSON.stringify(['e', {pluribus: 'unum'}], null, '\t'); + // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' + + text = JSON.stringify([new Date()], function (key, value) { + return this[key] instanceof Date ? + 'Date(' + this[key] + ')' : value; + }); + // text is '["Date(---current time---)"]' + + + JSON.parse(text, reviver) + This method parses a JSON text to produce an object or array. + It can throw a SyntaxError exception. + + The optional reviver parameter is a function that can filter and + transform the results. It receives each of the keys and values, + and its return value is used instead of the original value. + If it returns what it received, then the structure is not modified. + If it returns undefined then the member is deleted. + + Example: + + // Parse the text. Values that look like ISO date strings will + // be converted to Date objects. + + myData = JSON.parse(text, function (key, value) { + var a; + if (typeof value === 'string') { + a = +/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); + if (a) { + return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], + +a[5], +a[6])); + } + } + return value; + }); + + myData = JSON.parse('["Date(09/09/2001)"]', function (key, value) { + var d; + if (typeof value === 'string' && + value.slice(0, 5) === 'Date(' && + value.slice(-1) === ')') { + d = new Date(value.slice(5, -1)); + if (d) { + return d; + } + } + return value; + }); + + + This is a reference implementation. You are free to copy, modify, or + redistribute. + + This code should be minified before deployment. + See http://javascript.crockford.com/jsmin.html + + USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO + NOT CONTROL. +*/ + +/*jslint evil: true */ + +/*global JSON */ + +/*members "", "\b", "\t", "\n", "\f", "\r", "\"", JSON, "\\", apply, + call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, + getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, + lastIndex, length, parse, prototype, push, replace, slice, stringify, + test, toJSON, toString, valueOf +*/ + +// Create a JSON object only if one does not already exist. We create the +// methods in a closure to avoid creating global variables. + +if (!this.JSON) { + JSON = {}; +} +(function () { + + function f(n) { + // Format integers to have at least two digits. + return n < 10 ? '0' + n : n; + } + + if (typeof Date.prototype.toJSON !== 'function') { + + Date.prototype.toJSON = function (key) { + + return this.getUTCFullYear() + '-' + + f(this.getUTCMonth() + 1) + '-' + + f(this.getUTCDate()) + 'T' + + f(this.getUTCHours()) + ':' + + f(this.getUTCMinutes()) + ':' + + f(this.getUTCSeconds()) + 'Z'; + }; + + String.prototype.toJSON = + Number.prototype.toJSON = + Boolean.prototype.toJSON = function (key) { + return this.valueOf(); + }; + } + + var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + gap, + indent, + meta = { // table of character substitutions + '\b': '\\b', + '\t': '\\t', + '\n': '\\n', + '\f': '\\f', + '\r': '\\r', + '"' : '\\"', + '\\': '\\\\' + }, + rep; + + + function quote(string) { + +// If the string contains no control characters, no quote characters, and no +// backslash characters, then we can safely slap some quotes around it. +// Otherwise we must also replace the offending characters with safe escape +// sequences. + + escapable.lastIndex = 0; + return escapable.test(string) ? + '"' + string.replace(escapable, function (a) { + var c = meta[a]; + return typeof c === 'string' ? c : + '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }) + '"' : + '"' + string + '"'; + } + + + function str(key, holder) { + +// Produce a string from holder[key]. + + var i, // The loop counter. + k, // The member key. + v, // The member value. + length, + mind = gap, + partial, + value = holder[key]; + +// If the value has a toJSON method, call it to obtain a replacement value. + + if (value && typeof value === 'object' && + typeof value.toJSON === 'function') { + value = value.toJSON(key); + } + +// If we were called with a replacer function, then call the replacer to +// obtain a replacement value. + + if (typeof rep === 'function') { + value = rep.call(holder, key, value); + } + +// What happens next depends on the value's type. + + switch (typeof value) { + case 'string': + return quote(value); + + case 'number': + +// JSON numbers must be finite. Encode non-finite numbers as null. + + return isFinite(value) ? String(value) : 'null'; + + case 'boolean': + case 'null': + +// If the value is a boolean or null, convert it to a string. Note: +// typeof null does not produce 'null'. The case is included here in +// the remote chance that this gets fixed someday. + + return String(value); + +// If the type is 'object', we might be dealing with an object or an array or +// null. + + case 'object': + +// Due to a specification blunder in ECMAScript, typeof null is 'object', +// so watch out for that case. + + if (!value) { + return 'null'; + } + +// Make an array to hold the partial results of stringifying this object value. + + gap += indent; + partial = []; + +// Is the value an array? + + if (Object.prototype.toString.apply(value) === '[object Array]') { + +// The value is an array. Stringify every element. Use null as a placeholder +// for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || 'null'; + } + +// Join all of the elements together, separated with commas, and wrap them in +// brackets. + + v = partial.length === 0 ? '[]' : + gap ? '[\n' + gap + + partial.join(',\n' + gap) + '\n' + + mind + ']' : + '[' + partial.join(',') + ']'; + gap = mind; + return v; + } + +// If the replacer is an array, use it to select the members to be stringified. + + if (rep && typeof rep === 'object') { + length = rep.length; + for (i = 0; i < length; i += 1) { + k = rep[i]; + if (typeof k === 'string') { + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + } else { + +// Otherwise, iterate through all of the keys in the object. + + for (k in value) { + if (Object.hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + } + +// Join all of the member texts together, separated with commas, +// and wrap them in braces. + + v = partial.length === 0 ? '{}' : + gap ? '{\n' + gap + partial.join(',\n' + gap) + '\n' + + mind + '}' : '{' + partial.join(',') + '}'; + gap = mind; + return v; + } + } + +// If the JSON object does not yet have a stringify method, give it one. + + if (typeof JSON.stringify !== 'function') { + JSON.stringify = function (value, replacer, space) { + +// The stringify method takes a value and an optional replacer, and an optional +// space parameter, and returns a JSON text. The replacer can be a function +// that can replace values, or an array of strings that will select the keys. +// A default replacer method can be provided. Use of the space parameter can +// produce text that is more easily readable. + + var i; + gap = ''; + indent = ''; + +// If the space parameter is a number, make an indent string containing that +// many spaces. + + if (typeof space === 'number') { + for (i = 0; i < space; i += 1) { + indent += ' '; + } + +// If the space parameter is a string, it will be used as the indent string. + + } else if (typeof space === 'string') { + indent = space; + } + +// If there is a replacer, it must be a function or an array. +// Otherwise, throw an error. + + rep = replacer; + if (replacer && typeof replacer !== 'function' && + (typeof replacer !== 'object' || + typeof replacer.length !== 'number')) { + throw new Error('JSON.stringify'); + } + +// Make a fake root object containing our value under the key of ''. +// Return the result of stringifying the value. + + return str('', {'': value}); + }; + } + + +// If the JSON object does not yet have a parse method, give it one. + + if (typeof JSON.parse !== 'function') { + JSON.parse = function (text, reviver) { + +// The parse method takes a text and an optional reviver function, and returns +// a JavaScript value if the text is a valid JSON text. + + var j; + + function walk(holder, key) { + +// The walk method is used to recursively walk the resulting structure so +// that modifications can be made. + + var k, v, value = holder[key]; + if (value && typeof value === 'object') { + for (k in value) { + if (Object.hasOwnProperty.call(value, k)) { + v = walk(value, k); + if (v !== undefined) { + value[k] = v; + } else { + delete value[k]; + } + } + } + } + return reviver.call(holder, key, value); + } + + +// Parsing happens in four stages. In the first stage, we replace certain +// Unicode characters with escape sequences. JavaScript handles many characters +// incorrectly, either silently deleting them, or treating them as line endings. + + cx.lastIndex = 0; + if (cx.test(text)) { + text = text.replace(cx, function (a) { + return '\\u' + + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }); + } + +// In the second stage, we run the text against regular expressions that look +// for non-JSON patterns. We are especially concerned with '()' and 'new' +// because they can cause invocation, and '=' because it can cause mutation. +// But just to be safe, we want to reject all unexpected forms. + +// We split the second stage into 4 regexp operations in order to work around +// crippling inefficiencies in IE's and Safari's regexp engines. First we +// replace the JSON backslash pairs with '@' (a non-JSON character). Second, we +// replace all simple value tokens with ']' characters. Third, we delete all +// open brackets that follow a colon or comma or that begin the text. Finally, +// we look to see that the remaining characters are only whitespace or ']' or +// ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval. + + if (/^[\],:{}\s]*$/. +test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@'). +replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']'). +replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { + +// In the third stage we use the eval function to compile the text into a +// JavaScript structure. The '{' operator is subject to a syntactic ambiguity +// in JavaScript: it can begin a block or an object literal. We wrap the text +// in parens to eliminate the ambiguity. + + j = eval('(' + text + ')'); + +// In the optional fourth stage, we recursively walk the new structure, passing +// each name/value pair to a reviver function for possible transformation. + + return typeof reviver === 'function' ? + walk({'': j}, '') : j; + } + +// If the text is not JSON parseable, then a SyntaxError is thrown. + + throw new SyntaxError('JSON.parse'); + }; + } +}()); diff --git a/plugins/Comet/updatetimeline.js b/plugins/Comet/updatetimeline.js new file mode 100644 index 000000000..de750baba --- /dev/null +++ b/plugins/Comet/updatetimeline.js @@ -0,0 +1,77 @@ +// update the local timeline from a Comet server +// + +var updater = function() +{ + var _cometd; + + return { + init: function(server, timeline) + { + _cometd = $.cometd; // Uses the default Comet object + _cometd.setLogLevel('debug'); + _cometd.init(server); + _cometd.subscribe(timeline, receive); + $(window).unload(leave); + } + } + + function leave() + { + _cometd.disconnect(); + } + + function receive(message) + { + id = message.data.id; + + // Don't add it if it already exists + + if ($("#notice-"+id).length > 0) { + return; + } + + var noticeItem = makeNoticeItem(message.data); + $("#notices_primary .notices").prepend(noticeItem, true); + $("#notices_primary .notice:first").css({display:"none"}); + $("#notices_primary .notice:first").fadeIn(2500); + NoticeHover(); + NoticeReply(); + } + + function makeNoticeItem(data) + { + user = data['user']; + html = data['html'].replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); + + ni = "<li class=\"hentry notice\" id=\"notice-"+data['id']+"\">"+ + "<div class=\"entry-title\">"+ + "<span class=\"vcard author\">"+ + "<a href=\""+user['profile_url']+"\" class=\"url\">"+ + "<img src=\""+user['profile_image_url']+"\" class=\"avatar photo\" width=\"48\" height=\"48\" alt=\""+user['screen_name']+"\"/>"+ + "<span class=\"nickname fn\">"+user['screen_name']+"</span>"+ + "</a>"+ + "</span>"+ + "<p class=\"entry-content\">"+html+"</p>"+ + "</div>"+ + "<div class=\"entry-content\">"+ + "<dl class=\"timestamp\">"+ + "<dt>Published</dt>"+ + "<dd>"+ + "<a rel=\"bookmark\" href=\""+data['url']+"\" >"+ + "<abbr class=\"published\" title=\""+data['created_at']+"\">a few seconds ago</abbr>"+ + "</a> "+ + "</dd>"+ + "</dl>"+ + "<dl class=\"device\">"+ + "<dt>From</dt> "+ + "<dd>"+data['source']+"</dd>"+ + "</dl>"+ + "</div>"+ + "<div class=\"notice-options\">"+ + "</div>"+ + "</li>"; + return ni; + } +}(); + diff --git a/plugins/LinkbackPlugin.php b/plugins/LinkbackPlugin.php index 881ead99e..93a0294c4 100644 --- a/plugins/LinkbackPlugin.php +++ b/plugins/LinkbackPlugin.php @@ -121,6 +121,12 @@ class LinkbackPlugin extends Plugin { $args = array($this->notice->uri, $url); + if (!extension_loaded('xmlrpc')) { + if (!dl('xmlrpc.so')) { + common_log(LOG_ERR, "Can't pingback; xmlrpc extension not available."); + } + } + $request = xmlrpc_encode_request('pingback.ping', $args); $context = stream_context_create(array('http' => array('method' => "POST", 'header' => @@ -141,7 +147,7 @@ class LinkbackPlugin extends Plugin } // Largely cadged from trackback_cls.php by - // Ran Aroussi <ran@blogish.org>, GPL2 + // Ran Aroussi <ran@blogish.org>, GPL2 or any later version // http://phptrackback.sourceforge.net/ function getTrackback($text, $url) diff --git a/scripts/inboxqueuehandler.php b/scripts/inboxqueuehandler.php new file mode 100755 index 000000000..73d31e854 --- /dev/null +++ b/scripts/inboxqueuehandler.php @@ -0,0 +1,69 @@ +#!/usr/bin/env php +<?php +/* + * Laconica - a distributed open-source microblogging tool + * Copyright (C) 2008,2009 Control Yourself, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +// Abort if called from a web server + +if (isset($_SERVER) && array_key_exists('REQUEST_METHOD', $_SERVER)) { + print "This script must be run from the command line\n"; + exit(); +} + +define('INSTALLDIR', realpath(dirname(__FILE__) . '/..')); +define('LACONICA', true); + +require_once(INSTALLDIR . '/lib/common.php'); +require_once(INSTALLDIR . '/lib/queuehandler.php'); + +set_error_handler('common_error_handler'); + +class InboxQueueHandler extends QueueHandler +{ + function transport() + { + return 'inbox'; + } + + function start() { + $this->log(LOG_INFO, "INITIALIZE"); + return true; + } + + function handle_notice($notice) + { + $this->log(LOG_INFO, "Distributing notice to inboxes for $notice->id"); + $notice->addToInboxes(); + $notice->blowSubsCache(); + return true; + } + + function finish() { + } +} + +ini_set("max_execution_time", "0"); +ini_set("max_input_time", "0"); +set_time_limit(0); +mb_internal_encoding('UTF-8'); + +$id = ($argc > 1) ? $argv[1] : null; + +$handler = new InboxQueueHandler($id); + +$handler->runOnce(); diff --git a/scripts/jabberqueuehandler.php b/scripts/jabberqueuehandler.php index 924fc4545..8b6e974c0 100755 --- a/scripts/jabberqueuehandler.php +++ b/scripts/jabberqueuehandler.php @@ -54,6 +54,13 @@ class JabberQueueHandler extends XmppQueueHandler } } +// Abort immediately if xmpp is not enabled, otherwise the daemon chews up +// lots of CPU trying to connect to unconfigured servers +if (common_config('xmpp','enabled')==false) { + print "Aborting daemon - xmpp is disabled\n"; + exit(); +} + ini_set("max_execution_time", "0"); ini_set("max_input_time", "0"); set_time_limit(0); @@ -63,4 +70,4 @@ $resource = ($argc > 1) ? $argv[1] : (common_config('xmpp','resource') . '-queue $handler = new JabberQueueHandler($resource); -$handler->runOnce();
\ No newline at end of file +$handler->runOnce(); diff --git a/scripts/memcachedqueuehandler.php b/scripts/memcachedqueuehandler.php new file mode 100755 index 000000000..185b781f7 --- /dev/null +++ b/scripts/memcachedqueuehandler.php @@ -0,0 +1,70 @@ +#!/usr/bin/env php +<?php +/* + * Laconica - a distributed open-source microblogging tool + * Copyright (C) 2008,2009 Control Yourself, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +// Abort if called from a web server + +if (isset($_SERVER) && array_key_exists('REQUEST_METHOD', $_SERVER)) { + print "This script must be run from the command line\n"; + exit(); +} + +define('INSTALLDIR', realpath(dirname(__FILE__) . '/..')); +define('LACONICA', true); + +require_once(INSTALLDIR . '/lib/common.php'); +require_once(INSTALLDIR . '/lib/queuehandler.php'); + +set_error_handler('common_error_handler'); + +class MemcachedQueueHandler extends QueueHandler +{ + function transport() + { + return 'memcache'; + } + + function start() { + $this->log(LOG_INFO, "INITIALIZE"); + return true; + } + + function handle_notice($notice) + { + // XXX: fork here + $this->log(LOG_INFO, "Blowing memcached for $notice->id"); + $notice->blowCaches(); + return true; + } + + function finish() { + } + +} + +ini_set("max_execution_time", "0"); +ini_set("max_input_time", "0"); +set_time_limit(0); +mb_internal_encoding('UTF-8'); + +$id = ($argc > 1) ? $argv[1] : null; + +$handler = new MemcachedQueueHandler($id); + +$handler->runOnce(); diff --git a/scripts/publicqueuehandler.php b/scripts/publicqueuehandler.php index 5075c12df..b0fa22d43 100755 --- a/scripts/publicqueuehandler.php +++ b/scripts/publicqueuehandler.php @@ -52,6 +52,13 @@ class PublicQueueHandler extends XmppQueueHandler } } +// Abort immediately if xmpp is not enabled, otherwise the daemon chews up +// lots of CPU trying to connect to unconfigured servers +if (common_config('xmpp','enabled')==false) { + print "Aborting daemon - xmpp is disabled\n"; + exit(); +} + ini_set("max_execution_time", "0"); ini_set("max_input_time", "0"); set_time_limit(0); diff --git a/scripts/startdaemons.sh b/scripts/startdaemons.sh index c3729761d..66f9ed4e0 100755 --- a/scripts/startdaemons.sh +++ b/scripts/startdaemons.sh @@ -24,7 +24,8 @@ DIR=`dirname $0` for f in xmppdaemon.php jabberqueuehandler.php publicqueuehandler.php \ xmppconfirmhandler.php smsqueuehandler.php ombqueuehandler.php \ - twitterqueuehandler.php facebookqueuehandler.php pingqueuehandler.php; do + twitterqueuehandler.php facebookqueuehandler.php pingqueuehandler.php \ + memcachedqueuehandler.php inboxqueuehandler.php; do echo -n "Starting $f..."; php $DIR/$f diff --git a/scripts/stopdaemons.sh b/scripts/stopdaemons.sh index 2bb8f9ecb..196991de0 100755 --- a/scripts/stopdaemons.sh +++ b/scripts/stopdaemons.sh @@ -24,7 +24,8 @@ SDIR=`dirname $0` DIR=`php $SDIR/getpiddir.php` for f in jabberhandler ombhandler publichandler smshandler pinghandler \ - xmppconfirmhandler xmppdaemon twitterhandler facebookhandler ; do + xmppconfirmhandler xmppdaemon twitterhandler facebookhandler \ + memcachedhandler inboxhandler; do FILES="$DIR/$f.*.pid" for ff in "$FILES" ; do diff --git a/scripts/xmppconfirmhandler.php b/scripts/xmppconfirmhandler.php index 2b8b085ce..7f39235fe 100755 --- a/scripts/xmppconfirmhandler.php +++ b/scripts/xmppconfirmhandler.php @@ -140,6 +140,13 @@ class XmppConfirmHandler extends XmppQueueHandler } } +// Abort immediately if xmpp is not enabled, otherwise the daemon chews up +// lots of CPU trying to connect to unconfigured servers +if (common_config('xmpp','enabled')==false) { + print "Aborting daemon - xmpp is disabled\n"; + exit(); +} + ini_set("max_execution_time", "0"); ini_set("max_input_time", "0"); set_time_limit(0); diff --git a/scripts/xmppdaemon.php b/scripts/xmppdaemon.php index ef3f8c63d..b79fa1b3b 100755 --- a/scripts/xmppdaemon.php +++ b/scripts/xmppdaemon.php @@ -152,11 +152,6 @@ class XMPPDaemon extends Daemon $body = preg_replace('/d[\ ]*('. $to .')[\ ]*/', '', $pl['body']); $this->add_direct($user, $body, $to, $from); } else { - $len = mb_strlen($pl['body']); - if($len > 140) { - $this->from_site($from, 'Message too long - maximum is 140 characters, you sent ' . $len); - return; - } $this->add_notice($user, $pl); } @@ -255,15 +250,13 @@ class XMPPDaemon extends Daemon function add_notice(&$user, &$pl) { $body = trim($pl['body']); - $content_shortened = common_shorten_link($body); + $content_shortened = common_shorten_links($body); if (mb_strlen($content_shortened) > 140) { - $content = trim(mb_substr($body, 0, 140)); - $content_shortened = common_shorten_link($content); - } - else { - $content = $body; + $from = jabber_normalize_jid($pl['from']); + $this->from_site($from, "Message too long - maximum is 140 characters, you sent ".mb_strlen($content_shortened)); + return; } - $notice = Notice::saveNew($user->id, $content, 'xmpp'); + $notice = Notice::saveNew($user->id, $content_shortened, 'xmpp'); if (is_string($notice)) { $this->log(LOG_ERR, $notice); return; @@ -321,6 +314,13 @@ class XMPPDaemon extends Daemon } } +// Abort immediately if xmpp is not enabled, otherwise the daemon chews up +// lots of CPU trying to connect to unconfigured servers +if (common_config('xmpp','enabled')==false) { + print "Aborting daemon - xmpp is disabled\n"; + exit(); +} + ini_set("max_execution_time", "0"); ini_set("max_input_time", "0"); set_time_limit(0); diff --git a/theme/base/css/display.css b/theme/base/css/display.css index 2fb1c007f..0bc2e68b6 100644 --- a/theme/base/css/display.css +++ b/theme/base/css/display.css @@ -86,7 +86,7 @@ border:0; .error, .success { -padding:4px 7px; +padding:4px 1.55%; border-radius:4px; -moz-border-radius:4px; -webkit-border-radius:4px; @@ -426,6 +426,7 @@ line-height:1; #form_notice fieldset { border:0; padding:0; +position:relative; } #form_notice legend { display:none; @@ -480,7 +481,13 @@ margin-bottom:7px; margin-left:18px; float:left; } - +#form_notice .error { +float:left; +clear:both; +width:96.9%; +margin-bottom:0; +line-height:1.618; +} /* entity_profile */ .entity_profile { |