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'
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.
-
-A full changelog is available at http://status.net/wiki/StatusNet_0.9.7.
+- 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.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
=============
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.)
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
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.
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
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
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
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
/* 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()
* @return array Array of objects, in order
*/
function multiGet($cls, $keyCol, $keyVals, $skipNulls=true)
+ {
+ $result = self::pivotGet($cls, $keyCol, $keyVals);
+
+ $values = array_values($result);
+
+ if ($skipNulls) {
+ $tmp = array();
+ foreach ($values as $value) {
+ if (!empty($value)) {
+ $tmp[] = $value;
+ }
+ }
+ $values = $tmp;
+ }
+
+ 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) {
- $i = self::getcached($cls, $keyCol, $keyVal);
+
+ $kv = array_merge($otherCols, array($keyCol => $keyVal));
+
+ $i = self::multicache($cls, $kv);
+
if ($i !== false) {
$result[$keyVal] = $i;
} else if (!empty($keyVal)) {
$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()) {
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::cachekey($cls, $keyCol, $keyVal);
+ $ck = self::multicacheKey($cls, $kv);
$c->set($ck, null);
}
}
}
}
- $values = array_values($result);
-
- if ($skipNulls) {
- $tmp = array();
- foreach ($values as $value) {
- if (!empty($value)) {
- $tmp[] = $value;
- }
- }
- $values = $tmp;
- }
-
- return new ArrayWrapper($values);
+ return $result;
}
function columnType($columnName)
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.
return $this->_profile;
}
+
+ function _setProfile($profile)
+ {
+ $this->_profile = $profile;
+ }
function delete()
{
*/
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();
}
/**
$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();
}
/**
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;
}
}
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);
+ }
}
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
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))) {
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');
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)
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)
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]);
+ }
+ }
}
/* 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
*/
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
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
}
throw new PEAR_Exception($err->getMessage());
}
+
PEAR::setErrorHandling(PEAR_ERROR_CALLBACK, 'PEAR_ErrorToPEAR_Exception');
$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
$this->out->elementEnd('ol');
$this->out->elementEnd('div');
- return $cnt;
+ return $total;
}
/**
{
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));
+ }
+ }
}
$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) {
$this->out->elementEnd('ol');
$this->out->elementEnd('div');
- return $cnt;
+ return $total;
}
/**
$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);
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)
}
/**
- * 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)
case 'CancelrsvpAction':
case 'ShoweventAction':
case 'ShowrsvpAction':
+ case 'TimelistAction':
include_once $dir . '/' . strtolower(mb_substr($cls, 0, -6)) . '.php';
return false;
case 'EventListItem':
case 'EventForm':
case 'RSVPForm':
case 'CancelRSVPForm':
+ case 'EventTimeList':
include_once $dir . '/'.strtolower($cls).'.php';
break;
case 'Happening':
$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;
}
function onEndShowScripts($action)
{
- $action->inlineScript('$(document).ready(function() { $("#event-startdate").datepicker(); $("#event-enddate").datepicker(); });');
+ $action->script($this->path('event.js'));
}
function onEndShowStyles($action)
.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
--- /dev/null
+$(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('<option value="' + key + '">' + val + '</option>');
+ });
+
+ $("#event-endtime").html(times.join(''));
+ if (startt < endt) {
+ $("#event-endtime").val(endt).attr("selected", "selected");
+ }
+ })
+ }
+
+ $("#event-starttime").change(function(e) {
+ updateTimes();
+ });
+
+});
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();
$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.'),
$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
$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
--- /dev/null
+<?php
+/**
+ * Helper class for calculating and displaying event times
+ *
+ * PHP version 5
+ *
+ * @category Data
+ * @package StatusNet
+ * @author Zach Copley <zach@status.net>
+ * @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 <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * 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;
+ }
+
+}
+
+
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
$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;
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')) {
}
}
+ // @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
*
--- /dev/null
+<?php
+/**
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ * @category Event
+ * @package StatusNet
+ * @author Zach Copley <zach@status.net>
+ * @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);
+ }
+ }
+}
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()
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
*
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 */