diff --git a/README b/README index 6a0633144b..5db5d9bfa2 100644 --- a/README +++ b/README @@ -2,19 +2,19 @@ README ------ -StatusNet 0.9.7 "World Leader Pretend" -17 March 2011 +StatusNet 1.0.0beta2 +2 August 2011 -This is the README file for StatusNet, the Open Source microblogging -platform. It includes installation instructions, descriptions of -options you can set, warnings, tips, and general info for -administrators. Information on using StatusNet can be found in the +This is the README file for StatusNet, the Open Source social +networking platform. It includes installation instructions, +descriptions of options you can set, warnings, tips, and general info +for administrators. Information on using StatusNet can be found in the "doc" subdirectory or in the "help" section on-line. About ===== -StatusNet is a Free and Open Source microblogging platform. It helps +StatusNet is a Free and Open Source social networking platform. It helps people in a community, company or group to exchange short (140 characters, by default) messages over the Web. Users can choose which people to "follow" and receive only their friends' or colleagues' @@ -96,47 +96,27 @@ for additional terms. New this version ================ -This is a security, bug and feature release since version 0.9.6 released on -23 October 2010. - -For best compatibility with client software and site federation, and a -lot of bug fixes, it is highly recommended that all public sites -upgrade to the new version. Upgrades require new database indexes for -best performance; see Upgrade below. +This is a security release since version 0.9.7 released on 11 March +2011. It fixes security bug #3260. All sites running version 0.9.7 or +below are recommended to upgrade to 0.9.9 immediately. Notable changes this version: -- GroupPrivateMessage plugin lets users send private messages - to a group. (Similar to "private groups" on Yammer.) -- Support for Twitter streaming API in Twitter bridge plugin -- Support for a new Activity Streams-based API using AtomPub, allowing - richer API data. See http://status.net/wiki/AtomPub for details. -- Unified Facebook plugin, replacing previous Facebook application - and Facebook Connect plugin. -- A plugin to send out a daily summary email to network users. -- In-line thumbnails of some attachments (video, images) and oEmbed objects. -- Local copies of remote profiles to let moderators manage OStatus users. -- Upgrade upstream JS, minify everything. -- Allow pushing plugin JS, CSS, and static files to a CDN. -- Configurable nickname rules. -- Better support for bit.ly URL shortener. -- InProcessCache plugin for additional caching on top of memcached. -- Support for Activity Streams JSON feeds on many streams. -- User-initiated backup and restore of account data in Activity Streams - format. -- Bookmark plugin for making del.icio.us-like social bookmarking sites, - including del.icio.us backup file import. Supports OStatus. -- SQLProfile plugin to tune SQL queries. -- Better sorting on timelines to support restored or imported data. -- Hundreds of translations from http://translatewiki.net/ -- Hundreds of performance tunings, bug fixes, and UI improvements. -- Remove deprecated data from Activity Streams Atom output, to the - extent possible. -- NewMenu plugin for new layout of menu items. -- Experimental support for moving an account from one server to - another, using new AtomPub API. +- Fix bug #3260, a cross-site scripting (XSS) bug that allows an + attacker to inject JavaScript into a page with a carefully structured URL. +- Updated code for Google Analytics to reflect new API. +- Various fixes for Bookmark plugin. +- Updates to reCAPTCHA plugin based on changes to API. +- New plugin to move the site notice to the sidebar. +- Add rss.me to notice source list. +- Updates to data backup/restore. +- Correct use of "likes" in Facebook plugin. +- Ignore failures in Twitter plugin. -A full changelog is available at http://status.net/wiki/StatusNet_0.9.7. +A full changelog is available at http://status.net/wiki/StatusNet_0.9.9. + +NOTE: The short-lived StatusNet 0.9.8 ("Letter Never Sent") did not +adequately fix bug #3260 as originally thought; thus this new release. Prerequisites ============= @@ -246,9 +226,9 @@ especially if you've previously installed PHP/MySQL packages. 1. Unpack the tarball you downloaded on your Web server. Usually a command like this will work: - tar zxf statusnet-0.9.7.tar.gz + tar zxf statusnet-0.9.9.tar.gz - ...which will make a statusnet-0.9.7 subdirectory in your current + ...which will make a statusnet-0.9.9 subdirectory in your current directory. (If you don't have shell access on your Web server, you may have to unpack the tarball on your local computer and FTP the files to the server.) @@ -256,7 +236,7 @@ especially if you've previously installed PHP/MySQL packages. 2. Move the tarball to a directory of your choosing in your Web root directory. Usually something like this will work: - mv statusnet-0.9.7 /var/www/statusnet + mv statusnet-0.9.9 /var/www/statusnet This will make your StatusNet instance available in the statusnet path of your server, like "http://example.net/statusnet". "microblog" or @@ -494,7 +474,7 @@ off of amd64 to another server. Public feed ----------- -You can send *all* messages from your microblogging site to a +You can send *all* messages from your social networking site to a third-party service using XMPP. This can be useful for providing search, indexing, bridging, or other cool services. @@ -634,7 +614,7 @@ Private The administrator can set the "private" flag for a site so that it's not visible to non-logged-in users. This might be useful for -workgroups who want to share a microblogging site for project +workgroups who want to share a social networking site for project management, but host it on a public server. Total privacy is not guaranteed or ensured. Also, privacy is @@ -671,7 +651,7 @@ with this situation. If you've been using StatusNet 0.7, 0.6, 0.5 or lower, or if you've been tracking the "git" version of the software, you will probably want to upgrade and keep your existing data. There is no automated -upgrade procedure in StatusNet 0.9.7. Try these step-by-step +upgrade procedure in StatusNet 0.9.9. Try these step-by-step instructions; read to the end first before trying them. 0. Download StatusNet and set up all the prerequisites as if you were @@ -692,7 +672,7 @@ instructions; read to the end first before trying them. 5. Once all writing processes to your site are turned off, make a final backup of the Web directory and database. 6. Move your StatusNet directory to a backup spot, like "statusnet.bak". -7. Unpack your StatusNet 0.9.7 tarball and move it to "statusnet" or +7. Unpack your StatusNet 0.9.9 tarball and move it to "statusnet" or wherever your code used to be. 8. Copy the config.php file and the contents of the avatar/, background/, file/, and local/ subdirectories from your old directory to your new @@ -1753,8 +1733,8 @@ There are several ways to get more information about StatusNet. Feedback ======== -* Microblogging messages to http://support.status.net/ are very welcome. -* The microblogging group http://identi.ca/group/statusnet is a good +* Messages to http://support.status.net/ are very welcome. +* The group http://identi.ca/group/statusnet is a good place to discuss the software. * StatusNet has a bug tracker for any defects you may find, or ideas for making things better. http://status.net/bugs diff --git a/classes/Avatar.php b/classes/Avatar.php index 0b5141ba53..bdf3739bbf 100644 --- a/classes/Avatar.php +++ b/classes/Avatar.php @@ -27,6 +27,11 @@ class Avatar extends Memcached_DataObject /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE + static function pivotGet($keyCol, $keyVals, $otherCols) + { + return Memcached_DataObject::pivotGet('Avatar', $keyCol, $keyVals, $otherCols); + } + // We clean up the file, too function delete() diff --git a/classes/Memcached_DataObject.php b/classes/Memcached_DataObject.php index 0eae9fb42a..9c92003e5c 100644 --- a/classes/Memcached_DataObject.php +++ b/classes/Memcached_DataObject.php @@ -76,46 +76,7 @@ class Memcached_DataObject extends Safe_DataObject */ function multiGet($cls, $keyCol, $keyVals, $skipNulls=true) { - $result = array_fill_keys($keyVals, null); - - $toFetch = array(); - - foreach ($keyVals as $keyVal) { - $i = self::getcached($cls, $keyCol, $keyVal); - if ($i !== false) { - $result[$keyVal] = $i; - } else if (!empty($keyVal)) { - $toFetch[] = $keyVal; - } - } - - if (count($toFetch) > 0) { - $i = DB_DataObject::factory($cls); - if (empty($i)) { - throw new Exception(_('Cannot instantiate class ' . $cls)); - } - $i->whereAddIn($keyCol, $toFetch, $i->columnType($keyCol)); - if ($i->find()) { - while ($i->fetch()) { - $copy = clone($i); - $copy->encache(); - $result[$i->$keyCol] = $copy; - } - } - - // Save state of DB misses - - foreach ($toFetch as $keyVal) { - if (empty($result[$keyVal])) { - // save the fact that no such row exists - $c = self::memcache(); - if (!empty($c)) { - $ck = self::cachekey($cls, $keyCol, $keyVal); - $c->set($ck, null); - } - } - } - } + $result = self::pivotGet($cls, $keyCol, $keyVals); $values = array_values($result); @@ -131,6 +92,70 @@ class Memcached_DataObject extends Safe_DataObject return new ArrayWrapper($values); } + + /** + * Get multiple items from the database by key + * + * @param string $cls Class to fetch + * @param string $keyCol name of column for key + * @param array $keyVals key values to fetch + * @param boolean $otherCols Other columns to hold fixed + * + * @return array Array mapping $keyVals to objects, or null if not found + */ + static function pivotGet($cls, $keyCol, $keyVals, $otherCols = array()) + { + $result = array_fill_keys($keyVals, null); + + $toFetch = array(); + + foreach ($keyVals as $keyVal) { + + $kv = array_merge($otherCols, array($keyCol => $keyVal)); + + $i = self::multicache($cls, $kv); + + if ($i !== false) { + $result[$keyVal] = $i; + } else if (!empty($keyVal)) { + $toFetch[] = $keyVal; + } + } + + if (count($toFetch) > 0) { + $i = DB_DataObject::factory($cls); + if (empty($i)) { + throw new Exception(_('Cannot instantiate class ' . $cls)); + } + foreach ($otherCols as $otherKeyCol => $otherKeyVal) { + $i->$otherKeyCol = $otherKeyVal; + } + $i->whereAddIn($keyCol, $toFetch, $i->columnType($keyCol)); + if ($i->find()) { + while ($i->fetch()) { + $copy = clone($i); + $copy->encache(); + $result[$i->$keyCol] = $copy; + } + } + + // Save state of DB misses + + foreach ($toFetch as $keyVal) { + if (empty($result[$keyVal])) { + $kv = array_merge($otherCols, array($keyCol => $keyVal)); + // save the fact that no such row exists + $c = self::memcache(); + if (!empty($c)) { + $ck = self::multicacheKey($cls, $kv); + $c->set($ck, null); + } + } + } + } + + return $result; + } function columnType($columnName) { diff --git a/classes/Notice.php b/classes/Notice.php index 5caecff8f3..918190a24c 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -106,7 +106,7 @@ class Notice extends Memcached_DataObject function getProfile() { if (is_int($this->_profile) && $this->_profile == -1) { - $this->_profile = Profile::staticGet('id', $this->profile_id); + $this->_setProfile(Profile::staticGet('id', $this->profile_id)); if (empty($this->_profile)) { // TRANS: Server exception thrown when a user profile for a notice cannot be found. @@ -117,6 +117,11 @@ class Notice extends Memcached_DataObject return $this->_profile; } + + function _setProfile($profile) + { + $this->_profile = $profile; + } function delete() { @@ -1366,17 +1371,11 @@ class Notice extends Memcached_DataObject */ function getReplyProfiles() { - $ids = $this->getReplies(); - $profiles = array(); - - foreach ($ids as $id) { - $profile = Profile::staticGet('id', $id); - if (!empty($profile)) { - $profiles[] = $profile; - } - } + $ids = $this->getReplies(); - return $profiles; + $profiles = Profile::multiGet('id', $ids); + + return $profiles->fetchAll(); } /** @@ -1433,25 +1432,14 @@ class Notice extends Memcached_DataObject $gi->notice_id = $this->id; - if ($gi->find()) { - while ($gi->fetch()) { - $ids[] = $gi->group_id; - } - } - + $ids = $gi->fetchAll('group_id'); + self::cacheSet($keypart, implode(',', $ids)); } - $groups = array(); - - foreach ($ids as $id) { - $group = User_group::staticGet('id', $id); - if ($group) { - $groups[] = $group; - } - } - - return $groups; + $groups = User_group::multiGet('id', $ids); + + return $groups->fetchAll(); } /** @@ -2382,11 +2370,10 @@ class Notice extends Memcached_DataObject if ($this->scope & Notice::ADDRESSEE_SCOPE) { - // XXX: just query for the single reply - - $replies = $this->getReplies(); - - if (!in_array($profile->id, $replies)) { + $repl = Reply::pkeyGet(array('notice_id' => $this->id, + 'profile_id' => $profile->id)); + + if (empty($repl)) { return false; } } @@ -2492,4 +2479,28 @@ class Notice extends Memcached_DataObject return $scope; } + static function fillProfiles($notices) + { + $map = self::getProfiles($notices); + + foreach ($notices as $notice) { + if (array_key_exists($notice->profile_id, $map)) { + $notice->_setProfile($map[$notice->profile_id]); + } + } + + return array_values($map); + } + + static function getProfiles(&$notices) + { + $ids = array(); + foreach ($notices as $notice) { + $ids[] = $notice->profile_id; + } + + $ids = array_unique($ids); + + return Memcached_DataObject::pivotGet('Profile', 'id', $ids); + } } diff --git a/classes/Profile.php b/classes/Profile.php index 23534dfdfd..d5008d9fb8 100644 --- a/classes/Profile.php +++ b/classes/Profile.php @@ -49,6 +49,11 @@ class Profile extends Memcached_DataObject return Memcached_DataObject::staticGet('Profile',$k,$v); } + function multiGet($keyCol, $keyVals, $skipNulls=true) + { + return parent::multiGet('Profile', $keyCol, $keyVals, $skipNulls); + } + /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE @@ -63,12 +68,18 @@ class Profile extends Memcached_DataObject return $this->_user; } + protected $_avatars = array(); + function getAvatar($width, $height=null) { if (is_null($height)) { $height = $width; } + if (array_key_exists($width, $this->_avatars)) { + return $this->_avatars[$width]; + } + $avatar = null; if (Event::handle('StartProfileGetAvatar', array($this, $width, &$avatar))) { @@ -78,9 +89,16 @@ class Profile extends Memcached_DataObject Event::handle('EndProfileGetAvatar', array($this, $width, &$avatar)); } + $this->_avatars[$width] = $avatar; + return $avatar; } + function _fillAvatar($width, $avatar) + { + $this->_avatars[$width] = $avatar; + } + function getOriginalAvatar() { $avatar = DB_DataObject::factory('avatar'); @@ -225,9 +243,14 @@ class Profile extends Memcached_DataObject function isMember($group) { - $gm = Group_member::pkeyGet(array('profile_id' => $this->id, - 'group_id' => $group->id)); - return (!empty($gm)); + $groups = $this->getGroups(0, null); + $gs = $groups->fetchAll(); + foreach ($gs as $g) { + if ($group->id == $g->id) { + return true; + } + } + return false; } function isAdmin($group) @@ -268,16 +291,7 @@ class Profile extends Memcached_DataObject self::cacheSet($keypart, implode(',', $ids)); } - $groups = array(); - - foreach ($ids as $id) { - $group = User_group::staticGet('id', $id); - if (!empty($group)) { - $groups[] = $group; - } - } - - return new ArrayWrapper($groups); + return User_group::multiGet('id', $ids); } function isTagged($peopletag) @@ -1357,7 +1371,22 @@ class Profile extends Memcached_DataObject function __sleep() { $vars = parent::__sleep(); - $skip = array('_user'); + $skip = array('_user', '_avatars'); return array_diff($vars, $skip); } + + static function fillAvatars(&$profiles, $width) + { + $ids = array(); + foreach ($profiles as $profile) { + $ids[] = $profile->id; + } + + $avatars = Avatar::pivotGet('profile_id', $ids, array('width' => $width, + 'height' => $width)); + + foreach ($profiles as $profile) { + $profile->_fillAvatar($width, $avatars[$profile->id]); + } + } } diff --git a/classes/Reply.php b/classes/Reply.php index 9ba623ba3f..acda0fecb4 100644 --- a/classes/Reply.php +++ b/classes/Reply.php @@ -22,6 +22,11 @@ class Reply extends Memcached_DataObject /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE + function pkeyGet($kv) + { + return Memcached_DataObject::pkeyGet('Reply',$kv); + } + /** * Wrapper for record insertion to update related caches */ diff --git a/classes/User_group.php b/classes/User_group.php index 6168f219b9..38cc5603db 100644 --- a/classes/User_group.php +++ b/classes/User_group.php @@ -33,6 +33,11 @@ class User_group extends Memcached_DataObject function staticGet($k,$v=NULL) { return Memcached_DataObject::staticGet('User_group',$k,$v); } + + function multiGet($keyCol, $keyVals, $skipNulls=true) + { + return parent::multiGet('User_group', $keyCol, $keyVals, $skipNulls); + } /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE diff --git a/lib/framework.php b/lib/framework.php index 7c92d14722..3a338ea888 100644 --- a/lib/framework.php +++ b/lib/framework.php @@ -20,7 +20,7 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } define('STATUSNET_BASE_VERSION', '1.0.0'); -define('STATUSNET_LIFECYCLE', 'beta1'); // 'dev', 'alpha[0-9]+', 'beta[0-9]+', 'rc[0-9]+', 'release' +define('STATUSNET_LIFECYCLE', 'beta2'); // 'dev', 'alpha[0-9]+', 'beta[0-9]+', 'rc[0-9]+', 'release' define('STATUSNET_VERSION', STATUSNET_BASE_VERSION . STATUSNET_LIFECYCLE); define('LACONICA_VERSION', STATUSNET_VERSION); // compatibility @@ -156,4 +156,5 @@ function PEAR_ErrorToPEAR_Exception($err) } throw new PEAR_Exception($err->getMessage()); } + PEAR::setErrorHandling(PEAR_ERROR_CALLBACK, 'PEAR_ErrorToPEAR_Exception'); diff --git a/lib/noticelist.php b/lib/noticelist.php index f18b2d6684..a4781d9daa 100644 --- a/lib/noticelist.php +++ b/lib/noticelist.php @@ -83,17 +83,16 @@ class NoticeList extends Widget $this->out->elementStart('div', array('id' =>'notices_primary')); $this->out->elementStart('ol', array('class' => 'notices xoxo')); - $cnt = 0; - - while ($this->notice->fetch() && $cnt <= NOTICES_PER_PAGE) { - $cnt++; - - if ($cnt > NOTICES_PER_PAGE) { - break; - } + $notices = $this->notice->fetchAll(); + $total = count($notices); + $notices = array_slice($notices, 0, NOTICES_PER_PAGE); + + self::prefill($notices); + + foreach ($notices as $notice) { try { - $item = $this->newListItem($this->notice); + $item = $this->newListItem($notice); $item->show(); } catch (Exception $e) { // we log exceptions and continue @@ -105,7 +104,7 @@ class NoticeList extends Widget $this->out->elementEnd('ol'); $this->out->elementEnd('div'); - return $cnt; + return $total; } /** @@ -122,4 +121,24 @@ class NoticeList extends Widget { return new NoticeListItem($notice, $this->out); } + + static function prefill(&$notices, $avatarSize=AVATAR_STREAM_SIZE) + { + // Prefill the profiles + $profiles = Notice::fillProfiles($notices); + // Prefill the avatars + Profile::fillAvatars($profiles, $avatarSize); + + $p = Profile::current(); + + $ids = array(); + + foreach ($notices as $notice) { + $ids[] = $notice->id; + } + + if (!empty($p)) { + Memcached_DataObject::pivotGet('Fave', 'notice_id', $ids, array('user_id' => $p->id)); + } + } } diff --git a/lib/threadednoticelist.php b/lib/threadednoticelist.php index 43494bab1a..407f7bdde3 100644 --- a/lib/threadednoticelist.php +++ b/lib/threadednoticelist.php @@ -76,17 +76,18 @@ class ThreadedNoticeList extends NoticeList $this->out->element('h2', null, _m('HEADER','Notices')); $this->out->elementStart('ol', array('class' => 'notices threaded-notices xoxo')); - $cnt = 0; + $notices = $this->notice->fetchAll(); + $total = count($notices); + $notices = array_slice($notices, 0, NOTICES_PER_PAGE); + + self::prefill($notices); + $conversations = array(); - while ($this->notice->fetch() && $cnt <= NOTICES_PER_PAGE) { - $cnt++; - - if ($cnt > NOTICES_PER_PAGE) { - break; - } + + foreach ($notices as $notice) { // Collapse repeats into their originals... - $notice = $this->notice; + if ($notice->repeat_of) { $orig = Notice::staticGet('id', $notice->repeat_of); if ($orig) { @@ -119,7 +120,7 @@ class ThreadedNoticeList extends NoticeList $this->out->elementEnd('ol'); $this->out->elementEnd('div'); - return $cnt; + return $total; } /** @@ -223,6 +224,7 @@ class ThreadedNoticeListItem extends NoticeListItem $item = new ThreadedNoticeListMoreItem($moreCutoff, $this->out, count($notices)); $item->show(); } + NoticeList::prefill($notices, AVATAR_MINI_SIZE); foreach (array_reverse($notices) as $notice) { if (Event::handle('StartShowThreadedNoticeSub', array($this, $this->notice, $notice))) { $item = new ThreadedNoticeListSubItem($notice, $this->notice, $this->out); diff --git a/lib/util.php b/lib/util.php index d358338519..f3be1d0ddc 100644 --- a/lib/util.php +++ b/lib/util.php @@ -1127,8 +1127,11 @@ function common_tag_link($tag) function common_canonical_tag($tag) { + // only alphanum + $tag = preg_replace('/[^\pL\pN]/u', '', $tag); $tag = mb_convert_case($tag, MB_CASE_LOWER, "UTF-8"); - return str_replace(array('-', '_', '.'), '', $tag); + $tag = substr($tag, 0, 64); + return $tag; } function common_valid_profile_tag($str) @@ -1501,16 +1504,18 @@ function common_enqueue_notice($notice) } /** - * Broadcast profile updates to remote subscribers. + * Legacy function to broadcast profile updates to OMB remote subscribers. + * + * XXX: This probably needs killing, but there are several bits of code + * that broadcast profile changes that need to be dealt with. AFAIK + * this function is only used for OMB. -z * * Since this may be slow with a lot of subscribers or bad remote sites, * this is run through the background queues if possible. */ function common_broadcast_profile(Profile $profile) { - $qm = QueueManager::get(); - $qm->enqueue($profile, "profile"); - return true; + Event::handle('BroadcastProfile', array($profile)); } function common_profile_url($nickname) diff --git a/plugins/Event/EventPlugin.php b/plugins/Event/EventPlugin.php index 98a7d895ed..f2396b8075 100644 --- a/plugins/Event/EventPlugin.php +++ b/plugins/Event/EventPlugin.php @@ -82,6 +82,7 @@ class EventPlugin extends MicroappPlugin case 'CancelrsvpAction': case 'ShoweventAction': case 'ShowrsvpAction': + case 'TimelistAction': include_once $dir . '/' . strtolower(mb_substr($cls, 0, -6)) . '.php'; return false; case 'EventListItem': @@ -89,6 +90,7 @@ class EventPlugin extends MicroappPlugin case 'EventForm': case 'RSVPForm': case 'CancelRSVPForm': + case 'EventTimeList': include_once $dir . '/'.strtolower($cls).'.php'; break; case 'Happening': @@ -121,6 +123,8 @@ class EventPlugin extends MicroappPlugin $m->connect('rsvp/:id', array('action' => 'showrsvp'), array('id' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')); + $m->connect('main/event/updatetimes', + array('action' => 'timelist')); return true; } @@ -345,7 +349,7 @@ class EventPlugin extends MicroappPlugin function onEndShowScripts($action) { - $action->inlineScript('$(document).ready(function() { $("#event-startdate").datepicker(); $("#event-enddate").datepicker(); });'); + $action->script($this->path('event.js')); } function onEndShowStyles($action) diff --git a/plugins/Event/event.css b/plugins/Event/event.css index 8c9cbbb082..7fbb67d732 100644 --- a/plugins/Event/event.css +++ b/plugins/Event/event.css @@ -6,3 +6,11 @@ .event-title { margin-left: 0px; } #content .event .entry-title { margin-left: 0px; } #content .event .entry-content { margin-left: 0px; } +.ui-autocomplete { + max-height: 100px; + overflow-y: auto; + /* prevent horizontal scrollbar */ + overflow-x: hidden; + /* add padding to account for vertical scrollbar */ + padding-right: 20px; +} \ No newline at end of file diff --git a/plugins/Event/event.js b/plugins/Event/event.js new file mode 100644 index 0000000000..8ed25a899b --- /dev/null +++ b/plugins/Event/event.js @@ -0,0 +1,73 @@ +$(document).ready(function() { + + var today = new Date(); + + $("#event-startdate").datepicker({ + // Don't let the user set a crazy start date + minDate: today, + onClose: function(dateText, picker) { + // Don't let the user set a crazy end date + var newStartDate = new Date(dateText); + var endDate = new Date($("#event-startdate").val()); + if (endDate < newStartDate) { + $("#event-enddate").val(dateText); + } + if (dateText !== null) { + $("#event-enddate").datepicker('option', 'minDate', new Date(dateText)); + } + }, + onSelect: function() { + var startd = $("#event-startdate").val(); + var endd = $("#event-enddate").val(); + var sdate = new Date(startd); + var edate = new Date(endd); + if (sdate !== edate) { + updateTimes(); + } + } + }); + + $("#event-enddate").datepicker({ + minDate: today, + onSelect: function() { + var startd = $("#event-startdate").val(); + var endd = $("#event-enddate").val(); + var sdate = new Date(startd); + var edate = new Date(endd); + if (sdate !== edate) { + updateTimes(); + } + } + }); + + function updateTimes() { + var startd = $("#event-startdate").val(); + var endd = $("#event-enddate").val(); + + var startt = $("#event-starttime option:selected").val(); + var endt = $("#event-endtime option:selected").val(); + + var sdate = new Date(startd + " " + startt); + var edate = new Date(endd + " " + endt); + var duration = (startd === endd); + + $.getJSON($('#timelist_action_url').val(), + { start: startt, ajax: true, duration: duration }, + function(data) { + var times = []; + $.each(data, function(key, val) { + times.push(''); + }); + + $("#event-endtime").html(times.join('')); + if (startt < endt) { + $("#event-endtime").val(endt).attr("selected", "selected"); + } + }) + } + + $("#event-starttime").change(function(e) { + updateTimes(); + }); + +}); diff --git a/plugins/Event/eventform.php b/plugins/Event/eventform.php index 6a6e17e77b..d7c554bf32 100644 --- a/plugins/Event/eventform.php +++ b/plugins/Event/eventform.php @@ -84,6 +84,17 @@ class EventForm extends Form function formData() { $this->out->elementStart('fieldset', array('id' => 'new_event_data')); + + // Passing in the URL of the Ajax action that the .js for this form hits + // when selecting event start and end times. JavaScript will try to + // use a relative path, unless explicitely told where an action is, + // and that's a bit difficult to calculate since the event form is on + // so many pages with different paths. It might be worth solving this + // globally by putting the base site path in the Identifier-URL meta tag + // or something similar, so it would be easy to calculate the exact path + // for actions and other things in JavaScripts. -z + $this->out->hidden('timelist_action_url', common_local_url('timelist')); + $this->out->elementStart('ul', 'form_data'); $this->li(); @@ -97,49 +108,71 @@ class EventForm extends Form $this->unli(); $this->li(); + + $today = new DateTime('today'); + $today->setTimezone(new DateTimeZone(common_timezone())); + $this->out->input('event-startdate', // TRANS: Field label on event form. _m('LABEL','Start date'), - null, + $today->format('m/d/Y'), // TRANS: Field title on event form. _m('Date the event starts.'), 'startdate'); $this->unli(); $this->li(); - $this->out->input('event-starttime', - // TRANS: Field label on event form. - _m('LABEL','Start time'), - null, - // TRANS: Field title on event form. - _m('Time the event starts.'), - 'starttime'); + + $times = EventTimeList::getTimes(); + + $this->out->dropdown( + 'event-starttime', + // TRANS: Field label on event form. + _m('LABEL','Start time'), + $times, + // TRANS: Field title on event form. + _m('Time the event starts.'), + false, + null + ); + $this->unli(); $this->li(); $this->out->input('event-enddate', // TRANS: Field label on event form. _m('LABEL','End date'), - null, + $today->format('m/d/Y'), // TRANS: Field title on event form. _m('Date the event ends.'), 'enddate'); $this->unli(); $this->li(); - $this->out->input('event-endtime', - // TRANS: Field label on event form. - _m('LABEL','End time'), - null, - // TRANS: Field title on event form. - _m('Time the event ends.'), - 'endtime'); + + // XXX: Initial end time should be at least 30 mins out? We could do + // every 15 minute instead -z + $keys = array_keys($times); + $endStr = date('m/d/y', strtotime('now')) . " {$keys[0]}"; + $end = new DateTime($endStr); + $end->modify('+30'); + + $this->out->dropdown( + 'event-endtime', + // TRANS: Field label on event form. + _m('LABEL','End time'), + EventTimeList::getTimes($end->format('c'), true), + // TRANS: Field title on event form. + _m('Time the event ends.'), + false, + null + ); $this->unli(); $this->li(); $this->out->input('event-location', // TRANS: Field label on event form. - _m('LABEL','Location'), + _m('LABEL','Where?'), null, // TRANS: Field title on event form. _m('Event location.'), diff --git a/plugins/Event/eventlistitem.php b/plugins/Event/eventlistitem.php index 9bf34e765b..fb27704461 100644 --- a/plugins/Event/eventlistitem.php +++ b/plugins/Event/eventlistitem.php @@ -83,13 +83,33 @@ class EventListItem extends NoticeListItemAdapter $out->elementEnd('h3'); // VEVENT/H3 OUT - $startDate = strftime("%x", strtotime($event->start_time)); - $startTime = strftime("%R", strtotime($event->start_time)); + $now = new DateTime(); + $startDate = new DateTime($event->start_time); + $endDate = new DateTime($event->end_time); + $userTz = new DateTimeZone(common_timezone()); - $endDate = strftime("%x", strtotime($event->end_time)); - $endTime = strftime("%R", strtotime($event->end_time)); + // Localize the time for the observer + $now->setTimeZone($userTz); + $startDate->setTimezone($userTz); + $endDate->setTimezone($userTz); - // FIXME: better dates + $thisYear = $now->format('Y'); + $startYear = $startDate->format('Y'); + $endYear = $endDate->format('Y'); + + $dateFmt = 'D, F j, '; // e.g.: Mon, Aug 31 + + if ($startYear != $thisYear || $endYear != $thisYear) { + $dateFmt .= 'Y,'; // append year if we need to think about years + } + + $startDateStr = $startDate->format($dateFmt); + $endDateStr = $endDate->format($dateFmt); + + $timeFmt = 'g:ia'; + + $startTimeStr = $startDate->format($timeFmt); + $endTimeStr = $endDate->format("{$timeFmt} (T)"); $out->elementStart('div', 'event-times'); // VEVENT/EVENT-TIMES IN @@ -98,16 +118,16 @@ class EventListItem extends NoticeListItemAdapter $out->element('abbr', array('class' => 'dtstart', 'title' => common_date_iso8601($event->start_time)), - $startDate . ' ' . $startTime); - $out->text(' - '); - if ($startDate == $endDate) { + $startDateStr . ' ' . $startTimeStr); + $out->text(' – '); + if ($startDateStr == $endDateStr) { $out->element('span', array('class' => 'dtend', 'title' => common_date_iso8601($event->end_time)), - $endTime); + $endTimeStr); } else { $out->element('span', array('class' => 'dtend', 'title' => common_date_iso8601($event->end_time)), - $endDate . ' ' . $endTime); + $endDateStr . ' ' . $endTimeStr); } $out->elementEnd('div'); // VEVENT/EVENT-TIMES OUT diff --git a/plugins/Event/eventtimelist.php b/plugins/Event/eventtimelist.php new file mode 100644 index 0000000000..4ca40cb61f --- /dev/null +++ b/plugins/Event/eventtimelist.php @@ -0,0 +1,119 @@ + + * @license http://www.fsf.org/licensing/licenses/agpl.html AGPLv3 + * @link http://status.net/ + * + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2011, StatusNet, 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 . + */ + +/** + * Class to get fancy times for the dropdowns on the new event form + */ +class EventTimeList { + + /** + * Round up to the nearest half hour + * + * @param string $time the time to round (date/time string) + * @return DateTime the rounded time + */ + public static function nearestHalfHour($time) + { + $start = strtotime($time); + + $minutes = date('i', $start); + $hour = date('H', $start); + + if ($minutes >= 30) { + $minutes = '00'; + $hour++; + } else { + $minutes = '30'; + } + + $newTimeStr = date('m/d/y', $start) . " {$hour}:{$minutes}:00"; + return new DateTime($newTimeStr); + } + + /** + * Output a list of times in half-hour intervals + * + * @param string $start Time to start with (date/time string) + * @param boolean $duration Whether to include the duration of the event + * (from the start) + * @return array $times (UTC time string => localized time string) + */ + public static function getTimes($start = 'now', $duration = false) + { + $newTime = self::nearestHalfHour($start); + + $newTime->setTimezone(new DateTimeZone(common_timezone())); + $times = array(); + $len = 0; + + for ($i = 0; $i < 48; $i++) { + + // make sure we store the time as UTC + $newTime->setTimezone(new DateTimeZone('UTC')); + $utcTime = $newTime->format('H:i:s'); + + // localize time for user + $newTime->setTimezone(new DateTimeZone(common_timezone())); + $localTime = $newTime->format('g:ia'); + + // pretty up the end-time option list a bit + if ($duration) { + $len += 30; + $hours = $len / 60; + // for i18n + $hourStr = _m('hour'); + $hoursStr = _m('hrs'); + $minStr = _m('mins'); + switch ($hours) { + case 0: + $total = " (0 {$minStr})"; + break; + case .5: + $total = " (30 {$minStr})"; + break; + case 1: + $total = " (1 {$hourStr})"; + break; + default: + $total = " ({$hours} " . $hoursStr . ')'; + break; + } + $localTime .= $total; + } + + $times[$utcTime] = $localTime; + $newTime->modify('+30min'); // 30 min intervals + } + + return $times; + } + +} + + diff --git a/plugins/Event/newevent.php b/plugins/Event/newevent.php index cadf0e1433..2704501abd 100644 --- a/plugins/Event/newevent.php +++ b/plugins/Event/newevent.php @@ -52,8 +52,8 @@ class NeweventAction extends Action protected $title = null; protected $location = null; protected $description = null; - protected $startTime = null; - protected $endTime = null; + protected $startTime = null; + protected $endTime = null; /** * Returns the title of the action @@ -89,67 +89,78 @@ class NeweventAction extends Action $this->checkSessionToken(); } - $this->title = $this->trimmed('title'); + try { - if (empty($this->title)) { - // TRANS: Client exception thrown when trying to post an event without providing a title. - throw new ClientException(_m('Title required.')); - } + $this->title = $this->trimmed('title'); - $this->location = $this->trimmed('location'); - $this->url = $this->trimmed('url'); - $this->description = $this->trimmed('description'); + if (empty($this->title)) { + // TRANS: Client exception thrown when trying to post an event without providing a title. + throw new ClientException(_m('Title required.')); + } - $startDate = $this->trimmed('startdate'); + $this->location = $this->trimmed('location'); + $this->url = $this->trimmed('url'); + $this->description = $this->trimmed('description'); - if (empty($startDate)) { - // TRANS: Client exception thrown when trying to post an event without providing a start date. - throw new ClientException(_m('Start date required.')); - } + $startDate = $this->trimmed('startdate'); - $startTime = $this->trimmed('starttime'); + if (empty($startDate)) { + // TRANS: Client exception thrown when trying to post an event without providing a start date. + throw new ClientException(_m('Start date required.')); + } - if (empty($startTime)) { - $startTime = '00:00'; - } + $startTime = $this->trimmed('event-starttime'); - $endDate = $this->trimmed('enddate'); + if (empty($startTime)) { + $startTime = '00:00'; + } - if (empty($endDate)) { - // TRANS: Client exception thrown when trying to post an event without providing an end date. - throw new ClientException(_m('End date required.')); - } + $endDate = $this->trimmed('enddate'); - $endTime = $this->trimmed('endtime'); + if (empty($endDate)) { + // TRANS: Client exception thrown when trying to post an event without providing an end date. + throw new ClientException(_m('End date required.')); + } - if (empty($endTime)) { - $endTime = '00:00'; - } + $endTime = $this->trimmed('event-endtime'); - $start = $startDate . ' ' . $startTime; + if (empty($endTime)) { + $endTime = '00:00'; + } - common_debug("Event start: '$start'"); + $start = $startDate . ' ' . $startTime; - $end = $endDate . ' ' . $endTime; + common_debug("Event start: '$start'"); - common_debug("Event start: '$end'"); + $end = $endDate . ' ' . $endTime; - $this->startTime = strtotime($start); - $this->endTime = strtotime($end); + common_debug("Event start: '$end'"); - if ($this->startTime == 0) { - // TRANS: Client exception thrown when trying to post an event with a date that cannot be processed. - // TRANS: %s is the data that could not be processed. - throw new Exception(sprintf(_m('Could not parse date "%s".'), - $start)); - } + $this->startTime = strtotime($start); + $this->endTime = strtotime($end); + if ($this->startTime == 0) { + // TRANS: Client exception thrown when trying to post an event with a date that cannot be processed. + // TRANS: %s is the data that could not be processed. + throw new ClientException(sprintf(_m('Could not parse date "%s".'), + $start)); + } - if ($this->endTime == 0) { - // TRANS: Client exception thrown when trying to post an event with a date that cannot be processed. - // TRANS: %s is the data that could not be processed. - throw new Exception(sprintf(_m('Could not parse date "%s".'), - $end)); + if ($this->endTime == 0) { + // TRANS: Client exception thrown when trying to post an event with a date that cannot be processed. + // TRANS: %s is the data that could not be processed. + throw new ClientException(sprintf(_m('Could not parse date "%s".'), + $end)); + } + } catch (ClientException $ce) { + if ($this->boolean('ajax')) { + $this->outputAjaxError($ce->getMessage()); + return false; + } else { + $this->error = $ce->getMessage(); + $this->showPage(); + return false; + } } return true; @@ -220,9 +231,13 @@ class NeweventAction extends Action RSVP::saveNew($profile, $event, RSVP::POSITIVE); } catch (ClientException $ce) { - $this->error = $ce->getMessage(); - $this->showPage(); - return; + if ($this->boolean('ajax')) { + $this->outputAjaxError($ce->getMessage()); + } else { + $this->error = $ce->getMessage(); + $this->showPage(); + return; + } } if ($this->boolean('ajax')) { @@ -242,6 +257,23 @@ class NeweventAction extends Action } } + // @todo factor this out into a base class + function outputAjaxError($msg) + { + header('Content-Type: text/xml;charset=utf-8'); + $this->xw->startDocument('1.0', 'UTF-8'); + $this->elementStart('html'); + $this->elementStart('head'); + // TRANS: Page title after an AJAX error occurs + $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'); + return; + } + /** * Show the event form * diff --git a/plugins/Event/timelist.php b/plugins/Event/timelist.php new file mode 100644 index 0000000000..a6e0174180 --- /dev/null +++ b/plugins/Event/timelist.php @@ -0,0 +1,106 @@ +. + * + * @category Event + * @package StatusNet + * @author Zach Copley + * @copyright 2011 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Callback handler to populate end time dropdown + */ +class TimelistAction extends Action { + + private $start; + private $duration; + + /** + * Get ready + * + * @param array $args misc. arguments + * + * @return boolean true + */ + function prepare($args) { + parent::prepare($args); + $this->start = $this->arg('start'); + $this->duration = $this->boolean('duration', false); + return true; + } + + /** + * Handle input and ouput something + * + * @param array $args $_REQUEST arguments + * + * @return void + */ + function handle($args) + { + parent::handle($args); + + if (!common_logged_in()) { + // TRANS: Error message displayed when trying to perform an action that requires a logged in user. + $this->clientError(_('Not logged in.')); + return; + } + + if (!empty($this->start)) { + $times = EventTimeList::getTimes($this->start, $this->duration); + } else { + $this->clientError(_m('Unexpected form submission.')); + return; + } + + if ($this->boolean('ajax')) { + header('Content-Type: application/json; charset=utf-8'); + print json_encode($times); + } else { + $this->clientError(_m('This action is AJAX only.')); + } + } + + /** + * Override the regular error handler to show something more + * ajaxy + * + * @param string $msg error message + * @param int $code error code + */ + function clientError($msg, $code = 400) { + if ($this->boolean('ajax')) { + header('Content-Type: application/json; charset=utf-8'); + print json_encode( + array( + 'success' => false, + 'code' => $code, + 'message' => $msg + ) + ); + } else { + parent::clientError($msg, $code); + } + } +} diff --git a/plugins/Meteor/MeteorPlugin.php b/plugins/Meteor/MeteorPlugin.php index 3f963eb732..1cff453d61 100644 --- a/plugins/Meteor/MeteorPlugin.php +++ b/plugins/Meteor/MeteorPlugin.php @@ -103,7 +103,11 @@ class MeteorPlugin extends RealtimePlugin function _updateInitialize($timeline, $user_id) { $script = parent::_updateInitialize($timeline, $user_id); - return $script." MeteorUpdater.init(\"$this->webserver\", $this->webport, \"{$timeline}\");"; + $ours = sprintf("MeteorUpdater.init(%s, %s, %s);", + json_encode($this->webserver), + json_encode($this->webport), + json_encode($timeline)); + return $script." ".$ours; } function _connect() diff --git a/plugins/OMB/OMBPlugin.php b/plugins/OMB/OMBPlugin.php index f5fed60079..38494c8134 100644 --- a/plugins/OMB/OMBPlugin.php +++ b/plugins/OMB/OMBPlugin.php @@ -369,6 +369,18 @@ class OMBPlugin extends Plugin return true; } + /** + * Broadcast a profile over OMB + * + * @param Profile $profile to broadcast + * @return false + */ + function onBroadcastProfile($profile) { + $qm = QueueManager::get(); + $qm->enqueue($profile, "profile"); + return true; + } + /** * Plugin version info * diff --git a/theme/neo/css/display.css b/theme/neo/css/display.css index d8bb5fc693..d7a4914a7d 100644 --- a/theme/neo/css/display.css +++ b/theme/neo/css/display.css @@ -1171,9 +1171,19 @@ td.entity_profile { width: auto; } -#event-startdate, #event-starttime, #event-enddate, #event-endtime { - width: 120px; +label[for=event-starttime], label[for=event-endtime] { + display: none; +} + +#event-starttime, #event-endtime { + margin-top: -1px; + margin-bottom: -1px; + height: 2em; +} + +#event-startdate, #event-enddate { margin-right: 20px; + width: 120px; } /* Limited-scope specific styles */